From ba5ffb5fc16eb0ac5611acf1135655aa222a19d8 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Fri, 31 Oct 2025 10:28:45 +0100 Subject: [PATCH 01/53] feat: start development cycle for 0.0.10-alpha --- src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj | 4 ++-- src/WebExpress.WebCore/WebExpress.WebCore.csproj | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj b/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj index a2ce933..1ef5e93 100644 --- a/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj +++ b/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj @@ -41,9 +41,9 @@ - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/WebExpress.WebCore/WebExpress.WebCore.csproj b/src/WebExpress.WebCore/WebExpress.WebCore.csproj index 53c7830..be8150c 100644 --- a/src/WebExpress.WebCore/WebExpress.WebCore.csproj +++ b/src/WebExpress.WebCore/WebExpress.WebCore.csproj @@ -3,8 +3,8 @@ Library WebExpress.WebCore - 0.0.9.0 - 0.0.9.0 + 0.0.10.0 + 0.0.10.0 net9.0 any https://github.com/webexpress-framework/WebExpress.git @@ -14,7 +14,7 @@ true True Core library of the WebExpress web server. - 0.0.9-alpha + 0.0.10-alpha https://github.com/webexpress-framework/WebExpress icon.png README.md From 86f1466a552431f0309b67c5a92413df3e53c39c Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 2 Nov 2025 18:30:39 +0100 Subject: [PATCH 02/53] feat: general improvements and minor bugs --- src/WebExpress.WebCore/WebLog/ILog.cs | 2 +- src/WebExpress.WebCore/WebLog/LogFrame.cs | 21 ++++----------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/WebExpress.WebCore/WebLog/ILog.cs b/src/WebExpress.WebCore/WebLog/ILog.cs index 71ff833..fdda1f8 100644 --- a/src/WebExpress.WebCore/WebLog/ILog.cs +++ b/src/WebExpress.WebCore/WebLog/ILog.cs @@ -72,7 +72,7 @@ public interface ILog : ILogger /// /// The default instance of the logger. /// - public static Log Current { get; } + public static ILog Current { get; } /// /// Set file name time patterns. diff --git a/src/WebExpress.WebCore/WebLog/LogFrame.cs b/src/WebExpress.WebCore/WebLog/LogFrame.cs index d564b12..d548c03 100644 --- a/src/WebExpress.WebCore/WebLog/LogFrame.cs +++ b/src/WebExpress.WebCore/WebLog/LogFrame.cs @@ -31,7 +31,7 @@ public class LogFrame : IDisposable /// /// The log entry. /// - protected Log Log { get; set; } + protected ILog Log { get; set; } /// /// Initializes a new instance of the class. @@ -42,30 +42,17 @@ public class LogFrame : IDisposable /// Method that wants to log. /// The line number. /// The source file. - public LogFrame(Log log, string name, string additionalHeading = null, [CallerMemberName] string instance = null, [CallerLineNumber] int? line = null, [CallerFilePath] string file = null) + public LogFrame(ILog log, string name, string additionalHeading = null, [CallerMemberName] string instance = null, [CallerLineNumber] int? line = null, [CallerFilePath] string file = null) { Instance = instance; - Status = string.Format("{0} abgeschlossen. ", name); + Status = string.Format("{0} completed. ", name); Log = log; Log.Seperator(); - Log.Info(string.Format("Beginne mit {0}", name) + (!string.IsNullOrWhiteSpace(additionalHeading) ? " " + additionalHeading : ""), instance, line, file); + Log.Info(string.Format("Starting {0}", name) + (!string.IsNullOrWhiteSpace(additionalHeading) ? " " + additionalHeading : ""), instance, line, file); Log.Info("".PadRight(80, '-'), instance, line, file); } - /// - /// Initializes a new instance of the class. - /// - /// The name. - /// The additional heading or zero. - /// Method that wants to log. - /// The line number. - /// The source file. - public LogFrame(string name, string additionalHeading = null, [CallerMemberName] string instance = null, [CallerLineNumber] int? line = null, [CallerFilePath] string file = null) - : this(Log.Current, name, additionalHeading, instance, line, file) - { - } - /// /// Release unmanaged resources that were reserved during initialization. /// From 733bc2cc93cd6e1adfd8e22479a788aaeb5b75d2 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sat, 15 Nov 2025 15:58:27 +0100 Subject: [PATCH 03/53] feat: switch to .net10.0 --- src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj | 2 +- src/WebExpress.WebCore/WebExpress.WebCore.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj b/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj index 1ef5e93..ee13ee0 100644 --- a/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj +++ b/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable disable diff --git a/src/WebExpress.WebCore/WebExpress.WebCore.csproj b/src/WebExpress.WebCore/WebExpress.WebCore.csproj index be8150c..c16f749 100644 --- a/src/WebExpress.WebCore/WebExpress.WebCore.csproj +++ b/src/WebExpress.WebCore/WebExpress.WebCore.csproj @@ -5,7 +5,7 @@ WebExpress.WebCore 0.0.10.0 0.0.10.0 - net9.0 + net10.0 any https://github.com/webexpress-framework/WebExpress.git webexpress-framework@outlook.com From 36cac73edce2ed4574610f86b72b5249d092d7ef Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 16 Nov 2025 22:58:29 +0100 Subject: [PATCH 04/53] feat: general improvements and minor bugs --- .../Manager/UnitTestApplication.cs | 8 +- .../Manager/UnitTestThemeManager.cs | 7 ++ .../WebApplication/ApplicationManager.cs | 4 +- .../WebAttribute/SegmentRegexAttribute.cs | 51 ++++++++++++ .../WebAttribute/SegmentStringAttribute.cs | 9 ++- src/WebExpress.WebCore/WebEx.cs | 5 ++ .../WebUri/UriPathSegmentVariableRegex.cs | 81 +++++++++++++++++++ 7 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 src/WebExpress.WebCore/WebAttribute/SegmentRegexAttribute.cs create mode 100644 src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestApplication.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestApplication.cs index db76fe1..c9f5d1d 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestApplication.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestApplication.cs @@ -138,7 +138,7 @@ public void ContextPath(Type applicationType, string contextPath) [Theory] [InlineData(typeof(TestApplicationA), "/asseta")] [InlineData(typeof(TestApplicationB), "/assetb")] - [InlineData(typeof(TestApplicationC), "/")] + [InlineData(typeof(TestApplicationC), "*/")] public void AssetPath(Type applicationType, string assetPath) { // preconditions @@ -146,7 +146,7 @@ public void AssetPath(Type applicationType, string assetPath) var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); // test execution - Assert.Equal(assetPath, application.AssetPath); + AssertExtensions.EqualWithPlaceholders(assetPath, application.AssetPath); } /// @@ -155,7 +155,7 @@ public void AssetPath(Type applicationType, string assetPath) [Theory] [InlineData(typeof(TestApplicationA), "/dataa")] [InlineData(typeof(TestApplicationB), "/datab")] - [InlineData(typeof(TestApplicationC), "/")] + [InlineData(typeof(TestApplicationC), "*/")] public void DataPath(Type applicationType, string dataPath) { // preconditions @@ -163,7 +163,7 @@ public void DataPath(Type applicationType, string dataPath) var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); // test execution - Assert.Equal(dataPath, application.DataPath); + AssertExtensions.EqualWithPlaceholders(dataPath, application.DataPath); } /// diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs index d7300d8..cf98c11 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs @@ -37,6 +37,7 @@ public void Remove() // test execution themeManager.Remove(plugin); + // validation Assert.Empty(componentHub.ThemeManager.Themes); } @@ -72,6 +73,7 @@ public void Id(Type applicationType, Type themeType, string id) // test execution var themes = componentHub.ThemeManager.GetThemes(application, themeType); + // validation if (id == null) { Assert.Empty(themes); @@ -100,6 +102,7 @@ public void Name(Type applicationType, Type themeType, string name) // test execution var themes = componentHub.ThemeManager.GetThemes(application, themeType); + // validation if (name == null) { Assert.Empty(themes); @@ -128,6 +131,7 @@ public void Description(Type applicationType, Type themeType, string expected) // test execution var theme = componentHub.ThemeManager.GetThemes(application, themeType).FirstOrDefault(); + // validation Assert.NotNull(theme); Assert.Equal(expected, theme?.Description); } @@ -151,6 +155,7 @@ public void Image(Type applicationType, Type themeType, string expected) // test execution var theme = componentHub.ThemeManager.GetThemes(application, themeType).FirstOrDefault(); + // validation Assert.NotNull(theme); Assert.Equal(expected, theme?.Image?.ToString()); } @@ -174,6 +179,7 @@ public void Mode(Type applicationType, Type themeType, ThemeMode expected) // test execution var theme = componentHub.ThemeManager.GetThemes(application, themeType).FirstOrDefault(); + // validation Assert.NotNull(theme); Assert.Equal(expected, theme?.ThemeMode); } @@ -197,6 +203,7 @@ public void ThemeStyle(Type applicationType, Type themeType, string expected) // test execution var theme = componentHub.ThemeManager.GetThemes(application, themeType).FirstOrDefault(); + // validation Assert.NotNull(theme); Assert.Equal(expected, theme?.ThemeStyle?.ToString()); } diff --git a/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs b/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs index 05b6928..71ccf79 100644 --- a/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs +++ b/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs @@ -88,8 +88,8 @@ private void Register(IPluginContext pluginContext) var icon = string.Empty; var description = string.Empty; var contextPath = string.Empty; - var assetPath = "/"; - var dataPath = "/"; + var assetPath = "./"; + var dataPath = "./"; // determining attributes foreach (var customAttribute in type.CustomAttributes diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentRegexAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentRegexAttribute.cs new file mode 100644 index 0000000..ca9b55a --- /dev/null +++ b/src/WebExpress.WebCore/WebAttribute/SegmentRegexAttribute.cs @@ -0,0 +1,51 @@ +using System; +using System.Linq.Expressions; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebUri; + +namespace WebExpress.WebCore.WebAttribute +{ + /// + /// Attribute to define a regex segment in a URI path. + /// + [AttributeUsage(AttributeTargets.Class)] + public class SegmentRegexAttribute : Attribute, IEndpointAttribute, ISegmentAttribute + where TParameter : Parameter + { + /// + /// Returns or sets the name of the variable. + /// + private string VariableName { get; set; } + + /// + /// Reurns or sets the string representation of the expression. + /// + private string Expression{ get; set; } + + /// + /// Returns or sets the display string. + /// + private string Display { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The regular expression. + /// The display string. + public SegmentRegexAttribute(string expression, string display) + { + VariableName = (Activator.CreateInstance() as Parameter)?.Key?.ToLower(); + Expression = expression; + Display = display; + } + + /// + /// Conversion to a path segment. + /// + /// The path segment. + public IUriPathSegment ToPathSegment() + { + return new UriPathSegmentVariableRegex(VariableName, Expression, Display); + } + } +} diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentStringAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentStringAttribute.cs index d125473..8102162 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentStringAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentStringAttribute.cs @@ -1,4 +1,5 @@ using System; +using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebAttribute @@ -7,7 +8,8 @@ namespace WebExpress.WebCore.WebAttribute /// Attribute to define a segment string in a URI path. /// [AttributeUsage(AttributeTargets.Class)] - public class SegmentStringAttribute : Attribute, IEndpointAttribute, ISegmentAttribute + public class SegmentStringAttribute : Attribute, IEndpointAttribute, ISegmentAttribute + where TParameter : Parameter { /// /// Returns or sets the name of the variable. @@ -22,11 +24,10 @@ public class SegmentStringAttribute : Attribute, IEndpointAttribute, ISegmentAtt /// /// Initializes a new instance of the class. /// - /// The name of the variable. /// The display string. - public SegmentStringAttribute(string variableName, string display) + public SegmentStringAttribute(string display) { - VariableName = variableName; + VariableName = (Activator.CreateInstance() as Parameter)?.Key?.ToLower(); Display = display; } diff --git a/src/WebExpress.WebCore/WebEx.cs b/src/WebExpress.WebCore/WebEx.cs index 31f6b7b..3f9e4ba 100644 --- a/src/WebExpress.WebCore/WebEx.cs +++ b/src/WebExpress.WebCore/WebEx.cs @@ -65,6 +65,11 @@ public sealed class WebEx /// public static IComponentHub ComponentHub => _componentHub; + /// + /// Returns or sets the path to the favicon image used by the application. + /// + public static string Favicon { get; set; } = "webexpress.webui/assets/img/rocket.png"; + /// /// Running the application. /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs new file mode 100644 index 0000000..4a63792 --- /dev/null +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; + +namespace WebExpress.WebCore.WebUri +{ + /// + /// Variable path segment. + /// + public class UriPathSegmentVariableRegex : UriPathSegmentVariable + { + /// + /// Initializes a new instance of the class. + /// + /// The name. + /// The regular expression. + /// The tag or null + public UriPathSegmentVariableRegex(string name, string regex, object tag = null) + : base(name, tag) + { + VariableName = name; + Value = name; + Display = name; + Expression = regex; + Tag = tag; + } + + /// + /// Initializes a new instance of the class. + /// + /// The name. + /// The regular expression. + /// The display text. + /// The tag or null + public UriPathSegmentVariableRegex(string name, string regex, string display, object tag = null) + : base(name, tag) + { + VariableName = name; + Value = name; + Display = display; + Expression = regex; + Tag = tag; + } + + /// + /// Initializes a new instance of the class. + /// + /// The path segment to copy. + public UriPathSegmentVariableRegex(UriPathSegmentVariableRegex segment) + : base(segment.VariableName, segment.Display, segment.Tag) + { + Expression = segment.Expression; + } + + /// + /// Returns the variable. + /// + /// The value. + /// The variable value pair. + public override IDictionary GetVariable(string value) + { + return new Dictionary(); + } + + /// + /// Make a deep copy. + /// + /// The copy. + public override IUriPathSegment Copy() + { + return new UriPathSegmentVariableRegex(this) { Value = Value }; + } + + /// + /// Converts the segment to a string. + /// + /// A string that represents the current segment. + public override string ToString() + { + return base.ToString(); + } + } +} \ No newline at end of file From 3b5764a41b6808a9f997becedc640ddb2aab099c Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Thu, 20 Nov 2025 20:30:59 +0100 Subject: [PATCH 05/53] fix: general improvements and minor bugs --- src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs b/src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs index 9666f69..4e878a8 100644 --- a/src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs +++ b/src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs @@ -195,7 +195,7 @@ public virtual IRoute RemoveSegment(string segments) /// An instance of IUri representing the route as a URI. public IUri ToUri(params Parameter[] parameters) { - return new UriEndpoint(this).SetParameters(parameters); + return new UriEndpoint([.. PathSegments]).SetParameters(parameters); } /// From f072edef0200047e53cd101c634fbcd2dc74b3bf Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 23 Nov 2025 16:42:11 +0100 Subject: [PATCH 06/53] fix: general improvements and minor bugs --- .../Fixture/AssertExtensions.cs | 14 +- .../Fixture/UnitTestFixture.cs | 19 + .../Manager/UnitTestEventManager.cs | 4 +- .../Manager/UnitTestFragmentManager.cs | 2 +- .../Manager/UnitTestInternationalization.cs | 13 +- .../Manager/UnitTestRestApiManager.cs | 33 +- .../Manager/UnitTestSessionManager.cs | 2 +- .../Manager/UnitTestSettingPageManager.cs | 4 +- .../Manager/UnitTestSitemapManager.cs | 13 +- .../Manager/UnitTestThemeManager.cs | 4 +- .../Message/UnitTestPostRequest.cs | 8 +- .../Route/UnitTestRoute.cs | 4 +- .../TestApplicationA.cs | 2 +- .../TestApplicationC.cs | 4 +- .../TestEventHandlerA.cs | 8 +- .../TestEventHandlerB.cs | 8 +- src/WebExpress.WebCore.Test/TestFragmentA.cs | 4 +- src/WebExpress.WebCore.Test/TestFragmentB.cs | 4 +- src/WebExpress.WebCore.Test/TestFragmentC.cs | 4 +- src/WebExpress.WebCore.Test/TestFragmentD.cs | 4 +- src/WebExpress.WebCore.Test/TestJobA.cs | 2 +- src/WebExpress.WebCore.Test/TestParameterA.cs | 2 +- .../TestStatusPage301.cs | 4 +- .../TestStatusPage400.cs | 6 +- .../TestStatusPage404.cs | 4 +- .../TestStatusPage500.cs | 6 +- src/WebExpress.WebCore.Test/TestTask.cs | 6 +- src/WebExpress.WebCore.Test/TestVisualTree.cs | 6 +- .../Uri/UnitTestUri.cs | 58 ++- src/WebExpress.WebCore.Test/WWW/About.cs | 4 +- .../WWW/Api/1/TestRestApiA.cs | 6 +- .../WWW/Api/2/TestRestApiB.cs | 6 +- .../WWW/Api/3/TestRestApiC.cs | 8 +- src/WebExpress.WebCore.Test/WWW/Blog/Index.cs | 6 +- .../WWW/Blog/Post/Add.cs | 6 +- .../WWW/Blog/Post/Index.cs | 6 +- .../WWW/Blog/Post/PostId/Edit.cs | 4 +- .../WWW/Blog/Post/PostId/Index.cs | 6 +- src/WebExpress.WebCore.Test/WWW/Contact.cs | 6 +- src/WebExpress.WebCore.Test/WWW/Index.cs | 4 +- .../WWW/Products/Details/Index.cs | 6 +- .../WWW/Products/Index.cs | 4 +- .../WWW/Products/List.cs | 4 +- .../WWW/Resources/TestResourceA.cs | 4 +- .../WWW/Resources/TestResourceB.cs | 2 +- .../WWW/Resources/TestResourceC.cs | 6 +- .../WWW/Resources/TestResourceD.cs | 6 +- .../WWW/Settings/TestSettingPageA.cs | 4 +- .../WWW/Settings/TestSettingPageB.cs | 4 +- .../WWW/Settings/TestSettingPageC.cs | 4 +- src/WebExpress.WebCore/ArgumentParser.cs | 4 +- src/WebExpress.WebCore/HttpServer.cs | 15 +- .../InternationalizationManager.cs | 2 +- .../WebApplication/ApplicationManager.cs | 8 +- .../Model/ApplicationDictionary.cs | 2 +- src/WebExpress.WebCore/WebAsset/Asset.cs | 4 +- .../WebAsset/AssetManager.cs | 2 +- .../WebAsset/Model/AssetItemDictionary.cs | 4 +- .../WebAttribute/SegmentAttribute.cs | 11 +- .../WebAttribute/SegmentDoubleAttribute.cs | 9 +- .../WebAttribute/SegmentGuidAttribute.cs | 20 +- .../WebAttribute/SegmentIntAttribute.cs | 11 +- .../WebAttribute/SegmentRegexAttribute.cs | 12 +- .../WebAttribute/SegmentStringAttribute.cs | 11 +- .../WebAttribute/SegmentUIntAttribute.cs | 11 +- .../WebComponent/ComponentActivator.cs | 16 +- .../WebComponent/ComponentHub.cs | 8 +- .../WebEndpoint/EndpointManager.cs | 8 +- src/WebExpress.WebCore/WebEndpoint/IRoute.cs | 2 +- .../WebEndpoint/RouteEndpoint.cs | 6 +- .../WebEvent/EventManager.cs | 6 +- .../WebEvent/Model/EventItem.cs | 2 +- .../WebFragment/FragmentComparer.cs | 2 +- .../WebFragment/FragmentManager.cs | 6 +- .../WebFragment/Model/FragmentDictionary.cs | 2 +- src/WebExpress.WebCore/WebHtml/HtmlElement.cs | 14 +- .../WebHtml/HtmlElementSectionBody.cs | 2 +- .../WebIdentity/IdentityManager.cs | 10 +- .../Model/IdentityPermissionDictionary.cs | 2 +- .../Model/IdentityPolicyDictionary.cs | 2 +- .../WebInclude/IncludeManager.cs | 18 +- .../WebInclude/Model/IncludeDictionary.cs | 4 +- src/WebExpress.WebCore/WebJob/JobManager.cs | 6 +- src/WebExpress.WebCore/WebLog/Log.cs | 6 +- src/WebExpress.WebCore/WebMessage/Request.cs | 43 ++- .../WebMessage/RequestAuthorization.cs | 2 +- .../WebPackage/PackageManager.cs | 28 +- .../WebPage/Model/PageDictionary.cs | 2 +- src/WebExpress.WebCore/WebPage/PageManager.cs | 82 +++-- src/WebExpress.WebCore/WebPage/VisualTree.cs | 6 +- .../WebParameter/IParameter.cs | 48 +++ .../{WebMessage => WebParameter}/Parameter.cs | 58 ++- .../WebParameter/ParameterApiVersion.cs | 40 +++ .../ParameterDictionary.cs | 6 +- .../ParameterFile.cs | 2 +- .../ParameterScope.cs | 2 +- .../WebPlugin/Model/PluginLoadContext.cs | 4 +- .../WebPlugin/PluginManager.cs | 38 +- .../WebResource/ResourceAsset.cs | 4 +- .../WebResource/ResourceBinary.cs | 2 +- .../WebResource/ResourceManager.cs | 325 +++++++++++------ .../WebRestAPI/RestApiManager.cs | 329 ++++++++++++------ .../WebRestApi/RestApiValidationResult.cs | 2 +- .../WebSession/Model/Session.cs | 2 +- .../Model/SessionPropertyParameter.cs | 2 +- .../WebSession/SessionManager.cs | 2 +- .../Model/SettingCategoryDictionary.cs | 2 +- .../Model/SettingGroupDictionary.cs | 2 +- .../Model/SettingPageDictionary.cs | 4 +- .../WebSettingPage/Model/TimeSpanConverter.cs | 2 +- .../WebSettingPage/SettingPageManager.cs | 213 +++++------- .../WebSitemap/ISitemapManager.cs | 2 +- .../WebSitemap/Model/SitemapNode.cs | 6 +- .../WebSitemap/SitemapManager.cs | 43 +-- .../Model/StatusPageDictionary.cs | 2 +- .../WebStatusPage/StatusPageManager.cs | 267 +++++++++----- .../WebTheme/Model/ThemeItemDictionary.cs | 4 +- .../WebTheme/ThemeManager.cs | 6 +- src/WebExpress.WebCore/WebUri/IUri.cs | 29 +- .../WebUri/IUriPathSegment.cs | 13 - .../WebUri/IUriPathSegmentConstant.cs | 1 - .../WebUri/IUriPathSegmentVariable.cs | 37 +- src/WebExpress.WebCore/WebUri/UriEndpoint.cs | 77 ++-- .../WebUri/UriPathSegmentConstant.cs | 38 +- .../WebUri/UriPathSegmentRoot.cs | 17 +- .../WebUri/UriPathSegmentVariable.cs | 91 +++-- .../UriPathSegmentVariableApiVersion.cs | 102 ++++++ .../WebUri/UriPathSegmentVariableDouble.cs | 28 +- .../WebUri/UriPathSegmentVariableGuid.cs | 56 ++- .../WebUri/UriPathSegmentVariableInt.cs | 32 +- .../WebUri/UriPathSegmentVariableRegex.cs | 29 +- .../WebUri/UriPathSegmentVariableString.cs | 30 +- .../WebUri/UriPathSegmentVariableUInt.cs | 30 +- 133 files changed, 1720 insertions(+), 1078 deletions(-) create mode 100644 src/WebExpress.WebCore/WebParameter/IParameter.cs rename src/WebExpress.WebCore/{WebMessage => WebParameter}/Parameter.cs (72%) create mode 100644 src/WebExpress.WebCore/WebParameter/ParameterApiVersion.cs rename src/WebExpress.WebCore/{WebMessage => WebParameter}/ParameterDictionary.cs (63%) rename src/WebExpress.WebCore/{WebMessage => WebParameter}/ParameterFile.cs (97%) rename src/WebExpress.WebCore/{WebMessage => WebParameter}/ParameterScope.cs (92%) create mode 100644 src/WebExpress.WebCore/WebUri/UriPathSegmentVariableApiVersion.cs diff --git a/src/WebExpress.WebCore.Test/Fixture/AssertExtensions.cs b/src/WebExpress.WebCore.Test/Fixture/AssertExtensions.cs index f241278..e6fb0b4 100644 --- a/src/WebExpress.WebCore.Test/Fixture/AssertExtensions.cs +++ b/src/WebExpress.WebCore.Test/Fixture/AssertExtensions.cs @@ -23,6 +23,14 @@ public static partial class AssertExtensions /// The actual string to compare. public static void EqualWithPlaceholders(string expected, string actual) { + if (expected is null && actual is null) + { + return; + } + + Assert.NotNull(expected); + Assert.NotNull(actual); + var str = RemoveLineBreaks(actual?.ToString()); Assert.True(AreEqualWithPlaceholders(expected, str), $"Expected: {expected}{Environment.NewLine}Actual: {str}"); } @@ -47,15 +55,15 @@ public static void EqualWithPlaceholders(string expected, IHtmlNode actual) /// True if the actual string matches the expected string with placeholders; otherwise, false. private static bool AreEqualWithPlaceholders(string expected, string actual) { - if (expected == null && actual == null) + if (expected is null && actual is null) { return true; } - else if (expected != null && actual == null) + else if (expected is not null && actual is null) { return false; } - else if (expected == null && actual != null) + else if (expected is null && actual is not null) { return false; } diff --git a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs index bf3684b..a310319 100644 --- a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs +++ b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs @@ -112,6 +112,25 @@ public static Request CrerateRequestMock(string content = "", string uri = "") return request; } + /// + /// Create a fake request. + /// + /// The URI of the request. + /// A fake request for testing. + public static Request CrerateRequestMock(IUri uri) + { + var context = CreateHttpContextMock(); + + var request = context.Request; + + if (uri is not null) + { + request.Uri = uri as UriEndpoint; + } + + return request; + } + /// /// Create a fake http context. /// diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestEventManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestEventManager.cs index 9c68326..049f21b 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestEventManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestEventManager.cs @@ -70,7 +70,7 @@ public void Id(Type applicationType, Type eventType, string id) // test execution var eventHandlers = componentHub.EventManager.GetEventHandlers(application, eventType); - if (id == null) + if (id is null) { Assert.Empty(eventHandlers); return; @@ -96,7 +96,7 @@ public void EventId(Type applicationType, Type eventType, string id) // test execution var eventHandlers = componentHub.EventManager.GetEventHandlers(application, eventType); - if (id == null) + if (id is null) { Assert.Empty(eventHandlers); return; diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentManager.cs index 35df62c..fdaa0e4 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentManager.cs @@ -78,7 +78,7 @@ public void Id(Type applicationType, Type fragmentType, string id) // test execution var fragment = componentHub.FragmentManager.GetFragments(application, fragmentType); - if (id == null) + if (id is null) { Assert.Empty(fragment); return; diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestInternationalization.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestInternationalization.cs index 9b3247c..8421e2b 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestInternationalization.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestInternationalization.cs @@ -75,49 +75,48 @@ public void Translate(string key, string excepted, string cultureName = null, st // preconditions UnitTestFixture.CreateAndRegisterComponentHubMock(); - if (cultureName == null && !param.Any()) + if (cultureName is null && param.Length == 0) { // test execution var result = I18N.Translate(key); Assert.Equal(excepted, result); } - if (cultureName == null && param.Any()) + if (cultureName is null && param.Length != 0) { // test execution var result = I18N.Translate(key, param); Assert.Equal(excepted, result); } - if (cultureName != null && pluginID == null && !param.Any()) + if (cultureName is not null && pluginID is null && param.Length == 0) { // test execution var result = I18N.Translate(CultureInfo.GetCultureInfo(cultureName), key); Assert.Equal(excepted, result); } - if (cultureName != null && pluginID == null && param.Any()) + if (cultureName is not null && pluginID is null && param.Length != 0) { // test execution var result = I18N.Translate(CultureInfo.GetCultureInfo(cultureName), key, param); Assert.Equal(excepted, result); } - if (cultureName != null && pluginID != null && !param.Any()) + if (cultureName is not null && pluginID is not null && param.Length == 0) { // test execution var result = I18N.Translate(CultureInfo.GetCultureInfo(cultureName), pluginID, key); Assert.Equal(excepted, result); } - if (cultureName != null && pluginID != null && param.Any()) + if (cultureName is not null && pluginID is not null && param.Length != 0) { // test execution var result = I18N.Translate(CultureInfo.GetCultureInfo(cultureName), pluginID, key, param); Assert.Equal(excepted, result); } - } /// diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs index 9c18eba..79c9a46 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs @@ -3,7 +3,7 @@ using WebExpress.WebCore.Test.WWW.Api._2; using WebExpress.WebCore.Test.WWW.Api._3; using WebExpress.WebCore.WebComponent; -using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebRestApi; namespace WebExpress.WebCore.Test.Manager @@ -93,6 +93,37 @@ public void RoutePath(Type applicationType, Type resourceType, string path) Assert.Equal(path, api?.Route.ToString()); } + /// + /// Test the version from path property of the rest api. + /// + [Theory] + [InlineData(typeof(TestApplicationA), typeof(TestRestApiA), "1")] + [InlineData(typeof(TestApplicationA), typeof(TestRestApiB), "2")] + [InlineData(typeof(TestApplicationA), typeof(TestRestApiC), "3")] + [InlineData(typeof(TestApplicationB), typeof(TestRestApiA), "1")] + [InlineData(typeof(TestApplicationB), typeof(TestRestApiB), "2")] + [InlineData(typeof(TestApplicationB), typeof(TestRestApiC), "3")] + [InlineData(typeof(TestApplicationC), typeof(TestRestApiA), "1")] + [InlineData(typeof(TestApplicationC), typeof(TestRestApiB), "2")] + [InlineData(typeof(TestApplicationC), typeof(TestRestApiC), "3")] + public void Version(Type applicationType, Type resourceType, string expected) + { + // preconditions + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); + var api = componentHub.RestApiManager.GetRestApi(resourceType, application)?.FirstOrDefault(); + componentHub.SitemapManager.Refresh(); + var uri = componentHub.SitemapManager.GetUri(resourceType, application); + + // test execution + var version = uri.Parameters + .Where(x => x.Key == "_apiversion") + .FirstOrDefault(); + + // test execution + Assert.Equal(expected, version.Value); + } + /// /// Test the context path property of the rest api. /// diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs index e7bf41e..ec14484 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs @@ -1,6 +1,6 @@ using WebExpress.WebCore.Test.Fixture; using WebExpress.WebCore.WebComponent; -using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebSession.Model; namespace WebExpress.WebCore.Test.Manager diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSettingPageManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSettingPageManager.cs index bab64fb..ae0b801 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSettingPageManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSettingPageManager.cs @@ -336,7 +336,9 @@ public void GetFirstSettingPage(Type applicationType, Type settingCategoryType, var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var settingCategory = componentHub.SettingPageManager.GetSettingCategories(application).FirstOrDefault(x => x.CategoryId.ToString() == settingCategoryType?.FullName.ToLower()); - var firstPage = firstPageType != null ? componentHub.SettingPageManager.GetSettingPages(firstPageType, application).FirstOrDefault() : null; + var firstPage = firstPageType is not null + ? componentHub.SettingPageManager.GetSettingPages(firstPageType, application).FirstOrDefault() + : null; var settingPage = componentHub.SettingPageManager.GetFirstSettingPage(application, settingCategory); // test execution diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs index 3ec7ab8..0f2a35c 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs @@ -18,8 +18,9 @@ public class UnitTestSitemapManager /// /// Test the refresh function of the sitemap manager. /// - [Fact] - public void Refresh() + [Theory] + [InlineData(103)] + public void Refresh(int expected) { // preconditions var componentManager = UnitTestFixture.CreateAndRegisterComponentHubMock(); @@ -27,7 +28,8 @@ public void Refresh() // test execution componentManager.SitemapManager.Refresh(); - Assert.Equal(97, componentManager.SitemapManager.SiteMap.Count()); + // validation + Assert.Equal(expected, componentManager.SitemapManager.SiteMap.Count()); } /// @@ -80,6 +82,7 @@ public void SearchResource(string uri, string id) componentHub.EndpointManager.HandleRequest(UnitTestFixture.CrerateRequestMock(), searchResult?.EndpointContext); + // validation Assert.Equal(id, searchResult?.EndpointContext?.EndpointId.ToString()); } @@ -118,6 +121,7 @@ public void GetUri(Type applicationType, Type resourceType, int? param, string e // test execution var uri = componentHub.SitemapManager.GetUri(resourceType, application, [param.HasValue ? new TestParameterA(param.Value) : null]); + // validation Assert.Equal(expected, uri?.ToString()); } @@ -163,7 +167,8 @@ public void GetEndpoint(string uri, string expected) // test execution var endpoint = componentHub.SitemapManager.GetEndpoint(new UriEndpoint(uri)); - Assert.Equal(expected, endpoint?.EndpointId?.ToString()); + // validation + AssertExtensions.EqualWithPlaceholders(expected, endpoint?.EndpointId?.ToString()); } /// diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs index cf98c11..f4b01a9 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs @@ -74,7 +74,7 @@ public void Id(Type applicationType, Type themeType, string id) var themes = componentHub.ThemeManager.GetThemes(application, themeType); // validation - if (id == null) + if (id is null) { Assert.Empty(themes); return; @@ -103,7 +103,7 @@ public void Name(Type applicationType, Type themeType, string name) var themes = componentHub.ThemeManager.GetThemes(application, themeType); // validation - if (name == null) + if (name is null) { Assert.Empty(themes); return; diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestPostRequest.cs b/src/WebExpress.WebCore.Test/Message/UnitTestPostRequest.cs index 8b73d5c..856cdf1 100644 --- a/src/WebExpress.WebCore.Test/Message/UnitTestPostRequest.cs +++ b/src/WebExpress.WebCore.Test/Message/UnitTestPostRequest.cs @@ -11,7 +11,7 @@ public class UnitTestPostRequest : UnitTestRequest // Assert.True // ( - // param != null && param == "1" + // param is not null && param == "1" // ); //} @@ -26,9 +26,9 @@ public class UnitTestPostRequest : UnitTestRequest // Assert.True // ( - // a != null && a == "ä" && - // b != null && b == "ö ü" && - // s != null && s == "1" + // a is not null && a == "ä" && + // b is not null && b == "ö ü" && + // s is not null && s == "1" // ); //} diff --git a/src/WebExpress.WebCore.Test/Route/UnitTestRoute.cs b/src/WebExpress.WebCore.Test/Route/UnitTestRoute.cs index 862f497..911af39 100644 --- a/src/WebExpress.WebCore.Test/Route/UnitTestRoute.cs +++ b/src/WebExpress.WebCore.Test/Route/UnitTestRoute.cs @@ -43,7 +43,9 @@ public void ConcatSegment(string baseRoute, string segment, string expected, int var route = new RouteEndpoint(baseRoute); // test execution - var concat = route.Concat(segment != null ? [.. segment?.Split('/').Select(x => new UriPathSegmentConstant(x))] : null); + var concat = route.Concat(segment is not null + ? [.. segment?.Split('/').Select(x => new UriPathSegmentConstant(x))] + : null); Assert.Equal(expected, concat.ToString()); Assert.Equal(count, concat.PathSegments.Count()); diff --git a/src/WebExpress.WebCore.Test/TestApplicationA.cs b/src/WebExpress.WebCore.Test/TestApplicationA.cs index 5b687a7..4e8c0c8 100644 --- a/src/WebExpress.WebCore.Test/TestApplicationA.cs +++ b/src/WebExpress.WebCore.Test/TestApplicationA.cs @@ -22,7 +22,7 @@ public sealed class TestApplicationA : IApplication private TestApplicationA(IApplicationContext applicationContext) { // test the injection - if (applicationContext == null) + if (applicationContext is null) { throw new ArgumentNullException(nameof(applicationContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/TestApplicationC.cs b/src/WebExpress.WebCore.Test/TestApplicationC.cs index 5adfc91..62f2f66 100644 --- a/src/WebExpress.WebCore.Test/TestApplicationC.cs +++ b/src/WebExpress.WebCore.Test/TestApplicationC.cs @@ -22,13 +22,13 @@ public sealed class TestApplicationC : IApplication private TestApplicationC(IApplicationContext applicationContext, IPluginManager pluginManager) { // test the injection - if (applicationContext == null) + if (applicationContext is null) { throw new ArgumentNullException(nameof(applicationContext), "Parameter cannot be null or empty."); } // test the injection - if (pluginManager == null) + if (pluginManager is null) { throw new ArgumentNullException(nameof(pluginManager), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/TestEventHandlerA.cs b/src/WebExpress.WebCore.Test/TestEventHandlerA.cs index c93ae04..97c8fdf 100644 --- a/src/WebExpress.WebCore.Test/TestEventHandlerA.cs +++ b/src/WebExpress.WebCore.Test/TestEventHandlerA.cs @@ -19,13 +19,13 @@ public sealed class TestEventHandlerA : IEventHandler private TestEventHandlerA(IEventHandlerContext eventHandlerContext, IApplicationManager applicationManager) { // test the injection - if (eventHandlerContext == null) + if (eventHandlerContext is null) { throw new ArgumentNullException(nameof(eventHandlerContext), "Parameter cannot be null or empty."); } // test the injection - if (applicationManager == null) + if (applicationManager is null) { throw new ArgumentNullException(nameof(applicationManager), "Parameter cannot be null or empty."); } @@ -39,13 +39,13 @@ private TestEventHandlerA(IEventHandlerContext eventHandlerContext, IApplication public void Process(object sender, IEventArgument eventArgument) { // test the parameter - if (sender == null) + if (sender is null) { throw new ArgumentNullException(nameof(sender), "Parameter cannot be null or empty."); } // test the parameter - if (eventArgument == null) + if (eventArgument is null) { throw new ArgumentNullException(nameof(eventArgument), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/TestEventHandlerB.cs b/src/WebExpress.WebCore.Test/TestEventHandlerB.cs index 542d528..b375a70 100644 --- a/src/WebExpress.WebCore.Test/TestEventHandlerB.cs +++ b/src/WebExpress.WebCore.Test/TestEventHandlerB.cs @@ -19,13 +19,13 @@ public sealed class TestEventHandlerB : IEventHandler private TestEventHandlerB(IEventHandlerContext eventHandlerContext, IApplicationManager applicationManager) { // test the injection - if (eventHandlerContext == null) + if (eventHandlerContext is null) { throw new ArgumentNullException(nameof(eventHandlerContext), "Parameter cannot be null or empty."); } // test the injection - if (applicationManager == null) + if (applicationManager is null) { throw new ArgumentNullException(nameof(applicationManager), "Parameter cannot be null or empty."); } @@ -39,13 +39,13 @@ private TestEventHandlerB(IEventHandlerContext eventHandlerContext, IApplication public void Process(object sender, TestEventArgument eventArgument) { // test the parameter - if (sender == null) + if (sender is null) { throw new ArgumentNullException(nameof(sender), "Parameter cannot be null or empty."); } // test the parameter - if (eventArgument == null) + if (eventArgument is null) { throw new ArgumentNullException(nameof(eventArgument), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/TestFragmentA.cs b/src/WebExpress.WebCore.Test/TestFragmentA.cs index 8fb3a7a..0fda54e 100644 --- a/src/WebExpress.WebCore.Test/TestFragmentA.cs +++ b/src/WebExpress.WebCore.Test/TestFragmentA.cs @@ -30,13 +30,13 @@ public sealed class TestFragmentA : IFragment public TestFragmentA(IComponentHub componentHub, IFragmentContext fragmentContext, IComponentId id) { // test the injection - if (componentHub == null) + if (componentHub is null) { throw new ArgumentNullException(nameof(componentHub), "Parameter componentHub cannot be null or empty."); } // test the injection - if (fragmentContext == null) + if (fragmentContext is null) { throw new ArgumentNullException(nameof(fragmentContext), "Parameter fragmentContext cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/TestFragmentB.cs b/src/WebExpress.WebCore.Test/TestFragmentB.cs index 88a58c6..0f179d9 100644 --- a/src/WebExpress.WebCore.Test/TestFragmentB.cs +++ b/src/WebExpress.WebCore.Test/TestFragmentB.cs @@ -27,13 +27,13 @@ public sealed class TestFragmentB : IFragment public TestFragmentB(IComponentHub componentHub, IFragmentContext fragmentContext) { // test the injection - if (componentHub == null) + if (componentHub is null) { throw new ArgumentNullException(nameof(componentHub), "Parameter cannot be null or empty."); } // test the injection - if (fragmentContext == null) + if (fragmentContext is null) { throw new ArgumentNullException(nameof(fragmentContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/TestFragmentC.cs b/src/WebExpress.WebCore.Test/TestFragmentC.cs index 40be3f3..7f4281f 100644 --- a/src/WebExpress.WebCore.Test/TestFragmentC.cs +++ b/src/WebExpress.WebCore.Test/TestFragmentC.cs @@ -26,13 +26,13 @@ public sealed class TestFragmentC : IFragment public TestFragmentC(IComponentHub componentHub, IFragmentContext fragmentContext) { // test the injection - if (componentHub == null) + if (componentHub is null) { throw new ArgumentNullException(nameof(componentHub), "Parameter cannot be null or empty."); } // test the injection - if (fragmentContext == null) + if (fragmentContext is null) { throw new ArgumentNullException(nameof(fragmentContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/TestFragmentD.cs b/src/WebExpress.WebCore.Test/TestFragmentD.cs index 3a583fd..913287e 100644 --- a/src/WebExpress.WebCore.Test/TestFragmentD.cs +++ b/src/WebExpress.WebCore.Test/TestFragmentD.cs @@ -27,13 +27,13 @@ public sealed class TestFragmentD : IFragment public TestFragmentD(IComponentHub componentHub, IFragmentContext fragmentContext) { // test the injection - if (componentHub == null) + if (componentHub is null) { throw new ArgumentNullException(nameof(componentHub), "Parameter cannot be null or empty."); } // test the injection - if (fragmentContext == null) + if (fragmentContext is null) { throw new ArgumentNullException(nameof(fragmentContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/TestJobA.cs b/src/WebExpress.WebCore.Test/TestJobA.cs index 8794b38..1351db7 100644 --- a/src/WebExpress.WebCore.Test/TestJobA.cs +++ b/src/WebExpress.WebCore.Test/TestJobA.cs @@ -16,7 +16,7 @@ public sealed class TestJobA : IJob private TestJobA(IJobContext jobContext) { // test the injection - if (jobContext == null) + if (jobContext is null) { throw new ArgumentNullException(nameof(jobContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/TestParameterA.cs b/src/WebExpress.WebCore.Test/TestParameterA.cs index e77468b..993ae27 100644 --- a/src/WebExpress.WebCore.Test/TestParameterA.cs +++ b/src/WebExpress.WebCore.Test/TestParameterA.cs @@ -1,4 +1,4 @@ -using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebParameter; namespace WebExpress.WebCore.Test { diff --git a/src/WebExpress.WebCore.Test/TestStatusPage301.cs b/src/WebExpress.WebCore.Test/TestStatusPage301.cs index ad41698..97617eb 100644 --- a/src/WebExpress.WebCore.Test/TestStatusPage301.cs +++ b/src/WebExpress.WebCore.Test/TestStatusPage301.cs @@ -20,7 +20,7 @@ public sealed class TestStatusPage301 : IStatusPage private TestStatusPage301(IStatusPageContext statusPageContext) { // test the injection - if (statusPageContext == null) + if (statusPageContext is null) { throw new ArgumentNullException(nameof(statusPageContext), "Parameter cannot be null or empty."); } @@ -34,7 +34,7 @@ private TestStatusPage301(IStatusPageContext statusPageContext) public void Process(IRenderContext renderContext, VisualTree visualTree) { // test the parameter - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/TestStatusPage400.cs b/src/WebExpress.WebCore.Test/TestStatusPage400.cs index c005266..0243af1 100644 --- a/src/WebExpress.WebCore.Test/TestStatusPage400.cs +++ b/src/WebExpress.WebCore.Test/TestStatusPage400.cs @@ -27,13 +27,13 @@ public sealed class TestStatusPage400 : IStatusPage private TestStatusPage400(IStatusPageContext statusPageContext, StatusMessage message) { // test the injection - if (statusPageContext == null) + if (statusPageContext is null) { throw new ArgumentNullException(nameof(statusPageContext), "Parameter cannot be null or empty."); } // test the injection - if (message == null) + if (message is null) { throw new ArgumentNullException(nameof(message), "Parameter cannot be null or empty."); } @@ -49,7 +49,7 @@ private TestStatusPage400(IStatusPageContext statusPageContext, StatusMessage me public void Process(IRenderContext renderContext, VisualTree visualTree) { // test the parameter - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/TestStatusPage404.cs b/src/WebExpress.WebCore.Test/TestStatusPage404.cs index ab0bea6..09165f4 100644 --- a/src/WebExpress.WebCore.Test/TestStatusPage404.cs +++ b/src/WebExpress.WebCore.Test/TestStatusPage404.cs @@ -20,7 +20,7 @@ public sealed class TestStatusPage404 : IStatusPage private TestStatusPage404(IStatusPageContext statusPageContext) { // test the injection - if (statusPageContext == null) + if (statusPageContext is null) { throw new ArgumentNullException(nameof(statusPageContext), "Parameter cannot be null or empty."); } @@ -34,7 +34,7 @@ private TestStatusPage404(IStatusPageContext statusPageContext) public void Process(IRenderContext renderContext, VisualTree visualTree) { // test the parameter - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/TestStatusPage500.cs b/src/WebExpress.WebCore.Test/TestStatusPage500.cs index f283612..8d585fa 100644 --- a/src/WebExpress.WebCore.Test/TestStatusPage500.cs +++ b/src/WebExpress.WebCore.Test/TestStatusPage500.cs @@ -27,13 +27,13 @@ public sealed class TestStatusPage500 : IStatusPage private TestStatusPage500(IStatusPageContext statusPageContext, StatusMessage message) { // test the injection - if (statusPageContext == null) + if (statusPageContext is null) { throw new ArgumentNullException(nameof(statusPageContext), "Parameter cannot be null or empty."); } // test the injection - if (message == null) + if (message is null) { throw new ArgumentNullException(nameof(message), "Parameter cannot be null or empty."); } @@ -49,7 +49,7 @@ private TestStatusPage500(IStatusPageContext statusPageContext, StatusMessage me public void Process(IRenderContext renderContext, VisualTree visualTree) { // test the parameter - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/TestTask.cs b/src/WebExpress.WebCore.Test/TestTask.cs index bfb27a3..5c7d70e 100644 --- a/src/WebExpress.WebCore.Test/TestTask.cs +++ b/src/WebExpress.WebCore.Test/TestTask.cs @@ -17,17 +17,17 @@ public TestTask(IComponentHub componentHub, string id, params object[] args) : base(id, args) { // test the injection - if (componentHub == null) + if (componentHub is null) { throw new ArgumentNullException(nameof(componentHub), "Parameter cannot be null or empty."); } - if (id == null) + if (id is null) { throw new ArgumentNullException(nameof(id), "Parameter cannot be null or empty."); } - if (args == null) + if (args is null) { throw new ArgumentNullException(nameof(args), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/TestVisualTree.cs b/src/WebExpress.WebCore.Test/TestVisualTree.cs index 4509ae9..bab7aa8 100644 --- a/src/WebExpress.WebCore.Test/TestVisualTree.cs +++ b/src/WebExpress.WebCore.Test/TestVisualTree.cs @@ -108,8 +108,10 @@ public virtual IHtmlNode Render(IVisualTreeContext context) html.Body.Add(Content); html.Body.Scripts = [.. Scripts.Values]; - html.Head.CssLinks = CssLinks.Where(x => x != null).Select(x => x.ToString()); - html.Head.ScriptLinks = HeaderScriptLinks?.Where(x => x != null).Select(x => x.ToString()); + html.Head.CssLinks = CssLinks.Where(x => x is not null) + .Select(x => x.ToString()); + html.Head.ScriptLinks = HeaderScriptLinks?.Where(x => x is not null) + .Select(x => x.ToString()); return html; } diff --git a/src/WebExpress.WebCore.Test/Uri/UnitTestUri.cs b/src/WebExpress.WebCore.Test/Uri/UnitTestUri.cs index cb5e36a..60d0583 100644 --- a/src/WebExpress.WebCore.Test/Uri/UnitTestUri.cs +++ b/src/WebExpress.WebCore.Test/Uri/UnitTestUri.cs @@ -1,4 +1,6 @@ -using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.Test.WWW; +using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.Test.Uri @@ -24,14 +26,15 @@ public class UnitTestUri public void UriAbsolute(UriScheme scheme, string authority, string user, string port, string path, string query, string fragment, string expected) { // preconditions - var uriUser = user != null ? user + "@" : ""; - var uriPort = port != null ? ":" + port : null; - var uriQuery = query != null ? "?" + query : ""; - var uriFragment = fragment != null ? "#" + fragment : null; + var uriUser = user is not null ? user + "@" : ""; + var uriPort = port is not null ? ":" + port : null; + var uriQuery = query is not null ? "?" + query : ""; + var uriFragment = fragment is not null ? "#" + fragment : null; // test execution var uri = new UriEndpoint($"{scheme}://{uriUser}{authority}{uriPort}{path}{uriQuery}{uriFragment}"); + // validation Assert.Equal(expected, uri.ToString()); Assert.Equal(scheme, uri.Scheme); Assert.Equal(authority, uri.Authority.Host); @@ -61,12 +64,13 @@ public void UriAbsolute(UriScheme scheme, string authority, string user, string public void UriRelative(string path, string query, string fragment, string expected) { // preconditions - var uriQuery = query != null ? "?" + query : ""; - var uriFragment = fragment != null ? "#" + fragment : null; + var uriQuery = query is not null ? "?" + query : ""; + var uriFragment = fragment is not null ? "#" + fragment : null; // test execution var uri = new UriEndpoint($"{path}{uriQuery}{uriFragment}"); + // validation Assert.Equal(expected, uri.ToString()); Assert.Equal(path, !string.IsNullOrWhiteSpace(path) ? "/" + string.Join("/", uri.PathSegments.Skip(1)) @@ -91,6 +95,7 @@ public void Concat(string path, string segment, string expected, int count) // test execution var concat = uri.Concat(segment); + // validation Assert.Equal(expected, concat.ToString()); Assert.Equal(count, concat.PathSegments.Count()); } @@ -113,6 +118,7 @@ public void Skip(string path, int skipCount, string expected) // test execution var skip = uri.Skip(skipCount); + // validation Assert.Equal(expected, skip?.ToString()); } @@ -139,6 +145,7 @@ public void Take(string path, int takeCount, string expected) // test execution var take = uri.Take(takeCount); + // validation Assert.Equal(expected, take?.ToString()); } @@ -160,7 +167,7 @@ public void Merge(string uri, string route, string expected) [.. route.Split('/').Select ( x => (IUriPathSegment)(x == "$guid" - ? new UriPathSegmentVariableGuid("guid") { Value = random } + ? new UriPathSegmentVariableGuid("guid") { Value = random } : new UriPathSegmentConstant(x)) )] ); @@ -168,6 +175,7 @@ [.. route.Split('/').Select // test execution var resourceUri = new UriEndpoint(uriEndpoint, routeEndpoint.PathSegments); + // validation Assert.Equal(expected.Replace("$guid", random), resourceUri?.ToString()); } @@ -179,17 +187,19 @@ [.. route.Split('/').Select [InlineData("http://user@example.com/a/b/c/x/y/z", "http://user@example.com/a/b/c", "http://user@example.com/a/b/c")] public void BasePath(string uri, string baseUri, string expected) { + // test execution var resourceUri = new UriEndpoint(uri) { BasePath = new UriEndpoint(baseUri) }; + // validation Assert.Equal(uri, resourceUri.ToString()); Assert.Equal(expected, resourceUri.BasePath.ToString()); } /// - /// Test the setfragment method. + /// Test the SetFragment method. /// [Theory] [InlineData("http://user@example.com/x", null, "http://user@example.com/x")] @@ -209,5 +219,35 @@ public void SetFragment(string uri, string fragment, string expected) // validation Assert.Equal(expected, resourceUri.ToString()); } + + /// + /// Test the GetDisplayText method. + /// + [Theory] + [InlineData(typeof(TestApplicationA), typeof(WWW.Index), null)] + [InlineData(typeof(TestApplicationA), typeof(About), null)] + [InlineData(typeof(TestApplicationA), typeof(Contact), null)] + [InlineData(typeof(TestApplicationB), typeof(WWW.Index), null)] + [InlineData(typeof(TestApplicationB), typeof(About), null)] + [InlineData(typeof(TestApplicationB), typeof(Contact), null)] + [InlineData(typeof(TestApplicationC), typeof(WWW.Index), null)] + [InlineData(typeof(TestApplicationC), typeof(About), null)] + [InlineData(typeof(TestApplicationC), typeof(Contact), null)] + public void GetDisplayText(Type applicationType, Type resourceType, string expected) + { + // preconditions + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); + componentHub.SitemapManager.Refresh(); + var page = componentHub.PageManager.GetPages(resourceType, application)?.FirstOrDefault(); + var endpoint = componentHub.SitemapManager.GetEndpoint(page.Route.ToUri()); + var renderContext = UnitTestFixture.CrerateRenderContextMock(application); + + // test execution + var display = endpoint.Route.ToUri().GetDisplayText(renderContext); + + // validation + Assert.Equal(expected, display); + } } } diff --git a/src/WebExpress.WebCore.Test/WWW/About.cs b/src/WebExpress.WebCore.Test/WWW/About.cs index a3326d4..5cdaff6 100644 --- a/src/WebExpress.WebCore.Test/WWW/About.cs +++ b/src/WebExpress.WebCore.Test/WWW/About.cs @@ -29,7 +29,7 @@ public About(IPageContext pageContext) PageContext = pageContext; // test the injection - if (pageContext == null) + if (pageContext is null) { throw new ArgumentNullException(nameof(pageContext), "Parameter cannot be null or empty."); } @@ -43,7 +43,7 @@ public About(IPageContext pageContext) public void Process(IRenderContext renderContext, VisualTree visualTree) { // test the context - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Api/1/TestRestApiA.cs b/src/WebExpress.WebCore.Test/WWW/Api/1/TestRestApiA.cs index 769e67a..152ed01 100644 --- a/src/WebExpress.WebCore.Test/WWW/Api/1/TestRestApiA.cs +++ b/src/WebExpress.WebCore.Test/WWW/Api/1/TestRestApiA.cs @@ -19,7 +19,7 @@ public sealed class TestRestApiA : IRestApi public TestRestApiA(IRestApiContext restApiContext) { // test the injection - if (restApiContext == null) + if (restApiContext is null) { throw new ArgumentNullException(nameof(restApiContext), "Parameter cannot be null or empty."); } @@ -53,7 +53,7 @@ public Response GetData(Request request) public Response UpdateData(Request request) { // test the request - if (request == null) + if (request is null) { throw new ArgumentNullException(nameof(request), "Parameter cannot be null or empty."); } @@ -69,7 +69,7 @@ public Response UpdateData(Request request) public Response DeleteData(Request request) { // test the request - if (request == null) + if (request is null) { throw new ArgumentNullException(nameof(request), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Api/2/TestRestApiB.cs b/src/WebExpress.WebCore.Test/WWW/Api/2/TestRestApiB.cs index d243db1..56b4e3d 100644 --- a/src/WebExpress.WebCore.Test/WWW/Api/2/TestRestApiB.cs +++ b/src/WebExpress.WebCore.Test/WWW/Api/2/TestRestApiB.cs @@ -18,7 +18,7 @@ public sealed class TestRestApiB : IRestApi public TestRestApiB(IRestApiContext restApiContext) { // test the injection - if (restApiContext == null) + if (restApiContext is null) { throw new ArgumentNullException(nameof(restApiContext), "Parameter cannot be null or empty."); } @@ -52,7 +52,7 @@ public Response GetData(Request request) public Response UpdateData(Request request) { // test the request - if (request == null) + if (request is null) { throw new ArgumentNullException(nameof(request), "Parameter cannot be null or empty."); } @@ -68,7 +68,7 @@ public Response UpdateData(Request request) public Response DeleteData(Request request) { // test the request - if (request == null) + if (request is null) { throw new ArgumentNullException(nameof(request), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Api/3/TestRestApiC.cs b/src/WebExpress.WebCore.Test/WWW/Api/3/TestRestApiC.cs index 9b499f1..e406ca1 100644 --- a/src/WebExpress.WebCore.Test/WWW/Api/3/TestRestApiC.cs +++ b/src/WebExpress.WebCore.Test/WWW/Api/3/TestRestApiC.cs @@ -21,13 +21,13 @@ public TestRestApiC(IComponentHub componentHub, IRestApiContext restApiContext) : base(restApiContext) { // test the injection - if (componentHub == null) + if (componentHub is null) { throw new ArgumentNullException(nameof(componentHub), "Parameter cannot be null or empty."); } // test the injection - if (restApiContext == null) + if (restApiContext is null) { throw new ArgumentNullException(nameof(restApiContext), "Parameter cannot be null or empty."); } @@ -61,7 +61,7 @@ public override Response GetData(Request request) public override Response UpdateData(Request request) { // test the request - if (request == null) + if (request is null) { throw new ArgumentNullException(nameof(request), "Parameter cannot be null or empty."); } @@ -77,7 +77,7 @@ public override Response UpdateData(Request request) public override Response DeleteData(Request request) { // test the request - if (request == null) + if (request is null) { throw new ArgumentNullException(nameof(request), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Blog/Index.cs b/src/WebExpress.WebCore.Test/WWW/Blog/Index.cs index ae60598..1115178 100644 --- a/src/WebExpress.WebCore.Test/WWW/Blog/Index.cs +++ b/src/WebExpress.WebCore.Test/WWW/Blog/Index.cs @@ -16,7 +16,7 @@ public sealed class Index : Page private Index(IPageContext pageContext) { // test the injection - if (pageContext == null) + if (pageContext is null) { throw new ArgumentNullException(nameof(pageContext), "Parameter cannot be null or empty."); } @@ -30,13 +30,13 @@ private Index(IPageContext pageContext) public override void Process(IRenderContext renderContext, TestVisualTree visualTree) { // test the context - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } // test the visualTree - if (visualTree == null) + if (visualTree is null) { throw new ArgumentNullException(nameof(visualTree), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Blog/Post/Add.cs b/src/WebExpress.WebCore.Test/WWW/Blog/Post/Add.cs index 6f74a0c..413ef93 100644 --- a/src/WebExpress.WebCore.Test/WWW/Blog/Post/Add.cs +++ b/src/WebExpress.WebCore.Test/WWW/Blog/Post/Add.cs @@ -16,7 +16,7 @@ public sealed class Add : Page private Add(IPageContext pageContext) { // test the injection - if (pageContext == null) + if (pageContext is null) { throw new ArgumentNullException(nameof(pageContext), "Parameter cannot be null or empty."); } @@ -30,13 +30,13 @@ private Add(IPageContext pageContext) public override void Process(IRenderContext renderContext, TestVisualTree visualTree) { // test the context - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } // test the visualTree - if (visualTree == null) + if (visualTree is null) { throw new ArgumentNullException(nameof(visualTree), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Blog/Post/Index.cs b/src/WebExpress.WebCore.Test/WWW/Blog/Post/Index.cs index f9f9098..39cb3be 100644 --- a/src/WebExpress.WebCore.Test/WWW/Blog/Post/Index.cs +++ b/src/WebExpress.WebCore.Test/WWW/Blog/Post/Index.cs @@ -16,7 +16,7 @@ public sealed class Index : Page private Index(IPageContext pageContext) { // test the injection - if (pageContext == null) + if (pageContext is null) { throw new ArgumentNullException(nameof(pageContext), "Parameter cannot be null or empty."); } @@ -30,13 +30,13 @@ private Index(IPageContext pageContext) public override void Process(IRenderContext renderContext, TestVisualTree visualTree) { // test the context - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } // test the visualTree - if (visualTree == null) + if (visualTree is null) { throw new ArgumentNullException(nameof(visualTree), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Blog/Post/PostId/Edit.cs b/src/WebExpress.WebCore.Test/WWW/Blog/Post/PostId/Edit.cs index 67384d7..69bf174 100644 --- a/src/WebExpress.WebCore.Test/WWW/Blog/Post/PostId/Edit.cs +++ b/src/WebExpress.WebCore.Test/WWW/Blog/Post/PostId/Edit.cs @@ -28,7 +28,7 @@ public Edit(IPageContext pageContext) PageContext = pageContext; // test the injection - if (pageContext == null) + if (pageContext is null) { throw new ArgumentNullException(nameof(pageContext), "Parameter cannot be null or empty."); } @@ -42,7 +42,7 @@ public Edit(IPageContext pageContext) public void Process(IRenderContext renderContext, VisualTree visualTree) { // test the context - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Blog/Post/PostId/Index.cs b/src/WebExpress.WebCore.Test/WWW/Blog/Post/PostId/Index.cs index 130a23d..40123bb 100644 --- a/src/WebExpress.WebCore.Test/WWW/Blog/Post/PostId/Index.cs +++ b/src/WebExpress.WebCore.Test/WWW/Blog/Post/PostId/Index.cs @@ -7,7 +7,7 @@ namespace WebExpress.WebCore.Test.WWW.Blog.Post.PostId /// A dummy class for testing purposes. /// [Title("webindex:index.label")] - [SegmentGuid("segment")] + [SegmentGuid()] public sealed class Index : IPage { /// @@ -29,7 +29,7 @@ public Index(IPageContext pageContext) PageContext = pageContext; // test the injection - if (pageContext == null) + if (pageContext is null) { throw new ArgumentNullException(nameof(pageContext), "Parameter cannot be null or empty."); } @@ -43,7 +43,7 @@ public Index(IPageContext pageContext) public void Process(IRenderContext renderContext, VisualTree visualTree) { // test the context - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Contact.cs b/src/WebExpress.WebCore.Test/WWW/Contact.cs index 3697d8e..132ca95 100644 --- a/src/WebExpress.WebCore.Test/WWW/Contact.cs +++ b/src/WebExpress.WebCore.Test/WWW/Contact.cs @@ -16,7 +16,7 @@ public sealed class Contact : Page private Contact(IPageContext pageContext) { // test the injection - if (pageContext == null) + if (pageContext is null) { throw new ArgumentNullException(nameof(pageContext), "Parameter cannot be null or empty."); } @@ -30,13 +30,13 @@ private Contact(IPageContext pageContext) public override void Process(IRenderContext renderContext, TestVisualTree visualTree) { // test the context - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } // test the visualTree - if (visualTree == null) + if (visualTree is null) { throw new ArgumentNullException(nameof(visualTree), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Index.cs b/src/WebExpress.WebCore.Test/WWW/Index.cs index cfc2e40..465d0a5 100644 --- a/src/WebExpress.WebCore.Test/WWW/Index.cs +++ b/src/WebExpress.WebCore.Test/WWW/Index.cs @@ -28,7 +28,7 @@ public Index(IPageContext pageContext) PageContext = pageContext; // test the injection - if (pageContext == null) + if (pageContext is null) { throw new ArgumentNullException(nameof(pageContext), "Parameter cannot be null or empty."); } @@ -42,7 +42,7 @@ public Index(IPageContext pageContext) public void Process(IRenderContext renderContext, VisualTree visualTree) { // test the context - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Products/Details/Index.cs b/src/WebExpress.WebCore.Test/WWW/Products/Details/Index.cs index edba4ad..85bd346 100644 --- a/src/WebExpress.WebCore.Test/WWW/Products/Details/Index.cs +++ b/src/WebExpress.WebCore.Test/WWW/Products/Details/Index.cs @@ -7,7 +7,7 @@ namespace WebExpress.WebCore.Test.WWW.Products.Details /// A dummy class for testing purposes. /// [Title("webindex:index.label")] - [SegmentGuid("")] + [SegmentGuid()] public sealed class Index : IPage { /// @@ -29,7 +29,7 @@ public Index(IPageContext pageContext) PageContext = pageContext; // test the injection - if (pageContext == null) + if (pageContext is null) { throw new ArgumentNullException(nameof(pageContext), "Parameter cannot be null or empty."); } @@ -43,7 +43,7 @@ public Index(IPageContext pageContext) public void Process(IRenderContext renderContext, VisualTree visualTree) { // test the context - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Products/Index.cs b/src/WebExpress.WebCore.Test/WWW/Products/Index.cs index 4349317..d105b4b 100644 --- a/src/WebExpress.WebCore.Test/WWW/Products/Index.cs +++ b/src/WebExpress.WebCore.Test/WWW/Products/Index.cs @@ -28,7 +28,7 @@ public Index(IPageContext pageContext) PageContext = pageContext; // test the injection - if (pageContext == null) + if (pageContext is null) { throw new ArgumentNullException(nameof(pageContext), "Parameter cannot be null or empty."); } @@ -42,7 +42,7 @@ public Index(IPageContext pageContext) public void Process(IRenderContext renderContext, VisualTree visualTree) { // test the context - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Products/List.cs b/src/WebExpress.WebCore.Test/WWW/Products/List.cs index 089f34e..9ef0163 100644 --- a/src/WebExpress.WebCore.Test/WWW/Products/List.cs +++ b/src/WebExpress.WebCore.Test/WWW/Products/List.cs @@ -28,7 +28,7 @@ public List(IPageContext pageContext) PageContext = pageContext; // test the injection - if (pageContext == null) + if (pageContext is null) { throw new ArgumentNullException(nameof(pageContext), "Parameter cannot be null or empty."); } @@ -42,7 +42,7 @@ public List(IPageContext pageContext) public void Process(IRenderContext renderContext, VisualTree visualTree) { // test the context - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceA.cs b/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceA.cs index 4f65bf6..446c874 100644 --- a/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceA.cs +++ b/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceA.cs @@ -15,7 +15,7 @@ public sealed class TestResourceA : IResource public TestResourceA(IResourceContext resourceContext) { // test the injection - if (resourceContext == null) + if (resourceContext is null) { throw new ArgumentNullException(nameof(resourceContext), "Parameter cannot be null or empty."); } @@ -29,7 +29,7 @@ public TestResourceA(IResourceContext resourceContext) public Response Process(Request request) { // test the request - if (request == null) + if (request is null) { throw new ArgumentNullException(nameof(request), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceB.cs b/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceB.cs index ab0c2b4..399e1a3 100644 --- a/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceB.cs +++ b/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceB.cs @@ -23,7 +23,7 @@ public TestResourceB() public Response Process(Request request) { // test the request - if (request == null) + if (request is null) { throw new ArgumentNullException(nameof(request), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceC.cs b/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceC.cs index 538e866..41d3f68 100644 --- a/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceC.cs +++ b/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceC.cs @@ -16,13 +16,13 @@ public sealed class TestResourceC : IResource public TestResourceC(IResourceManager resourceManager, IResourceContext resourceContext) { // test the injection - if (resourceManager == null) + if (resourceManager is null) { throw new ArgumentNullException(nameof(resourceManager), "Parameter cannot be null or empty."); } // test the injection - if (resourceContext == null) + if (resourceContext is null) { throw new ArgumentNullException(nameof(resourceContext), "Parameter cannot be null or empty."); } @@ -36,7 +36,7 @@ public TestResourceC(IResourceManager resourceManager, IResourceContext resource public Response Process(Request request) { // test the request - if (request == null) + if (request is null) { throw new ArgumentNullException(nameof(request), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceD.cs b/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceD.cs index 7cc397c..7af5d43 100644 --- a/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceD.cs +++ b/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceD.cs @@ -16,13 +16,13 @@ public sealed class TestResourceD : IResource public TestResourceD(IResourceContext resourceContext, IResourceManager resourceManager) { // test the injection - if (resourceManager == null) + if (resourceManager is null) { throw new ArgumentNullException(nameof(resourceManager), "Parameter cannot be null or empty."); } // test the injection - if (resourceContext == null) + if (resourceContext is null) { throw new ArgumentNullException(nameof(resourceContext), "Parameter cannot be null or empty."); } @@ -36,7 +36,7 @@ public TestResourceD(IResourceContext resourceContext, IResourceManager resource public Response Process(Request request) { // test the request - if (request == null) + if (request is null) { throw new ArgumentNullException(nameof(request), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Settings/TestSettingPageA.cs b/src/WebExpress.WebCore.Test/WWW/Settings/TestSettingPageA.cs index 81340d0..e3e12a8 100644 --- a/src/WebExpress.WebCore.Test/WWW/Settings/TestSettingPageA.cs +++ b/src/WebExpress.WebCore.Test/WWW/Settings/TestSettingPageA.cs @@ -26,7 +26,7 @@ public TestSettingPageA(ISettingPageContext pageContext) PageContext = pageContext; // test the injection - if (pageContext == null) + if (pageContext is null) { throw new ArgumentNullException(nameof(pageContext), "Parameter cannot be null or empty."); } @@ -40,7 +40,7 @@ public TestSettingPageA(ISettingPageContext pageContext) public void Process(IRenderContext renderContext, VisualTree visualTree) { // test the context - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Settings/TestSettingPageB.cs b/src/WebExpress.WebCore.Test/WWW/Settings/TestSettingPageB.cs index 4530e2a..81a9582 100644 --- a/src/WebExpress.WebCore.Test/WWW/Settings/TestSettingPageB.cs +++ b/src/WebExpress.WebCore.Test/WWW/Settings/TestSettingPageB.cs @@ -26,7 +26,7 @@ public TestSettingPageB(ISettingPageContext pageContext) PageContext = pageContext; // test the injection - if (pageContext == null) + if (pageContext is null) { throw new ArgumentNullException(nameof(pageContext), "Parameter cannot be null or empty."); } @@ -40,7 +40,7 @@ public TestSettingPageB(ISettingPageContext pageContext) public void Process(IRenderContext renderContext, VisualTree visualTree) { // test the context - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore.Test/WWW/Settings/TestSettingPageC.cs b/src/WebExpress.WebCore.Test/WWW/Settings/TestSettingPageC.cs index 3f29a23..0e54eb0 100644 --- a/src/WebExpress.WebCore.Test/WWW/Settings/TestSettingPageC.cs +++ b/src/WebExpress.WebCore.Test/WWW/Settings/TestSettingPageC.cs @@ -24,7 +24,7 @@ public TestSettingPageC(ISettingPageContext pageContext) PageContext = pageContext; // test the injection - if (pageContext == null) + if (pageContext is null) { throw new ArgumentNullException(nameof(pageContext), "Parameter cannot be null or empty."); } @@ -38,7 +38,7 @@ public TestSettingPageC(ISettingPageContext pageContext) public void Process(IRenderContext renderContext, VisualTree visualTree) { // test the context - if (renderContext == null) + if (renderContext is null) { throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } diff --git a/src/WebExpress.WebCore/ArgumentParser.cs b/src/WebExpress.WebCore/ArgumentParser.cs index 8b473f7..9ba11f7 100644 --- a/src/WebExpress.WebCore/ArgumentParser.cs +++ b/src/WebExpress.WebCore/ArgumentParser.cs @@ -78,7 +78,7 @@ where x.FullName.Equals(key[1..], StringComparison.OrdinalIgnoreCase) || x.ShortName.Equals(key[1..], StringComparison.OrdinalIgnoreCase) select x).FirstOrDefault(); - if (command != null) + if (command is not null) { argsDict.Add(command.FullName.ToLower(), value.Trim()); } @@ -100,7 +100,7 @@ where x.FullName.Equals(key[1..], StringComparison.OrdinalIgnoreCase) || x.ShortName.Equals(key[1..], StringComparison.OrdinalIgnoreCase) select x).FirstOrDefault(); - if (command != null) + if (command is not null) { argsDict.Add(command.FullName.ToLower(), value.Trim()); } diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index b91e0f5..546460e 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -21,6 +21,7 @@ using WebExpress.WebCore.WebHtml; using WebExpress.WebCore.WebLog; using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebSitemap; using WebExpress.WebCore.WebUri; @@ -98,7 +99,7 @@ public HttpServer(HttpServerContext context) /// public void Start() { - if (HttpServerContext != null && HttpServerContext.Log != null) + if (HttpServerContext is not null && HttpServerContext.Log is not null) { HttpServerContext.Log.Info(message: I18N.Translate("webexpress.webcore:httpserver.run")); } @@ -260,7 +261,7 @@ private Response HandleClient(HttpContext context) HttpServerContext = HttpServerContext }); - if (searchResult != null) + if (searchResult is not null) { var resourceUri = new UriEndpoint(request.Uri, searchResult.Uri.PathSegments); request.Uri = resourceUri; @@ -270,7 +271,7 @@ private Response HandleClient(HttpContext context) // execute resource request.AddParameter(searchResult.Uri.Parameters.Select(x => new Parameter(x.Key, x.Value, ParameterScope.Url))); - if (searchResult.EndpointContext != null) + if (searchResult.EndpointContext is not null) { response = WebEx.ComponentHub.EndpointManager.HandleRequest(request, searchResult.EndpointContext); @@ -288,7 +289,7 @@ private Response HandleClient(HttpContext context) ( !response.Header.Cookies.Where(x => x.Name.Equals("session")).Any() && !request.Header.Cookies.Where(x => x.Name.Equals("session")).Any() && - request.Session != null + request.Session is not null ) { var cookie = new Cookie("session", request.Session.Id.ToString()) { Expires = DateTime.MaxValue }; @@ -384,7 +385,7 @@ private async Task SendResponseAsync(HttpContext context, Response response) responseFeature.ReasonPhrase = response.Reason; responseFeature.Headers.KeepAlive = "true"; - if (response.Header.Location != null) + if (response.Header.Location is not null) { responseFeature.Headers.Location = response.Header.Location; } @@ -457,7 +458,7 @@ private async Task SendResponseAsync(HttpContext context, Response response) .Where(x => route.StartsWith(x.Route.ToString())) .FirstOrDefault(); - if (searchResult != null) + if (searchResult is not null) { return statusPageManager.CreateStatusResponse ( @@ -468,7 +469,7 @@ private async Task SendResponseAsync(HttpContext context, Response response) ); } - if (applicationContext != null) + if (applicationContext is not null) { return statusPageManager.CreateStatusResponse ( diff --git a/src/WebExpress.WebCore/Internationalization/InternationalizationManager.cs b/src/WebExpress.WebCore/Internationalization/InternationalizationManager.cs index bc9ee28..110672f 100644 --- a/src/WebExpress.WebCore/Internationalization/InternationalizationManager.cs +++ b/src/WebExpress.WebCore/Internationalization/InternationalizationManager.cs @@ -137,7 +137,7 @@ public void Register(Assembly assembly, string pluginId) /// The context of the plugin containing the key-value pairs to remove. public void Remove(IPluginContext pluginContext) { - if (pluginContext == null) + if (pluginContext is null) { return; } diff --git a/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs b/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs index 71ccf79..a2d338a 100644 --- a/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs +++ b/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs @@ -80,7 +80,7 @@ private void Register(IPluginContext pluginContext) x => x.IsClass && x.IsSealed && x.IsPublic && - x.GetInterface(typeof(IApplication).Name) != null + x.GetInterface(typeof(IApplication).Name) is not null )) { var id = type.FullName?.ToLower(); @@ -176,7 +176,7 @@ private void Register(IPluginContext pluginContext) /// The context of the plugin that contains the applications to remove. internal void Remove(IPluginContext pluginContext) { - if (pluginContext == null) + if (pluginContext is null) { return; } @@ -267,11 +267,11 @@ public IEnumerable GetApplications(Type application) /// The context of the plugin that contains the applications. public void Boot(IPluginContext pluginContext) { - if (pluginContext == null) + if (pluginContext is null) { return; } - else if (pluginContext.Assembly.GetCustomAttribute() != null) + else if (pluginContext.Assembly.GetCustomAttribute() is not null) { return; } diff --git a/src/WebExpress.WebCore/WebApplication/Model/ApplicationDictionary.cs b/src/WebExpress.WebCore/WebApplication/Model/ApplicationDictionary.cs index d1cff54..3dc6d2d 100644 --- a/src/WebExpress.WebCore/WebApplication/Model/ApplicationDictionary.cs +++ b/src/WebExpress.WebCore/WebApplication/Model/ApplicationDictionary.cs @@ -108,7 +108,7 @@ public IApplicationContext GetApplication(string applicationId) /// The contexts of the applications as an enumeration. public IEnumerable GetApplications(Type application) { - if (application == null) return []; + if (application is null) return []; var items = _dict.Values.SelectMany(x => x.Values) .Where(x => x.ApplicationClass.Equals(application) || application.IsAssignableFrom(x.ApplicationClass)) diff --git a/src/WebExpress.WebCore/WebAsset/Asset.cs b/src/WebExpress.WebCore/WebAsset/Asset.cs index e554e83..7486ba6 100644 --- a/src/WebExpress.WebCore/WebAsset/Asset.cs +++ b/src/WebExpress.WebCore/WebAsset/Asset.cs @@ -48,7 +48,7 @@ public Asset(IComponentHub componentHub, IAssetContext assetContext, IHttpServer /// The response. public Response Process(Request request) { - if (_data == null) + if (_data is null) { return new ResponseNotFound(); } @@ -139,7 +139,7 @@ public Response Process(Request request) /// The data. private byte[] GetData(Assembly assembly) { - if (assembly == null || _embeddedResource == null) + if (assembly is null || _embeddedResource is null) { return []; } diff --git a/src/WebExpress.WebCore/WebAsset/AssetManager.cs b/src/WebExpress.WebCore/WebAsset/AssetManager.cs index 8d7239d..1a1d4a0 100644 --- a/src/WebExpress.WebCore/WebAsset/AssetManager.cs +++ b/src/WebExpress.WebCore/WebAsset/AssetManager.cs @@ -70,7 +70,7 @@ private AssetManager(IComponentHub componentHub, IHttpServerContext httpServerCo .EndsWith(x.AssetContext.Route.ToString().Replace("/", ".")) ); - if (asset != null) + if (asset is not null) { return asset.Instance.Process(request); } diff --git a/src/WebExpress.WebCore/WebAsset/Model/AssetItemDictionary.cs b/src/WebExpress.WebCore/WebAsset/Model/AssetItemDictionary.cs index 4924daa..baad7ec 100644 --- a/src/WebExpress.WebCore/WebAsset/Model/AssetItemDictionary.cs +++ b/src/WebExpress.WebCore/WebAsset/Model/AssetItemDictionary.cs @@ -65,7 +65,7 @@ public bool AddAssetItem(IPluginContext pluginContext, IApplicationContext appli /// An enumeration of asset contexts that were removed. public IEnumerable Remove(IPluginContext pluginContext) { - if (pluginContext == null) + if (pluginContext is null) { return []; } @@ -97,7 +97,7 @@ public IEnumerable Remove(IPluginContext pluginContext) /// An enumeration of asset contexts that were removed. internal IEnumerable Remove(IApplicationContext applicationContext) { - if (applicationContext == null) + if (applicationContext is null) { return []; } diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentAttribute.cs index 6c4884d..0d2edd5 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentAttribute.cs @@ -14,20 +14,13 @@ public class SegmentAttribute : Attribute, IEndpointAttribute, ISegmentAttribute /// private string Segment { get; set; } - /// - /// Returns or sets the display string. - /// - private string Display { get; set; } - /// /// Initializes a new instance of the class. /// /// The segment of the uri path. - /// The display string. - public SegmentAttribute(string segment, string display = null) + public SegmentAttribute(string segment) { Segment = segment; - Display = display; } /// @@ -36,7 +29,7 @@ public SegmentAttribute(string segment, string display = null) /// The path segment. public IUriPathSegment ToPathSegment() { - return new UriPathSegmentConstant(Segment, Display) { }; + return new UriPathSegmentConstant(Segment) { }; } } } diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentDoubleAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentDoubleAttribute.cs index 1c2a208..bbac82b 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentDoubleAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentDoubleAttribute.cs @@ -1,4 +1,5 @@ using System; +using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebAttribute @@ -6,8 +7,12 @@ namespace WebExpress.WebCore.WebAttribute /// /// Attribute to define a double segment in a URI path. /// + /// + /// The type of parameter to associate with the segment key. + /// [AttributeUsage(AttributeTargets.Class)] - public class SegmentDoubleAttribute : Attribute, IEndpointAttribute, ISegmentAttribute + public class SegmentDoubleAttribute : Attribute, IEndpointAttribute, ISegmentAttribute + where TParameter : IParameter { /// /// Returns or sets the name of the variable. @@ -36,7 +41,7 @@ public SegmentDoubleAttribute(string variableName, string display) /// The path segment. public IUriPathSegment ToPathSegment() { - return new UriPathSegmentVariableDouble(VariableName, Display); + return new UriPathSegmentVariableDouble(VariableName, Display); } } } diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentGuidAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentGuidAttribute.cs index 57d1d7d..52eb1db 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentGuidAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentGuidAttribute.cs @@ -1,5 +1,5 @@ using System; -using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebAttribute @@ -7,34 +7,30 @@ namespace WebExpress.WebCore.WebAttribute /// /// A dynamic path segment of type guid. /// + /// + /// The type of parameter to associate with the segment key. + /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public class SegmentGuidAttribute : Attribute, IEndpointAttribute, ISegmentAttribute - where TParameter : Parameter + where TParameter : IParameter { /// /// Returns or sets the name of the variable. /// private string VariableName { get; set; } - /// - /// Returns or sets the display string. - /// - private string Display { get; set; } - /// /// Returns or sets the display format. /// - private UriPathSegmentVariableGuid.Format DisplayFormat { get; set; } + private UriPathSegmentVariableGuid.Format DisplayFormat { get; set; } /// /// Initializes a new instance of the class. /// - /// The display string. /// The display format. - public SegmentGuidAttribute(string display, UriPathSegmentVariableGuid.Format displayFormat = UriPathSegmentVariableGuid.Format.Simple) + public SegmentGuidAttribute(UriPathSegmentVariableGuid.Format displayFormat = UriPathSegmentVariableGuid.Format.Simple) { VariableName = (Activator.CreateInstance() as Parameter)?.Key?.ToLower(); - Display = display; DisplayFormat = displayFormat; } @@ -44,7 +40,7 @@ public SegmentGuidAttribute(string display, UriPathSegmentVariableGuid.Format di /// The path segment. public IUriPathSegment ToPathSegment() { - return new UriPathSegmentVariableGuid(VariableName, Display, DisplayFormat); + return new UriPathSegmentVariableGuid(VariableName, DisplayFormat); } } } diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentIntAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentIntAttribute.cs index ed888a2..8dbfb42 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentIntAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentIntAttribute.cs @@ -1,4 +1,5 @@ using System; +using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebAttribute @@ -6,8 +7,12 @@ namespace WebExpress.WebCore.WebAttribute /// /// Attribute to define an integer segment in a URI path. /// - [AttributeUsage(AttributeTargets.Class)] - public class SegmentIntAttribute : Attribute, IEndpointAttribute, ISegmentAttribute + /// + /// The type of parameter to associate with the segment key. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class SegmentIntAttribute : Attribute, IEndpointAttribute, ISegmentAttribute + where TParameter : IParameter { /// /// Returns or sets the name of the variable. @@ -36,7 +41,7 @@ public SegmentIntAttribute(string variableName, string display) /// The path segment. public IUriPathSegment ToPathSegment() { - return new UriPathSegmentVariableInt(VariableName, Display); + return new UriPathSegmentVariableInt(VariableName, Display); } } } diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentRegexAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentRegexAttribute.cs index ca9b55a..6a9659b 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentRegexAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentRegexAttribute.cs @@ -1,6 +1,5 @@ using System; -using System.Linq.Expressions; -using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebAttribute @@ -8,9 +7,12 @@ namespace WebExpress.WebCore.WebAttribute /// /// Attribute to define a regex segment in a URI path. /// + /// + /// The type of parameter to associate with the segment key. + /// [AttributeUsage(AttributeTargets.Class)] public class SegmentRegexAttribute : Attribute, IEndpointAttribute, ISegmentAttribute - where TParameter : Parameter + where TParameter : IParameter { /// /// Returns or sets the name of the variable. @@ -20,7 +22,7 @@ public class SegmentRegexAttribute : Attribute, IEndpointAttribute, /// /// Reurns or sets the string representation of the expression. /// - private string Expression{ get; set; } + private string Expression { get; set; } /// /// Returns or sets the display string. @@ -45,7 +47,7 @@ public SegmentRegexAttribute(string expression, string display) /// The path segment. public IUriPathSegment ToPathSegment() { - return new UriPathSegmentVariableRegex(VariableName, Expression, Display); + return new UriPathSegmentVariableRegex(VariableName, Expression, Display); } } } diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentStringAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentStringAttribute.cs index 8102162..765e1aa 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentStringAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentStringAttribute.cs @@ -1,5 +1,5 @@ using System; -using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebAttribute @@ -7,9 +7,12 @@ namespace WebExpress.WebCore.WebAttribute /// /// Attribute to define a segment string in a URI path. /// - [AttributeUsage(AttributeTargets.Class)] + /// + /// The type of parameter to associate with the segment key. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public class SegmentStringAttribute : Attribute, IEndpointAttribute, ISegmentAttribute - where TParameter : Parameter + where TParameter : IParameter { /// /// Returns or sets the name of the variable. @@ -37,7 +40,7 @@ public SegmentStringAttribute(string display) /// The path segment. public IUriPathSegment ToPathSegment() { - return new UriPathSegmentVariableString(VariableName, Display); + return new UriPathSegmentVariableString(VariableName, Display); } } } diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentUIntAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentUIntAttribute.cs index fc6b841..3d90a73 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentUIntAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentUIntAttribute.cs @@ -1,4 +1,5 @@ using System; +using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebAttribute @@ -9,8 +10,12 @@ namespace WebExpress.WebCore.WebAttribute /// /// This attribute is used to specify a segment in the URI path that contains an unsigned integer variable. /// - [AttributeUsage(AttributeTargets.Class)] - public class SegmentUIntAttribute : Attribute, IEndpointAttribute, ISegmentAttribute + /// + /// The type of parameter to associate with the segment key. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class SegmentUIntAttribute : Attribute, IEndpointAttribute, ISegmentAttribute + where TParameter : IParameter { /// /// Returns or sets the name of the variable. @@ -39,7 +44,7 @@ public SegmentUIntAttribute(string variableName, string display) /// The path segment. public IUriPathSegment ToPathSegment() { - return new UriPathSegmentVariableUInt(VariableName, Display); + return new UriPathSegmentVariableUInt(VariableName, Display); } } } diff --git a/src/WebExpress.WebCore/WebComponent/ComponentActivator.cs b/src/WebExpress.WebCore/WebComponent/ComponentActivator.cs index 6787bbc..300a0c7 100644 --- a/src/WebExpress.WebCore/WebComponent/ComponentActivator.cs +++ b/src/WebExpress.WebCore/WebComponent/ComponentActivator.cs @@ -28,7 +28,7 @@ public static T CreateInstance(Type responseType, IHttpServerContext httpServ var flags = BindingFlags.NonPublic | BindingFlags.Instance; var constructors = responseType?.GetConstructors(flags); - if (constructors != null) + if (constructors is not null) { foreach (var constructor in constructors.OrderByDescending(x => x.GetParameters().Length)) { @@ -69,7 +69,7 @@ public static T CreateInstance(IHttpServerContext httpServerContext, params o var flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; var constructors = typeof(T).GetConstructors(flags); - if (constructors != null) + if (constructors is not null) { foreach (var constructor in constructors.OrderByDescending(x => x.GetParameters().Length)) { @@ -107,7 +107,7 @@ public static T CreateInstance(Type componentType, IHttpServerContext httpSer var flags = BindingFlags.NonPublic | BindingFlags.Instance; var constructors = componentType?.GetConstructors(flags); - if (constructors != null) + if (constructors is not null) { foreach (var constructor in constructors.OrderByDescending(x => x.GetParameters().Length)) { @@ -149,7 +149,7 @@ public static T CreateInstance(IHttpServerContext httpServerContext, ICompone var flags = BindingFlags.NonPublic | BindingFlags.Instance; var constructors = componentType?.GetConstructors(flags); - if (constructors != null) + if (constructors is not null) { foreach (var constructor in constructors.OrderByDescending(x => x.GetParameters().Length)) { @@ -191,7 +191,7 @@ public static T CreateInstance(IHttpServerContext httpServerContext, ICompone var componentType = typeof(T); var constructors = componentType?.GetConstructors(flags); - if (constructors != null) + if (constructors is not null) { foreach (var constructor in constructors.OrderByDescending(x => x.GetParameters().Length)) { @@ -205,7 +205,7 @@ public static T CreateInstance(IHttpServerContext httpServerContext, ICompone properties.Where(x => x.PropertyType == parameter.ParameterType) .FirstOrDefault()? .GetValue(componentHub) ?? - advancedParameters.Where(x => x != null) + advancedParameters.Where(x => x is not null) .Where(x => x.GetType() == parameter.ParameterType) .FirstOrDefault() ?? null ).ToArray(); @@ -238,7 +238,7 @@ public static TComponent CreateInstance(Type componentType var flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; var constructors = componentType?.GetConstructors(flags); - if (constructors != null) + if (constructors is not null) { foreach (var constructor in constructors.OrderByDescending(x => x.GetParameters().Length)) { @@ -297,7 +297,7 @@ public static IComponent CreateInstance(Type componentType, TContext c var flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; var constructors = componentType?.GetConstructors(flags); - if (constructors != null) + if (constructors is not null) { foreach (var constructor in constructors.OrderByDescending(x => x.GetParameters().Length)) { diff --git a/src/WebExpress.WebCore/WebComponent/ComponentHub.cs b/src/WebExpress.WebCore/WebComponent/ComponentHub.cs index ed96034..8708788 100644 --- a/src/WebExpress.WebCore/WebComponent/ComponentHub.cs +++ b/src/WebExpress.WebCore/WebComponent/ComponentHub.cs @@ -277,7 +277,7 @@ protected ComponentHub(IHttpServerContext httpServerContext) /// The instance of the create and initialized component. private IComponentManager CreateInstance(Type componentType) { - if (componentType == null) + if (componentType is null) { return null; } @@ -353,7 +353,9 @@ internal void Register(IPluginContext pluginContext) _dictionary.Add(pluginContext, []); var componentItems = _dictionary[pluginContext]; - foreach (var type in assembly.GetExportedTypes().Where(x => x.IsClass && x.IsSealed && x.GetInterface(typeof(IComponentManager).Name) != null)) + foreach (var type in assembly + .GetExportedTypes() + .Where(x => x.IsClass && x.IsSealed && x.GetInterface(typeof(IComponentManager).Name) is not null)) { var id = type.FullName?.ToLower(); @@ -466,7 +468,7 @@ internal void ShutDownComponent(IPluginContext pluginContext) /// The context of the plugin that contains the applications to remove. public void Remove(IPluginContext pluginContext) { - if (pluginContext == null) + if (pluginContext is null) { return; } diff --git a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs index 461dcd8..6dba8da 100644 --- a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs +++ b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs @@ -92,7 +92,7 @@ public void Remove() where TEndpointContext : IEndpointContext /// An enumeration of endpoint contexts. public IEnumerable GetEndpoints(Type endpointType, IApplicationContext applicationContext = null) { - if (endpointType == null) + if (endpointType is null) { return []; } @@ -186,14 +186,14 @@ public static IRoute CreateEndpointRoute var typeName = $"{s.FullNamespace}.Index"; var segmentInfoType = classType.Assembly.GetType(typeName, throwOnError: false, ignoreCase: true); - if (segmentInfoType != null) + if (segmentInfoType is not null) { var segAttrType = segmentInfoType.CustomAttributes .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(ISegmentAttribute))) .Select(x => x.AttributeType) .FirstOrDefault(); - var segInstance = segAttrType != null + var segInstance = segAttrType is not null ? segmentInfoType.GetCustomAttribute(segAttrType, false) as ISegmentAttribute : null; var nameAttr = segmentInfoType.CustomAttributes @@ -207,7 +207,7 @@ public static IRoute CreateEndpointRoute segmentResult = segInstance?.ToPathSegment(); name = nameAttr?.ConstructorArguments.FirstOrDefault().Value?.ToString(); description = descAttr?.ConstructorArguments.FirstOrDefault().Value?.ToString(); - icon = iconAttr != null + icon = iconAttr is not null ? Activator.CreateInstance(iconAttr.AttributeType.GenericTypeArguments.FirstOrDefault()) as IIcon : null; } diff --git a/src/WebExpress.WebCore/WebEndpoint/IRoute.cs b/src/WebExpress.WebCore/WebEndpoint/IRoute.cs index 39f8929..2130669 100644 --- a/src/WebExpress.WebCore/WebEndpoint/IRoute.cs +++ b/src/WebExpress.WebCore/WebEndpoint/IRoute.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebEndpoint diff --git a/src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs b/src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs index 4e878a8..a1ae1f1 100644 --- a/src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs +++ b/src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; -using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebEndpoint @@ -153,7 +153,7 @@ public IRoute Concat(string segment) /// A new IRoute instance representing the route after concatenation. public virtual IRoute Concat(params IUriPathSegment[] segments) { - if (segments == null || segments.Length == 0) + if (segments is null || segments.Length == 0) { return this; } @@ -161,7 +161,7 @@ public virtual IRoute Concat(params IUriPathSegment[] segments) var copy = new RouteEndpoint((IRoute)this); copy.PathSegments = copy.PathSegments .Select(x => x.Copy()) - .Concat(segments.Where(x => x != null).Where(x => !x.IsEmpty)); + .Concat(segments.Where(x => x is not null).Where(x => !x.IsEmpty)); return copy; } diff --git a/src/WebExpress.WebCore/WebEvent/EventManager.cs b/src/WebExpress.WebCore/WebEvent/EventManager.cs index 04d6b87..a2630df 100644 --- a/src/WebExpress.WebCore/WebEvent/EventManager.cs +++ b/src/WebExpress.WebCore/WebEvent/EventManager.cs @@ -155,8 +155,8 @@ private void Register(IPluginContext pluginContext, IEnumerable).Name) != null + x.GetInterface(typeof(IEventHandler).Name) is not null || + x.GetInterface(typeof(IEventHandler<>).Name) is not null ) )) { @@ -260,7 +260,7 @@ internal void Remove(IPluginContext pluginContext) /// The context of the application that contains the events to remove. internal void Remove(IApplicationContext applicationContext) { - if (applicationContext == null) + if (applicationContext is null) { return; } diff --git a/src/WebExpress.WebCore/WebEvent/Model/EventItem.cs b/src/WebExpress.WebCore/WebEvent/Model/EventItem.cs index 16c78f1..0f294a3 100644 --- a/src/WebExpress.WebCore/WebEvent/Model/EventItem.cs +++ b/src/WebExpress.WebCore/WebEvent/Model/EventItem.cs @@ -97,7 +97,7 @@ public void Process(object sender, IEventArgument eventArgument) .GetInterfaces() .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEventHandler<>)); - if (handlerType != null) + if (handlerType is not null) { var genericArgument = handlerType.GetGenericArguments().First(); var method = handlerType.GetMethod("Process"); diff --git a/src/WebExpress.WebCore/WebFragment/FragmentComparer.cs b/src/WebExpress.WebCore/WebFragment/FragmentComparer.cs index 2d627ff..112bcc6 100644 --- a/src/WebExpress.WebCore/WebFragment/FragmentComparer.cs +++ b/src/WebExpress.WebCore/WebFragment/FragmentComparer.cs @@ -19,7 +19,7 @@ public class FragmentComparer : IEqualityComparer /// True if both objects are similar; false otherwise. public bool Equals(T x, T y) { - if (x == null && y == null) + if (x is null && y is null) { return true; } diff --git a/src/WebExpress.WebCore/WebFragment/FragmentManager.cs b/src/WebExpress.WebCore/WebFragment/FragmentManager.cs index bf5d45b..51bef5c 100644 --- a/src/WebExpress.WebCore/WebFragment/FragmentManager.cs +++ b/src/WebExpress.WebCore/WebFragment/FragmentManager.cs @@ -105,7 +105,7 @@ private void Register(IPluginContext pluginContext, IEnumerable x.IsClass == true && x.IsSealed && x.IsPublic) - .Where(x => x.GetInterface(typeof(IFragment<,>).Name) != null)) + .Where(x => x.GetInterface(typeof(IFragment<,>).Name) is not null)) { var id = fragmentType.FullName?.ToLower(); var scopes = new List(); @@ -236,7 +236,7 @@ private void Register(IPluginContext pluginContext, IEnumerableThe context of the plugin that contains the components to remove. internal void Remove(IPluginContext pluginContext) { - if (pluginContext == null) + if (pluginContext is null) { return; } @@ -255,7 +255,7 @@ internal void Remove(IPluginContext pluginContext) /// The context of the application that contains the fragments to remove. internal void Remove(IApplicationContext applicationContext) { - if (applicationContext == null) + if (applicationContext is null) { return; } diff --git a/src/WebExpress.WebCore/WebFragment/Model/FragmentDictionary.cs b/src/WebExpress.WebCore/WebFragment/Model/FragmentDictionary.cs index 1213440..c1a5ebd 100644 --- a/src/WebExpress.WebCore/WebFragment/Model/FragmentDictionary.cs +++ b/src/WebExpress.WebCore/WebFragment/Model/FragmentDictionary.cs @@ -37,7 +37,7 @@ public bool AddFragmentItem(IPluginContext pluginContext, IApplicationContext ap { var type = fragmentItem.FragmentClass; - if (type.GetInterface(typeof(IFragment<,>).Name) == null) + if (type.GetInterface(typeof(IFragment<,>).Name) is null) { return false; } diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs index 3b9410b..e7cd6cf 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs @@ -278,7 +278,7 @@ protected string GetAttribute(string name) { var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); - if (a != null) + if (a is not null) { return a is HtmlAttribute ? (a as HtmlAttribute).Value : string.Empty; } @@ -295,7 +295,7 @@ protected bool HasAttribute(string name) { var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); - return (a != null); + return (a is not null); } /// @@ -307,7 +307,7 @@ protected void SetAttribute(string name, string value) { var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); - if (a != null) + if (a is not null) { if (string.IsNullOrWhiteSpace(value)) { @@ -340,7 +340,7 @@ protected void SetAttribute(string name) var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); - if (a == null) + if (a is null) { _attributes.Add(new HtmlAttributeNoneValue(name)); } @@ -354,7 +354,7 @@ protected void RemoveAttribute(string name) { var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); - if (a != null) + if (a is not null) { _attributes.Remove(a); } @@ -378,7 +378,7 @@ protected HtmlElement GetElement(string name) /// The element. protected void SetElement(HtmlElement element) { - if (element != null) + if (element is not null) { var a = _elements.Where(x => x is HtmlElement && (x as HtmlElement).ElementName == element.ElementName); @@ -430,7 +430,7 @@ public virtual void ToString(StringBuilder builder, int deep) closeTag = true; var count = builder.Length; - foreach (var v in _elements.Where(x => x != null)) + foreach (var v in _elements.Where(x => x is not null)) { v.ToString(builder, deep + 1); } diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementSectionBody.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementSectionBody.cs index 69f1bb6..a431b3e 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementSectionBody.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementSectionBody.cs @@ -70,7 +70,7 @@ public override void ToString(StringBuilder builder, int deep) { ToPreString(builder, deep); - foreach (var v in Elements.Where(x => x != null)) + foreach (var v in Elements.Where(x => x is not null)) { v.ToString(builder, deep + 1); } diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs index bf1f4ec..5457981 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs @@ -126,7 +126,7 @@ private void Register(IPluginContext pluginContext, IEnumerableThe context of the application that contains the identities to remove. internal void Remove(IApplicationContext applicationContext) { - if (applicationContext == null) + if (applicationContext is null) { return; } diff --git a/src/WebExpress.WebCore/WebIdentity/Model/IdentityPermissionDictionary.cs b/src/WebExpress.WebCore/WebIdentity/Model/IdentityPermissionDictionary.cs index 3ddc71f..5f7ce50 100644 --- a/src/WebExpress.WebCore/WebIdentity/Model/IdentityPermissionDictionary.cs +++ b/src/WebExpress.WebCore/WebIdentity/Model/IdentityPermissionDictionary.cs @@ -68,7 +68,7 @@ public void RemovePermissionItem(IPluginContext pluginConte var permissionList = appContextDict[applicationContext]; var itemToRemove = permissionList.FirstOrDefault(x => x.PermissionClass == type); - if (itemToRemove != null) + if (itemToRemove is not null) { permissionList.Remove(itemToRemove); diff --git a/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyDictionary.cs b/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyDictionary.cs index d47725e..b1530e6 100644 --- a/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyDictionary.cs +++ b/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyDictionary.cs @@ -66,7 +66,7 @@ public void RemovePolicyItem(IPluginContext pluginContext, IApp if (appContextDict.TryGetValue(applicationContext, out var policyList)) { var itemToRemove = policyList.FirstOrDefault(x => x.PolicyClass == type); - if (itemToRemove != null) + if (itemToRemove is not null) { policyList.Remove(itemToRemove); diff --git a/src/WebExpress.WebCore/WebInclude/IncludeManager.cs b/src/WebExpress.WebCore/WebInclude/IncludeManager.cs index 864f76c..af622c4 100644 --- a/src/WebExpress.WebCore/WebInclude/IncludeManager.cs +++ b/src/WebExpress.WebCore/WebInclude/IncludeManager.cs @@ -68,7 +68,7 @@ private IncludeManager(IComponentHub componentHub, IHttpServerContext httpServer /// The plugin context. private void Register(IPluginContext pluginContext) { - if (pluginContext == null) + if (pluginContext is null) { return; } @@ -87,7 +87,7 @@ private void Register(IPluginContext pluginContext) /// The application context. private void Register(IApplicationContext applicationContext) { - if (applicationContext == null) + if (applicationContext is null) { return; } @@ -112,14 +112,14 @@ private void Register(IPluginContext pluginContext, IEnumerable x.IsClass && x.IsSealed && x.IsPublic) - .Where(x => x.GetInterface(typeof(IInclude).Name) != null)) + .Where(x => x.GetInterface(typeof(IInclude).Name) is not null)) { var id = includeType.FullName?.ToLower(); var cache = false; @@ -205,7 +205,7 @@ private void Register(IPluginContext pluginContext, IEnumerableThe plugin context. internal void Remove(IPluginContext pluginContext) { - if (pluginContext == null) + if (pluginContext is null) { return; } @@ -237,7 +237,7 @@ internal void Remove(IPluginContext pluginContext) /// The application context. internal void Remove(IApplicationContext applicationContext) { - if (applicationContext == null) + if (applicationContext is null) { return; } @@ -264,7 +264,7 @@ internal void Remove(IApplicationContext applicationContext) /// Enumerable of include contexts. public IEnumerable GetIncludes(IApplicationContext applicationContext) { - if (applicationContext == null) + if (applicationContext is null) { return []; } @@ -284,9 +284,9 @@ public IEnumerable GetIncludes(IApplicationContext applicationC /// Enumerable of include contexts. public IEnumerable GetIncludes(IApplicationContext applicationContext, Type includeType) { - if (applicationContext == null || includeType == null) + if (applicationContext is null || includeType is null) { - return Enumerable.Empty(); + return []; } return _dictionary.Values diff --git a/src/WebExpress.WebCore/WebInclude/Model/IncludeDictionary.cs b/src/WebExpress.WebCore/WebInclude/Model/IncludeDictionary.cs index 5da0c95..b6464ec 100644 --- a/src/WebExpress.WebCore/WebInclude/Model/IncludeDictionary.cs +++ b/src/WebExpress.WebCore/WebInclude/Model/IncludeDictionary.cs @@ -19,14 +19,14 @@ internal sealed class IncludeDictionary : DictionaryTrue if the item was successfully added; otherwise, false if an item with the same key already exists in the collection. public bool AddIncludeItem(IPluginContext pluginContext, IApplicationContext applicationContext, IncludeItem includeItem) { - if (pluginContext == null || applicationContext == null || includeItem == null) + if (pluginContext is null || applicationContext is null || includeItem is null) { return false; } if (!TryGetValue(pluginContext, out var appDict)) { - appDict = new Dictionary>(); + appDict = []; Add(pluginContext, appDict); } diff --git a/src/WebExpress.WebCore/WebJob/JobManager.cs b/src/WebExpress.WebCore/WebJob/JobManager.cs index a6fe07c..9f3b27f 100644 --- a/src/WebExpress.WebCore/WebJob/JobManager.cs +++ b/src/WebExpress.WebCore/WebJob/JobManager.cs @@ -115,7 +115,7 @@ private void Register(IPluginContext pluginContext, IEnumerable x.IsClass == true && x.IsSealed && x.IsPublic && - x.GetInterface(typeof(IJob).Name) != null + x.GetInterface(typeof(IJob).Name) is not null )) { var id = job.FullName?.ToLower(); @@ -201,7 +201,7 @@ private void Register(IPluginContext pluginContext, IEnumerableThe context of the plugin that contains the jobs to remove. internal void Remove(IPluginContext pluginContext) { - if (pluginContext == null) + if (pluginContext is null) { return; } @@ -227,7 +227,7 @@ internal void Remove(IPluginContext pluginContext) /// The context of the application that contains the jobs to remove. internal void Remove(IApplicationContext applicationContext) { - if (applicationContext == null) + if (applicationContext is null) { return; } diff --git a/src/WebExpress.WebCore/WebLog/Log.cs b/src/WebExpress.WebCore/WebLog/Log.cs index b437925..14fc7c0 100644 --- a/src/WebExpress.WebCore/WebLog/Log.cs +++ b/src/WebExpress.WebCore/WebLog/Log.cs @@ -91,7 +91,7 @@ public class Log : ILog /// /// Checks if the log has been opened for writing. /// - public bool IsOpen => _workerThread != null; + public bool IsOpen => _workerThread is not null; /// /// Returns the log mode. @@ -398,7 +398,9 @@ public void Exception(Exception ex, [CallerMemberName] string instance = null, [ lock (_queue) { Add(LogLevel.Exception, ex?.Message.Trim(), $"{className}.{instance}", line, file); - Add(LogLevel.Exception, ex?.StackTrace != null ? ex?.StackTrace.Trim() : ex?.Message.Trim(), $"{className}.{instance}", line, file); + Add(LogLevel.Exception, ex?.StackTrace is not null + ? ex?.StackTrace.Trim() + : ex?.Message.Trim(), $"{className}.{instance}", line, file); ExceptionCount++; ErrorCount++; diff --git a/src/WebExpress.WebCore/WebMessage/Request.cs b/src/WebExpress.WebCore/WebMessage/Request.cs index 49e54c8..742201c 100644 --- a/src/WebExpress.WebCore/WebMessage/Request.cs +++ b/src/WebExpress.WebCore/WebMessage/Request.cs @@ -9,6 +9,7 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using WebExpress.WebCore.WebHtml; +using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebSession.Model; using WebExpress.WebCore.WebUri; @@ -20,6 +21,8 @@ namespace WebExpress.WebCore.WebMessage /// public class Request { + private readonly ParameterDictionary _param = []; + /// /// The context of the web server. /// @@ -35,11 +38,6 @@ public class Request /// public UriEndpoint Uri { get; internal set; } - /// - /// Returns the parameters. - /// - private ParameterDictionary Param { get; } = []; - /// /// Returns the session. /// @@ -384,14 +382,14 @@ private void ParseRequestParams() last = new Parameter(match.Groups[1].ToString().Trim(), match.Groups[2].ToString().Trim(), ParameterScope.Parameter); AddParameter(last); } - else if (last != null) + else if (last is not null) { last.Value += "\r\n" + v; } } - if (last != null) + if (last is not null) { last.Value = last.Value.TrimEnd(); } @@ -432,7 +430,7 @@ private void ParseSessionParams() Session = WebEx.ComponentHub?.SessionManager?.GetSession(this); var property = Session?.GetProperty(); - if (property != null && property.Params != null) + if (property is not null && property.Params is not null) { foreach (var param in property.Params) { @@ -461,9 +459,9 @@ public void AddParameter(Parameter param) { var key = param.Key.ToLower(); - if (!Param.TryAdd(key, param)) + if (!_param.TryAdd(key, param)) { - Param[key] = param; + _param[key] = param; } } @@ -472,11 +470,11 @@ public void AddParameter(Parameter param) /// /// The name of the parameter. /// The value. - public Parameter GetParameter(string name) + public IParameter GetParameter(string name) { if (!string.IsNullOrWhiteSpace(name) && HasParameter(name)) { - return Param[name.ToLower()]; + return _param[name.ToLower()]; } return null; @@ -485,15 +483,22 @@ public Parameter GetParameter(string name) /// /// Returns a parameter by name. /// - /// The parameter. + /// The parameter. /// The value. - public Parameter GetParameter() where T : Parameter + public IParameter GetParameter() + where TParameter : IParameter { - var name = Parameter.GetKey(); + var parameter = Parameter.GetParameter(); - if (!string.IsNullOrWhiteSpace(name) && HasParameter(name)) + if (parameter is not null + && !string.IsNullOrWhiteSpace(parameter.Key) + && HasParameter(parameter.Key)) { - return Param[name.ToLower()]; + var p = _param[parameter.Key.ToLower()]; + parameter.Value = p.Value; + parameter.Scope = p.Scope; + + return parameter; } return null; @@ -506,12 +511,12 @@ public Parameter GetParameter() where T : Parameter /// True if parameters are present, false otherwise. public bool HasParameter(string name) { - if (name == null) + if (name is null) { return false; } - return Param.ContainsKey(name.ToLower()); + return _param.ContainsKey(name.ToLower()); } } } diff --git a/src/WebExpress.WebCore/WebMessage/RequestAuthorization.cs b/src/WebExpress.WebCore/WebMessage/RequestAuthorization.cs index 428f21d..e0cd541 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestAuthorization.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestAuthorization.cs @@ -37,7 +37,7 @@ public partial class RequestAuthorization /// public static RequestAuthorization Parse(string str) { - if (str == null) + if (str is null) { return null; } diff --git a/src/WebExpress.WebCore/WebPackage/PackageManager.cs b/src/WebExpress.WebCore/WebPackage/PackageManager.cs index dc138a8..724b947 100644 --- a/src/WebExpress.WebCore/WebPackage/PackageManager.cs +++ b/src/WebExpress.WebCore/WebPackage/PackageManager.cs @@ -153,14 +153,14 @@ public void Scan() var packageFiles = Directory.GetFiles(_httpServerContext.PackagePath, "*.wxp").Select(x => Path.GetFileName(x)).ToList(); // all packages that are not yet installed - var newPackages = packageFiles.Except(Catalog.Packages.Where(x => x != null).Select(x => x.File)).ToList(); + var newPackages = packageFiles.Except(Catalog.Packages.Where(x => x is not null).Select(x => x.File)).ToList(); // all packages that are no longer available - var removePackages = Catalog.Packages.Where(x => x != null).Select(x => x.File).Except(packageFiles).ToList(); + var removePackages = Catalog.Packages.Where(x => x is not null).Select(x => x.File).Except(packageFiles).ToList(); // determine changed packages by comparing spec version and relevant metadata var changedPackages = new List(); - foreach (var existing in Catalog.Packages.Where(x => x != null)) + foreach (var existing in Catalog.Packages.Where(x => x is not null)) { var fullPath = Path.Combine(_httpServerContext.PackagePath, existing.File); if (!File.Exists(fullPath)) @@ -169,7 +169,7 @@ public void Scan() } var fromFile = LoadPackage(fullPath); - if (fromFile == null) + if (fromFile is null) { continue; } @@ -183,7 +183,7 @@ public void Scan() foreach (var package in newPackages) { var packagesFromFile = LoadPackage(Path.Combine(_httpServerContext.PackagePath, package)); - if (packagesFromFile == null) + if (packagesFromFile is null) { continue; } @@ -211,14 +211,14 @@ public void Scan() foreach (var package in changedPackages) { - var existing = Catalog.Packages.FirstOrDefault(x => x != null && x.File == package); - if (existing == null) + var existing = Catalog.Packages.FirstOrDefault(x => x is not null && x.File == package); + if (existing is null) { continue; } var fromFile = LoadPackage(Path.Combine(_httpServerContext.PackagePath, package)); - if (fromFile == null) + if (fromFile is null) { continue; } @@ -252,8 +252,8 @@ public void Scan() foreach (var package in removePackages) { - var existing = Catalog.Packages.FirstOrDefault(x => x != null && x.File == package); - if (existing == null) + var existing = Catalog.Packages.FirstOrDefault(x => x is not null && x.File == package); + if (existing is null) { continue; } @@ -305,7 +305,7 @@ private PackageCatalogItem LoadPackage(string file) using var zip = ZipFile.Open(file, ZipArchiveMode.Read); var specEntry = zip.Entries.Where(x => Path.GetExtension(x.FullName) == ".spec").FirstOrDefault(); - if (specEntry == null) + if (specEntry is null) { _httpServerContext.Log.Warning($"package spec was not found in '{file}'"); return null; @@ -559,13 +559,13 @@ private void Log() /// True if changed; otherwise false. private static bool HasPackageChanged(PackageCatalogItem existing, PackageCatalogItem fromFile) { - if (existing == null || fromFile == null) + if (existing is null || fromFile is null) { return false; } // if no metadata was present, treat as no change and let metadata be assigned on next run - if (existing.Metadata == null || fromFile.Metadata == null) + if (existing.Metadata is null || fromFile.Metadata is null) { return false; } @@ -602,7 +602,7 @@ private static bool HasPackageChanged(PackageCatalogItem existing, PackageCatalo /// The package. private void DeactivateAndUnregisterPackage(PackageCatalogItem package) { - if (package == null) + if (package is null) { return; } diff --git a/src/WebExpress.WebCore/WebPage/Model/PageDictionary.cs b/src/WebExpress.WebCore/WebPage/Model/PageDictionary.cs index 2925951..8a67878 100644 --- a/src/WebExpress.WebCore/WebPage/Model/PageDictionary.cs +++ b/src/WebExpress.WebCore/WebPage/Model/PageDictionary.cs @@ -34,7 +34,7 @@ public bool AddPageItem(IPluginContext pluginContext, IApplicationContext applic { var type = pageItem.PageClass; - if (type.GetInterface(typeof(IPage<>).Name) == null) + if (type.GetInterface(typeof(IPage<>).Name) is null) { return false; } diff --git a/src/WebExpress.WebCore/WebPage/PageManager.cs b/src/WebExpress.WebCore/WebPage/PageManager.cs index 066b472..2491e47 100644 --- a/src/WebExpress.WebCore/WebPage/PageManager.cs +++ b/src/WebExpress.WebCore/WebPage/PageManager.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -25,7 +26,7 @@ public class PageManager : IPageManager private readonly IComponentHub _componentHub; private readonly IHttpServerContext _httpServerContext; private readonly PageDictionary _dictionary = new(); - private static readonly Dictionary _delegateCache = []; + private static readonly ConcurrentDictionary _delegateCache = new(); /// /// An event that fires when an page is added. @@ -59,44 +60,52 @@ private PageManager(IComponentHub componentHub, IHttpServerContext httpServerCon var endpointtRegistration = new EndpointRegistration() { - EndpointResolver = (type, applicationContext) => applicationContext != null ? GetPages(type, applicationContext) : GetPages(type), + EndpointResolver = (type, applicationContext) => applicationContext is not null + ? GetPages(type, applicationContext) + : GetPages(type), EndpointsResolver = () => Pages, HandleRequest = (request, endpontContext) => { + // create or get page instance for this request var pageInstance = CreatePageInstance(endpontContext as IPageContext); var pageType = pageInstance.GetType(); var pageContext = endpontContext as IPageContext; var renderContext = new RenderContext(pageInstance, pageContext, request); var visualTreeContext = new VisualTreeContext(renderContext); - var visualTreeType = pageType.GetInterface(typeof(IPage<>).Name).GetGenericArguments()[0]; + // determine visual tree type implemented by the page + var pageInterface = pageType.GetInterface(typeof(IPage<>).Name) ?? throw new InvalidOperationException($"Page type {pageType.FullName} does not implement IPage<>."); + var visualTreeType = pageInterface.GetGenericArguments()[0]; + + // obtain or create a cached open-instance delegate safely if (!_delegateCache.TryGetValue(pageType, out var del)) { - // create and compile the expression + // create an open-instance delegate: (instance, renderContext, visualTree) => instance.Process(renderContext, visualTree) + var instanceParam = Expression.Parameter(pageType, "instance"); var renderContextParam = Expression.Parameter(typeof(IRenderContext), "renderContext"); var visualTreeParam = Expression.Parameter(visualTreeType, "visualTree"); - var processMethod = pageType.GetMethod("Process", [typeof(IRenderContext), visualTreeType]); - var callProzessMethod = Expression.Call - ( - Expression.Constant(pageInstance), - processMethod, - renderContextParam, - visualTreeParam - ); - var lambda = Expression.Lambda(callProzessMethod, renderContextParam, visualTreeParam) - .Compile(); - _delegateCache[pageType] = lambda; - del = lambda; + // find Process method matching signature Process(IRenderContext, TVisualTree) + var processMethod = pageType.GetMethod("Process", new[] { typeof(IRenderContext), visualTreeType }) ?? throw new InvalidOperationException($"Process method not found on type {pageType.FullName}"); + + // call instance.Process(renderContext, visualTree) + var callProcess = Expression.Call(instanceParam, processMethod, renderContextParam, visualTreeParam); + + // compile lambda with signature (instance, renderContext, visualTree) + var lambda = Expression.Lambda(callProcess, instanceParam, renderContextParam, visualTreeParam).Compile(); + + // add to concurrent dictionary atomically; if another thread added concurrently, use the existing one + del = _delegateCache.GetOrAdd(pageType, lambda); } // create visual tree instance - var visualTreeInstance = default(IVisualTree); + IVisualTree visualTreeInstance = null; var flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; var constructors = visualTreeType?.GetConstructors(flags); - if (constructors != null) + if (constructors is not null) { + // try constructors ordered by parameter count (descending) foreach (var constructor in constructors.OrderByDescending(x => x.GetParameters().Length)) { // injection @@ -107,30 +116,43 @@ private PageManager(IComponentHub componentHub, IHttpServerContext httpServerCon .FirstOrDefault(); var parameterValues = parameters.Select(parameter => - parameter.ParameterType == typeof(IComponentHub) ? componentHub : - parameter.ParameterType == typeof(IHttpServerContext) ? httpServerContext : + parameter.ParameterType == typeof(IComponentHub) ? _componentHub : + parameter.ParameterType == typeof(IHttpServerContext) ? _httpServerContext : parameter.ParameterType == typeof(IPageContext) ? pageContext : + parameter.ParameterType == typeof(IApplicationContext) ? pageContext?.ApplicationContext : parameter.ParameterType == typeof(IComponentId) ? contextIdProperty?.GetValue(pageContext) : hubProperties.Where(x => x.PropertyType == parameter.ParameterType) .FirstOrDefault()? - .GetValue(componentHub) ?? null + .GetValue(_componentHub) ?? null ).ToArray(); - if (constructor.Invoke(parameterValues) is IVisualTree visualTree) + // attempt to invoke constructor with resolved parameters + var invoked = constructor.Invoke(parameterValues); + if (invoked is IVisualTree visualTree) { visualTreeInstance = visualTree; + break; } } } else { - visualTreeInstance = Activator.CreateInstance(); + // fallback: try parameterless creation + visualTreeInstance = Activator.CreateInstance(visualTreeType) as IVisualTree; } - // execute the cached delegate - del.DynamicInvoke(renderContext, visualTreeInstance); + if (visualTreeInstance is null) + { + throw new InvalidOperationException($"Could not create visual tree instance of type {visualTreeType.FullName} for page {pageType.FullName}."); + } + + // execute the cached open-instance delegate; pass the current pageInstance + del.DynamicInvoke(pageInstance, renderContext, visualTreeInstance); + + // build response from visual tree + var response = visualTreeInstance.GetResponse(visualTreeContext); - return visualTreeInstance.GetResponse(visualTreeContext); + return response; } }; @@ -230,7 +252,7 @@ private IEndpoint CreatePageInstance(IPageContext pageContext) { var resourceItem = _dictionary.GetPageItem(pageContext); - if (resourceItem != null && resourceItem.Instance == null) + if (resourceItem is not null && resourceItem.Instance is null) { var instance = ComponentActivator.CreateInstance ( @@ -294,7 +316,7 @@ private void Register(IPluginContext pluginContext, IEnumerable x.IsClass == true && x.IsSealed && x.IsPublic) - .Where(x => x.GetInterface(typeof(IPage<>).Name) != null)) + .Where(x => x.GetInterface(typeof(IPage<>).Name) is not null)) { var id = pageType.FullName?.ToLower(); var segment = default(ISegmentAttribute); @@ -417,7 +439,7 @@ private void Register(IPluginContext pluginContext, IEnumerableThe context of the plugin that contains the pages to remove. public void Remove(IPluginContext pluginContext) { - if (pluginContext == null) + if (pluginContext is null) { return; } @@ -435,7 +457,7 @@ public void Remove(IPluginContext pluginContext) /// The context of the application that contains the page to remove. internal void Remove(IApplicationContext applicationContext) { - if (applicationContext == null) + if (applicationContext is null) { return; } diff --git a/src/WebExpress.WebCore/WebPage/VisualTree.cs b/src/WebExpress.WebCore/WebPage/VisualTree.cs index 948e150..f6d0c2b 100644 --- a/src/WebExpress.WebCore/WebPage/VisualTree.cs +++ b/src/WebExpress.WebCore/WebPage/VisualTree.cs @@ -75,7 +75,7 @@ public VisualTree() /// The java script code. public virtual void AddScript(string key, string code) { - if (key == null) return; + if (key is null) return; var k = key.ToLower(); var dict = Scripts; @@ -124,8 +124,8 @@ public virtual IHtmlNode Render(IVisualTreeContext context) html.Body.Add(Content); html.Body.Scripts = [.. Scripts.Values]; - html.Head.CssLinks = CssLinks.Where(x => x != null).Select(x => x.ToString()); - html.Head.ScriptLinks = HeaderScriptLinks?.Where(x => x != null).Select(x => x.ToString()); + html.Head.CssLinks = CssLinks.Where(x => x is not null).Select(x => x.ToString()); + html.Head.ScriptLinks = HeaderScriptLinks?.Where(x => x is not null).Select(x => x.ToString()); return html; } diff --git a/src/WebExpress.WebCore/WebParameter/IParameter.cs b/src/WebExpress.WebCore/WebParameter/IParameter.cs new file mode 100644 index 0000000..bf5f04d --- /dev/null +++ b/src/WebExpress.WebCore/WebParameter/IParameter.cs @@ -0,0 +1,48 @@ +using WebExpress.WebCore.WebIcon; +using WebExpress.WebCore.WebPage; + +namespace WebExpress.WebCore.WebParameter +{ + /// + /// Represents a parameter with a key, value, and scope. + /// + public interface IParameter + { + /// + /// Returns the key of the parameter. + /// + string Key { get; } + + /// + /// Returns or sets the scope of the parameter. + /// + ParameterScope Scope { get; internal set; } + + /// + /// Returns the value of the parameter. + /// + string Value { get; internal set; } + + /// + /// Returns a string that represents the display text for the current instance. + /// + /// The render context. + /// + /// A string containing the display text associated with the instance. The + /// value may be empty if no display text is available. + /// + string GetDisplayText(IRenderContext renderContext); + + /// + /// Returns an icon that visually represents the parameter within the given render context. + /// + /// + /// The rendering context that provides information required to determine the appropriate icon. + /// + /// + /// An icon associated with the current instance. The value may be null or empty + /// if no icon is available. + /// + IIcon GetIcon(IRenderContext renderContext); + } +} diff --git a/src/WebExpress.WebCore/WebMessage/Parameter.cs b/src/WebExpress.WebCore/WebParameter/Parameter.cs similarity index 72% rename from src/WebExpress.WebCore/WebMessage/Parameter.cs rename to src/WebExpress.WebCore/WebParameter/Parameter.cs index 8f1d4fd..ef3afe2 100644 --- a/src/WebExpress.WebCore/WebMessage/Parameter.cs +++ b/src/WebExpress.WebCore/WebParameter/Parameter.cs @@ -1,28 +1,30 @@ using System; using System.Collections.Generic; using System.Text; +using WebExpress.WebCore.WebIcon; +using WebExpress.WebCore.WebPage; -namespace WebExpress.WebCore.WebMessage +namespace WebExpress.WebCore.WebParameter { /// /// Represents a parameter with a key, value, and scope. /// - public class Parameter + public class Parameter : IParameter { /// - /// Returns or sets the scope of the parameter. + /// Returns the key of the parameter. /// - public ParameterScope Scope { get; private set; } + public string Key { get; private set; } /// - /// Returns the key of the parameter. + /// Returns or sets the scope of the parameter. /// - public string Key { get; private set; } + public ParameterScope Scope { get; set; } /// /// Returns the value of the parameter. /// - public string Value { get; internal set; } + public string Value { get; set; } /// /// Initializes a new instance of the class. @@ -95,6 +97,34 @@ public Parameter(string key, char value, ParameterScope scope) Scope = scope; } + /// + /// Returns a string that represents the display text for the current instance. + /// + /// The render context. + /// + /// A string containing the display text associated with the instance. The + /// value may be empty if no display text is available. + /// + public virtual string GetDisplayText(IRenderContext renderContext) + { + return Value; + } + + /// + /// Returns an icon that visually represents the parameter within the given render context. + /// + /// + /// The rendering context that provides information required to determine the appropriate icon. + /// + /// + /// An icon associated with the current instance. The value may be null or empty + /// if no icon is available. + /// + public virtual IIcon GetIcon(IRenderContext renderContext) + { + return null; + } + /// /// Creates a parameter list. /// @@ -110,7 +140,19 @@ public static List Create(params Parameter[] param) /// /// The type. /// The key. - public static string GetKey() where TParameter : Parameter + public static TParameter GetParameter() + where TParameter : IParameter + { + return Activator.CreateInstance(); + } + + /// + /// Returns the key. + /// + /// The type. + /// The key. + public static string GetKey() + where TParameter : IParameter { return Activator.CreateInstance()?.Key; } diff --git a/src/WebExpress.WebCore/WebParameter/ParameterApiVersion.cs b/src/WebExpress.WebCore/WebParameter/ParameterApiVersion.cs new file mode 100644 index 0000000..f0eda26 --- /dev/null +++ b/src/WebExpress.WebCore/WebParameter/ParameterApiVersion.cs @@ -0,0 +1,40 @@ +using WebExpress.WebCore.WebPage; + +namespace WebExpress.WebCore.WebParameter +{ + /// + /// Represents a api version parameter with a key, value, and scope. + /// + public class ParameterApiVersion : Parameter + { + /// + /// Initializes a new instance of the class. + /// + public ParameterApiVersion() + : base("_apiVersion", null, ParameterScope.Url) + { + } + + /// + /// Initializes a new instance of the class with a specified value. + /// + /// The value of the parameter. + public ParameterApiVersion(string value) + : base("_apiVersion", value, ParameterScope.Url) + { + } + + /// + /// Returns a string that represents the display text for the current instance. + /// + /// The render context. + /// + /// A string containing the display text associated with the instance. The + /// value may be empty if no display text is available. + /// + public override string GetDisplayText(IRenderContext renderContext) + { + return Value; + } + } +} diff --git a/src/WebExpress.WebCore/WebMessage/ParameterDictionary.cs b/src/WebExpress.WebCore/WebParameter/ParameterDictionary.cs similarity index 63% rename from src/WebExpress.WebCore/WebMessage/ParameterDictionary.cs rename to src/WebExpress.WebCore/WebParameter/ParameterDictionary.cs index cb515fe..0034368 100644 --- a/src/WebExpress.WebCore/WebMessage/ParameterDictionary.cs +++ b/src/WebExpress.WebCore/WebParameter/ParameterDictionary.cs @@ -1,11 +1,13 @@ using System.Collections.Concurrent; -namespace WebExpress.WebCore.WebMessage +namespace WebExpress.WebCore.WebParameter { /// /// Management of parameters. /// - public class ParameterDictionary : ConcurrentDictionary + /// Key: parameter name + /// Value: parameter + public class ParameterDictionary : ConcurrentDictionary { } } diff --git a/src/WebExpress.WebCore/WebMessage/ParameterFile.cs b/src/WebExpress.WebCore/WebParameter/ParameterFile.cs similarity index 97% rename from src/WebExpress.WebCore/WebMessage/ParameterFile.cs rename to src/WebExpress.WebCore/WebParameter/ParameterFile.cs index 492396b..19fd524 100644 --- a/src/WebExpress.WebCore/WebMessage/ParameterFile.cs +++ b/src/WebExpress.WebCore/WebParameter/ParameterFile.cs @@ -1,4 +1,4 @@ -namespace WebExpress.WebCore.WebMessage +namespace WebExpress.WebCore.WebParameter { /// /// Represents a file parameter with content type and data. diff --git a/src/WebExpress.WebCore/WebMessage/ParameterScope.cs b/src/WebExpress.WebCore/WebParameter/ParameterScope.cs similarity index 92% rename from src/WebExpress.WebCore/WebMessage/ParameterScope.cs rename to src/WebExpress.WebCore/WebParameter/ParameterScope.cs index c6fdd59..3f00af6 100644 --- a/src/WebExpress.WebCore/WebMessage/ParameterScope.cs +++ b/src/WebExpress.WebCore/WebParameter/ParameterScope.cs @@ -1,4 +1,4 @@ -namespace WebExpress.WebCore.WebMessage +namespace WebExpress.WebCore.WebParameter { /// /// Defines the scopes of the parameter. diff --git a/src/WebExpress.WebCore/WebPlugin/Model/PluginLoadContext.cs b/src/WebExpress.WebCore/WebPlugin/Model/PluginLoadContext.cs index 62499f8..9be058a 100644 --- a/src/WebExpress.WebCore/WebPlugin/Model/PluginLoadContext.cs +++ b/src/WebExpress.WebCore/WebPlugin/Model/PluginLoadContext.cs @@ -32,7 +32,7 @@ protected override Assembly Load(AssemblyName assemblyName) { string assemblyPath = Resolver.ResolveAssemblyToPath(assemblyName); - if (assemblyPath != null) + if (assemblyPath is not null) { return LoadFromAssemblyPath(assemblyPath); } @@ -49,7 +49,7 @@ protected override nint LoadUnmanagedDll(string unmanagedDllName) { string libraryPath = Resolver.ResolveUnmanagedDllToPath(unmanagedDllName); - if (libraryPath != null) + if (libraryPath is not null) { return LoadUnmanagedDllFromPath(libraryPath); } diff --git a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs index e9587a9..40f798c 100644 --- a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs +++ b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs @@ -73,7 +73,7 @@ internal void Register() try { var assembly = Assembly.LoadFrom(assemblyFile); - if (assembly != null) + if (assembly is not null) { assemblies.Add(assembly); _httpServerContext.Log.Debug @@ -94,7 +94,9 @@ internal void Register() } // register plugin - foreach (var assembly in assemblies.OrderBy(x => x.GetCustomAttribute() != null ? 0 : 1)) + foreach (var assembly in assemblies.OrderBy(x => x.GetCustomAttribute() is not null + ? 0 + : 1)) { Register(assembly); } @@ -123,7 +125,7 @@ internal IEnumerable Register(string pluginFile) { var assembly = loadContext.LoadFromAssemblyName(AssemblyName.GetAssemblyName(pluginFile)); - if (assembly != null) + if (assembly is not null) { assemblies.Add(assembly); _httpServerContext.Log.Debug @@ -167,12 +169,14 @@ private IEnumerable Register(Assembly assembly, PluginLoadContex try { // system plugins without plugin class (e.g. webexpress.webui) - if (assembly.GetCustomAttribute() != null) + if (assembly.GetCustomAttribute() is not null) { var attributeData = assembly.CustomAttributes .FirstOrDefault(a => a.AttributeType == typeof(SystemPluginAttribute)); var dependency = attributeData.ConstructorArguments.FirstOrDefault().Value?.ToString(); - var dependencies = dependency != null ? new List([dependency]) : []; + var dependencies = dependency is not null + ? new List([dependency]) + : []; var id = new ComponentId(assembly.GetName().Name.ToLower()); var pluginContext = new PluginContext() { @@ -236,7 +240,7 @@ private IEnumerable Register(Assembly assembly, PluginLoadContex foreach (var type in assembly .GetExportedTypes() .Where(x => x.IsClass && x.IsSealed) - .Where(x => x.GetInterface(typeof(IPlugin).Name) != null)) + .Where(x => x.GetInterface(typeof(IPlugin).Name) is not null)) { var id = new ComponentId(type.Namespace); var name = type.Assembly.GetCustomAttribute()?.Title; @@ -364,7 +368,7 @@ private IEnumerable Register(Assembly assembly, PluginLoadContex /// The context of the plugin that contains the elemets to remove. public void Remove(IPluginContext pluginContext) { - if (pluginContext == null) + if (pluginContext is null) { return; } @@ -457,7 +461,7 @@ public IPluginContext GetPlugin(string pluginId) return _dictionary.Values .Where ( - x => x.PluginContext != null && + x => x.PluginContext is not null && x.PluginContext.PluginId.ToString().Equals(pluginId) ) .Select(x => x.PluginContext) @@ -474,7 +478,7 @@ public IPluginContext GetPlugin(Type plugin) return _dictionary.Values .Where ( - x => x.PluginContext != null && + x => x.PluginContext is not null && x.PluginClass.Equals(plugin) ) .Select(x => x.PluginContext) @@ -489,7 +493,7 @@ public IPluginContext GetPlugin(Type plugin) public IEnumerable GetPlugins(IApplicationContext applicationContext) { return _dictionary.Values - .Where(x => x.ApplicationTypes != null) + .Where(x => x.ApplicationTypes is not null) .Where(x => x.ApplicationTypes.Select(x => _componentHub.ApplicationManager.GetApplications(x)) .SelectMany(x => x) .Where(x => x.ApplicationId == applicationContext.ApplicationId) @@ -506,7 +510,7 @@ public IEnumerable GetAssociatedApplications(IPluginContext { var pluginItem = GetPluginItem(pluginContext); - if (pluginItem == null) + if (pluginItem is null) { return []; } @@ -514,7 +518,7 @@ public IEnumerable GetAssociatedApplications(IPluginContext return pluginItem.ApplicationTypes? .Select(x => _componentHub.ApplicationManager.GetApplications(x)) .SelectMany(x => x) - .Where(x => x != null) ?? []; + .Where(x => x is not null) ?? []; } @@ -527,7 +531,7 @@ private PluginItem GetPluginItem(IPluginContext pluginContext) { var pluginId = pluginContext?.PluginId; - if (pluginId == null || !_dictionary.TryGetValue(pluginId, out PluginItem value)) + if (pluginId is null || !_dictionary.TryGetValue(pluginId, out PluginItem value)) { _httpServerContext.Log.Warning ( @@ -553,12 +557,12 @@ internal void Boot(IPluginContext pluginContext) var pluginItem = GetPluginItem(pluginContext); var token = pluginItem?.CancellationTokenSource.Token; - if (pluginItem == null) + if (pluginItem is null) { return; } - if (pluginItem.Plugin == null) + if (pluginItem.Plugin is null) { return; } @@ -663,7 +667,7 @@ private void Log() .Where ( x => x.Value.PluginClass.Assembly - .GetCustomAttribute() != null + .GetCustomAttribute() is not null ) .Select(x => string.Empty.PadRight(2) + I18N.Translate ( @@ -676,7 +680,7 @@ private void Log() .Where ( x => x.Value.PluginClass.Assembly - .GetCustomAttribute() == null + .GetCustomAttribute() is null ) .Select(x => string.Empty.PadRight(2) + I18N.Translate ( diff --git a/src/WebExpress.WebCore/WebResource/ResourceAsset.cs b/src/WebExpress.WebCore/WebResource/ResourceAsset.cs index 0bc86b9..53a96cc 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceAsset.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceAsset.cs @@ -51,7 +51,7 @@ public override Response Process(Request request) Data = GetData(file, assembly, resources.ToList()); - if (Data == null) + if (Data is null) { return new ResponseNotFound(); } @@ -137,7 +137,7 @@ public override Response Process(Request request) private static byte[] GetData(string file, Assembly assembly, IEnumerable resources) { var item = resources.Where(x => x.Equals(file, System.StringComparison.OrdinalIgnoreCase)).FirstOrDefault(); - if (item == null) + if (item is null) { return null; } diff --git a/src/WebExpress.WebCore/WebResource/ResourceBinary.cs b/src/WebExpress.WebCore/WebResource/ResourceBinary.cs index 396966a..6af4b6d 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceBinary.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceBinary.cs @@ -29,7 +29,7 @@ public ResourceBinary(IResourceContext resourceContext) public override Response Process(Request request) { var response = new ResponseOK(); - response.Header.ContentLength = Data != null ? Data.Length : 0; + response.Header.ContentLength = Data is not null ? Data.Length : 0; response.Header.ContentType = "binary/octet-stream"; response.Content = Data; diff --git a/src/WebExpress.WebCore/WebResource/ResourceManager.cs b/src/WebExpress.WebCore/WebResource/ResourceManager.cs index c6a899b..1751a08 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceManager.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceManager.cs @@ -2,12 +2,14 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Threading; using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebAttribute; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebCondition; using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPlugin; using WebExpress.WebCore.WebResource.Model; using WebExpress.WebCore.WebStatusPage; @@ -19,8 +21,13 @@ namespace WebExpress.WebCore.WebResource /// public sealed class ResourceManager : IResourceManager, ISystemComponent { + // synchronization root for protecting _dictionary and related mutable state + private readonly Lock _guard = new(); + private readonly IComponentHub _componentHub; private readonly IHttpServerContext _httpServerContext; + + // instantiate the dictionary; assume ResourceDictionary is a non-thread-safe collection private readonly ResourceDictionary _dictionary = []; /// @@ -36,10 +43,21 @@ public sealed class ResourceManager : IResourceManager, ISystemComponent /// /// Returns all resource contexts. /// - public IEnumerable Resources => _dictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x.Values) - .Select(x => x.ResourceContext); + public IEnumerable Resources + { + get + { + // return a snapshot to avoid enumeration during concurrent modifications + lock (_guard) + { + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Select(x => x.ResourceContext) + .ToList(); + } + } + } /// /// Initializes a new instance of the class. @@ -50,6 +68,7 @@ public sealed class ResourceManager : IResourceManager, ISystemComponent private ResourceManager(IComponentHub componentHub, IHttpServerContext httpServerContext) { _componentHub = componentHub; + _httpServerContext = httpServerContext; _componentHub.PluginManager.AddPlugin += OnAddPlugin; _componentHub.PluginManager.RemovePlugin += OnRemovePlugin; @@ -58,26 +77,47 @@ private ResourceManager(IComponentHub componentHub, IHttpServerContext httpServe var endpointtRegistration = new EndpointRegistration() { - EndpointResolver = (type, applicationContext) => applicationContext != null ? GetResorces(type, applicationContext) : GetResorces(type), - EndpointsResolver = () => Resources, + EndpointResolver = (type, applicationContext) => + { + // return appropriate endpoints based on applicationContext + return applicationContext is not null ? GetResorces(type, applicationContext) : GetResorces(type); + }, + EndpointsResolver = () => + { + // return snapshot of available resources + return Resources; + }, HandleRequest = (request, endpointContext) => { + // create or obtain resource instance and process request var resourceContext = endpointContext as IResourceContext; var resource = CreateResourceInstance(resourceContext); + if (resource is null) + { + // resource not found, return status page or bad request + return new ResponseBadRequest() + { + Content = I18N.Translate("webexpress.webcore:resourcemanager.resourcenotfound") + }; + } + return resource.Process(request); } }; - AddResource += (sender, e) => endpointtRegistration.AddEndpoint?.Invoke(sender, e); - RemoveResource += (sender, e) => endpointtRegistration.RemoveEndpoint?.Invoke(sender, e); + AddResource += (sender, e) => + { + endpointtRegistration.AddEndpoint?.Invoke(sender, e); + }; + RemoveResource += (sender, e) => + { + endpointtRegistration.RemoveEndpoint?.Invoke(sender, e); + }; _componentHub.EndpointManager.Register(endpointtRegistration); - _httpServerContext = httpServerContext; - - _httpServerContext.Log.Debug - ( + _httpServerContext.Log.Debug( I18N.Translate("webexpress.webcore:resourcemanager.initialization") ); } @@ -88,9 +128,12 @@ private ResourceManager(IComponentHub componentHub, IHttpServerContext httpServe /// The context of the plugin whose resources are to be associated. private void Register(IPluginContext pluginContext) { - if (_dictionary.ContainsKey(pluginContext)) + lock (_guard) { - return; + if (_dictionary.ContainsKey(pluginContext)) + { + return; + } } Register(pluginContext, _componentHub.ApplicationManager.GetApplications(pluginContext)); @@ -104,12 +147,22 @@ private void Register(IApplicationContext applicationContext) { foreach (var pluginContext in _componentHub.PluginManager.GetPlugins(applicationContext)) { - if (_dictionary.TryGetValue(pluginContext, out var appDict) && appDict.ContainsKey(applicationContext)) + bool shouldContinue = false; + + lock (_guard) + { + if (_dictionary.TryGetValue(pluginContext, out var appDict) && appDict.ContainsKey(applicationContext)) + { + shouldContinue = true; + } + } + + if (shouldContinue) { continue; } - Register(pluginContext, [applicationContext]); + Register(pluginContext, new[] { applicationContext }); } } @@ -120,12 +173,13 @@ private void Register(IApplicationContext applicationContext) /// The application context (optional). private void Register(IPluginContext pluginContext, IEnumerable applicationContexts) { + // assembly and reflection operations are per-plugin and read-only; mutations to _dictionary are synchronized var assembly = pluginContext?.Assembly; foreach (var resourceType in assembly.GetTypes() .Where(x => x.IsClass && x.IsSealed && x.IsPublic) - .Where(x => x.GetInterface(typeof(IResource).Name) != null) - .Where(x => x.GetInterface(typeof(IStatusPage).Name) == null)) + .Where(x => x.GetInterface(typeof(IResource).Name) is not null) + .Where(x => x.GetInterface(typeof(IStatusPage).Name) is null)) { var id = resourceType.FullName?.ToLower(); var segment = default(ISegmentAttribute); @@ -135,7 +189,7 @@ private void Register(IPluginContext pluginContext, IEnumerable !x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute)) && - !x.AttributeType.GetInterfaces().Contains(typeof(IPageAttribute))); + !x.AttributeType.GetInterfaces().Contains(typeof(IPageAttribute))); foreach (var customAttribute in resourceType.CustomAttributes .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute)))) @@ -162,12 +216,12 @@ private void Register(IPluginContext pluginContext, IEnumerable x.AttributeType) }; - if (_dictionary.AddResourceItem(pluginContext, applicationContext, resourceItem)) + bool added = false; + + lock (_guard) + { + added = _dictionary.AddResourceItem(pluginContext, applicationContext, resourceItem); + } + + if (added) { OnAddResource(resourceItem.ResourceContext); + _httpServerContext?.Log.Debug( I18N.Translate( "webexpress.webcore:resourcemanager.addresource", @@ -215,22 +277,23 @@ private void Register(IPluginContext pluginContext, IEnumerableThe context of the plugin that contains the resources to remove. internal void Remove(IPluginContext pluginContext) { - if (pluginContext == null) + if (pluginContext is null) { return; } - // the plugin has not been registered in the manager - if (_dictionary.TryGetValue(pluginContext, out var value)) + lock (_guard) { - foreach (var resourceItem in value.Values - .SelectMany(x => x.Values)) + if (_dictionary.TryGetValue(pluginContext, out var value)) { - OnRemoveResource(resourceItem.ResourceContext); - resourceItem.Dispose(); - } + foreach (var resourceItem in value.Values.SelectMany(x => x.Values)) + { + OnRemoveResource(resourceItem.ResourceContext); + resourceItem.Dispose(); + } - _dictionary.Remove(pluginContext); + _dictionary.Remove(pluginContext); + } } } @@ -240,23 +303,27 @@ internal void Remove(IPluginContext pluginContext) /// The context of the application that contains the resources to remove. internal void Remove(IApplicationContext applicationContext) { - if (applicationContext == null) + if (applicationContext is null) { return; } - foreach (var pluginDict in _dictionary.Values) + lock (_guard) { - foreach (var appDict in pluginDict.Where(x => x.Key == applicationContext).Select(x => x.Value)) + foreach (var pluginDict in _dictionary.Values) { - foreach (var resourceItem in appDict.Values) + foreach (var appDict in pluginDict.Where(x => x.Key == applicationContext).Select(x => x.Value)) { - OnRemoveResource(resourceItem.ResourceContext); - resourceItem.Dispose(); + foreach (var resourceItem in appDict.Values) + { + OnRemoveResource(resourceItem.ResourceContext); + resourceItem.Dispose(); + } } - } - pluginDict.Remove(applicationContext); + // remove the application mapping from the plugin dictionary + pluginDict.Remove(applicationContext); + } } } @@ -267,73 +334,88 @@ internal void Remove(IApplicationContext applicationContext) /// An enumeration of resource contexts. public IEnumerable GetResorces(IPluginContext pluginContext) { - if (_dictionary.TryGetValue(pluginContext, out var pluginResources)) + lock (_guard) { - return pluginResources - .SelectMany(x => x.Value) - .Select(x => x.Value.ResourceContext); - } + if (_dictionary.TryGetValue(pluginContext, out var pluginResources)) + { + return pluginResources + .SelectMany(x => x.Value) + .Select(x => x.Value.ResourceContext) + .ToList(); + } - return []; + return Enumerable.Empty(); + } } /// - /// Returns an enumeration of resource contextes. + /// Returns an enumeration of resource contexts. /// /// The resource type. - /// An enumeration of resource contextes. + /// An enumeration of resource contexts. public IEnumerable GetResorces() where T : IResource { return GetResorces(typeof(T)); } /// - /// Returns an enumeration of resource contextes. + /// Returns an enumeration of resource contexts. /// /// The resource type. - /// An enumeration of resource contextes. + /// An enumeration of resource contexts. public IEnumerable GetResorces(Type resourceType) { - return _dictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x.Values) - .Where(x => x.ResourceClass.Equals(resourceType)) - .Select(x => x.ResourceContext); + lock (_guard) + { + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.ResourceClass.Equals(resourceType)) + .Select(x => x.ResourceContext) + .ToList(); + } } /// - /// Returns an enumeration of resource contextes. + /// Returns an enumeration of resource contexts. /// /// The resource type. /// The context of the application. - /// An enumeration of resource contextes. + /// An enumeration of resource contexts. public IEnumerable GetResorces(Type resourceType, IApplicationContext applicationContext) { - return _dictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x.Values) - .Where(x => x.ResourceClass.Equals(resourceType)) - .Where(x => x.ResourceContext.ApplicationContext.Equals(applicationContext)) - .Select(x => x.ResourceContext); + lock (_guard) + { + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.ResourceClass.Equals(resourceType)) + .Where(x => x.ResourceContext.ApplicationContext.Equals(applicationContext)) + .Select(x => x.ResourceContext) + .ToList(); + } } /// - /// Returns an enumeration of resource contextes. + /// Returns an enumeration of resource contexts. /// /// The resource type. /// The context of the application. - /// An enumeration of resource contextes. + /// An enumeration of resource contexts. public IEnumerable GetResorces(IApplicationContext applicationContext) where T : IResource { - return _dictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x.Values) - .Where(x => x.ResourceClass.Equals(typeof(T))) - .Where(x => x.ResourceContext.ApplicationContext.Equals(applicationContext)) - .Select(x => x.ResourceContext); + lock (_guard) + { + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.ResourceClass.Equals(typeof(T))) + .Where(x => x.ResourceContext.ApplicationContext.Equals(applicationContext)) + .Select(x => x.ResourceContext) + .ToList(); + } } - /// /// Returns the resource context. /// @@ -342,13 +424,16 @@ public IEnumerable GetResorces(IApplicationContext applicat /// An resource context or null. public IResourceContext GetResorce(IApplicationContext applicationContext, string resourceId) { - return _dictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x.Values) - .Where(x => x.ResourceContext.ApplicationContext.Equals(applicationContext)) - .Where(x => x.ResourceContext.EndpointId.Equals(resourceId)) - .Select(x => x.ResourceContext) - .FirstOrDefault(); + lock (_guard) + { + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.ResourceContext.ApplicationContext.Equals(applicationContext)) + .Where(x => x.ResourceContext.EndpointId.Equals(resourceId)) + .Select(x => x.ResourceContext) + .FirstOrDefault(); + } } /// @@ -359,47 +444,78 @@ public IResourceContext GetResorce(IApplicationContext applicationContext, strin /// An resource context or null. public IResourceContext GetResorce(string applicationId, string resourceId) { - return _dictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x.Values) - .Where(x => x.ResourceContext.ApplicationContext.ApplicationId.Equals(applicationId)) - .Where(x => x.ResourceContext.EndpointId.Equals(resourceId)) - .Select(x => x.ResourceContext) - .FirstOrDefault(); + lock (_guard) + { + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.ResourceContext.ApplicationContext.ApplicationId.Equals(applicationId)) + .Where(x => x.ResourceContext.EndpointId.Equals(resourceId)) + .Select(x => x.ResourceContext) + .FirstOrDefault(); + } } /// /// Creates a new resource and returns it. If a resource already exists (through caching), the existing instance is returned. + /// Thread-safe: cached instance creation and assignment is protected. /// /// The context used for resource creation. /// The created or cached resource. private IResource CreateResourceInstance(IResourceContext resourceContext) { - var resourceItem = _dictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x.Values) - .FirstOrDefault(x => x.ResourceContext.Equals(resourceContext)); + if (resourceContext is null) + { + return null; + } + + ResourceItem resourceItem = null; - if (resourceItem != null && resourceItem.Instance == null) + // locate resourceItem and handle caching under lock + lock (_guard) { - var instance = ComponentActivator.CreateInstance - ( - resourceItem.ResourceClass, - resourceContext, - _httpServerContext, - _componentHub, - resourceContext.ApplicationContext - ); + resourceItem = _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .FirstOrDefault(x => x.ResourceContext.Equals(resourceContext)); - if (resourceItem.Cache) + if (resourceItem is null) + { + return null; + } + + // if instance already cached, return immediately + if (resourceItem.Instance is not null) { - resourceItem.Instance = instance; + return resourceItem.Instance as IResource; } - return instance; + // if caching is enabled, create and assign instance while holding lock to avoid double-creation + if (resourceItem.Cache) + { + var instanceCached = ComponentActivator.CreateInstance( + resourceItem.ResourceClass, + resourceContext, + _httpServerContext, + _componentHub, + resourceContext.ApplicationContext + ); + + resourceItem.Instance = instanceCached; + return instanceCached; + } } - return resourceItem?.Instance as IResource; + // if not caching, create instance outside lock (no shared state to modify) + var instanceNoCache = ComponentActivator.CreateInstance( + resourceItem.ResourceClass, + resourceContext, + _httpServerContext, + _componentHub, + resourceContext.ApplicationContext + ); + + return instanceNoCache; } /// @@ -439,6 +555,7 @@ private void OnRemovePlugin(object sender, IPluginContext e) { Remove(e); } + /// /// Raises the event when an application is removed. /// @@ -468,6 +585,8 @@ public void Dispose() _componentHub.PluginManager.RemovePlugin -= OnRemovePlugin; _componentHub.ApplicationManager.AddApplication -= OnAddApplication; _componentHub.ApplicationManager.RemoveApplication -= OnRemoveApplication; + + GC.SuppressFinalize(this); } } -} +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs b/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs index ed0e7f6..e51e9fc 100644 --- a/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs +++ b/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.RegularExpressions; +using System.Threading; using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebAttribute; @@ -10,6 +11,7 @@ using WebExpress.WebCore.WebCondition; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebPlugin; using WebExpress.WebCore.WebRestApi.Model; using WebExpress.WebCore.WebUri; @@ -19,10 +21,15 @@ namespace WebExpress.WebCore.WebRestApi /// /// The rest api manager manages rest api resources, which can be called with a URI (Uniform page Identifier). /// - public partial class RestApiManager : IRestApiManager + public partial class RestApiManager : IRestApiManager, IDisposable { + // synchronization guard for protecting _dictionary and related mutable state + private readonly Lock _guard = new(); + private readonly IComponentHub _componentHub; private readonly IHttpServerContext _httpServerContext; + + // instantiate the dictionary; assume RestApiDictionary is a non-thread-safe collection private readonly RestApiDictionary _dictionary = []; [GeneratedRegex(@"\.(?:_|V|v)(\d+)\.")] @@ -41,10 +48,21 @@ public partial class RestApiManager : IRestApiManager /// /// Returns all rest api resource contexts. /// - public IEnumerable RestApis => _dictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x.Values) - .Select(x => x.RestApiContext); + public IEnumerable RestApis + { + get + { + // return a stable snapshot to avoid enumeration during concurrent modifications + lock (_guard) + { + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Select(x => x.RestApiContext) + .ToList(); + } + } + } /// /// Initializes a new instance of the class. @@ -55,6 +73,7 @@ public partial class RestApiManager : IRestApiManager private RestApiManager(IComponentHub componentHub, IHttpServerContext httpServerContext) { _componentHub = componentHub; + _httpServerContext = httpServerContext; _componentHub.PluginManager.AddPlugin += OnAddPlugin; _componentHub.PluginManager.RemovePlugin += OnRemovePlugin; @@ -63,13 +82,26 @@ private RestApiManager(IComponentHub componentHub, IHttpServerContext httpServer var endpointtRegistration = new EndpointRegistration() { - EndpointResolver = (type, applicationContext) => applicationContext != null ? GetRestApi(type, applicationContext) : GetRestApi(type), + EndpointResolver = (type, applicationContext) => applicationContext is not null + ? GetRestApi(type, applicationContext) + : GetRestApi(type), EndpointsResolver = () => RestApis, HandleRequest = (request, endpointContext) => { + // get rest api context and create or obtain instance var restApiContext = endpointContext as IRestApiContext; var restApi = CreateApiInstance(restApiContext) as IRestApi; + // if no resource found, return bad request + if (restApiContext is null || restApi is null) + { + return new ResponseBadRequest() + { + Content = I18N.Translate("webexpress.webcore:restapimanager.resourcenotfound") + }; + } + + // execute according to allowed methods if (restApiContext.Methods.Any(x => x.Equals((CrudMethod)request.Method))) { switch (request.Method) @@ -84,6 +116,11 @@ private RestApiManager(IComponentHub componentHub, IHttpServerContext httpServer return restApi.UpdateData(request) ?? new ResponseOK(); case RequestMethod.DELETE: return restApi.DeleteData(request) ?? new ResponseOK(); + default: + return new ResponseBadRequest() + { + Content = I18N.Translate("webexpress.webcore:restapimanager.methodnotsupported", request.Method.ToString()) + }; } } @@ -99,85 +136,97 @@ private RestApiManager(IComponentHub componentHub, IHttpServerContext httpServer _componentHub.EndpointManager.Register(endpointtRegistration); - _httpServerContext = httpServerContext; - - _httpServerContext.Log.Debug - ( - I18N.Translate("webexpress.webcore:restapimanager.initialization") - ); + _httpServerContext.Log.Debug(I18N.Translate("webexpress.webcore:restapimanager.initialization")); } /// - /// Returns an enumeration of all containing page contexts of a plugin. + /// Returns an enumeration of all containing rest api contexts of a plugin. /// - /// A context of a plugin whose pages are to be registered. + /// A context of a plugin whose rest apis are to be registered. /// An enumeration of rest api resource contexts. public IEnumerable GetRestApi(IPluginContext pluginContext) { - if (_dictionary.TryGetValue(pluginContext, out var pluginResources)) + lock (_guard) { - return pluginResources - .SelectMany(x => x.Value) - .Select(x => x.Value.RestApiContext); - } + if (_dictionary.TryGetValue(pluginContext, out var pluginResources)) + { + // return snapshot list + return pluginResources + .SelectMany(x => x.Value) + .Select(x => x.Value.RestApiContext) + .ToList(); + } - return []; + return Enumerable.Empty(); + } } /// - /// Returns an enumeration of rest api resource contextes. + /// Returns an enumeration of rest api resource contexts. /// /// The rest api resource type. - /// An enumeration of rest api resource contextes. + /// An enumeration of rest api resource contexts. public IEnumerable GetRestApi() where T : IRestApi { return GetRestApi(typeof(T)); } /// - /// Returns an enumeration of rest api resource contextes. + /// Returns an enumeration of rest api resource contexts. /// /// The rest api resource type. - /// An enumeration of rest api resource contextes. + /// An enumeration of rest api resource contexts. public IEnumerable GetRestApi(Type restApiType) { - return _dictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x.Values) - .Where(x => x.RestApiClass.Equals(restApiType)) - .Select(x => x.RestApiContext); + lock (_guard) + { + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.RestApiClass.Equals(restApiType)) + .Select(x => x.RestApiContext) + .ToList(); + } } /// - /// Returns an enumeration of rest api resource contextes. + /// Returns an enumeration of rest api resource contexts for a given application. /// - /// The page type. + /// The rest api type. /// The context of the application. - /// An enumeration of page contextes. + /// An enumeration of rest api resource contexts. public IEnumerable GetRestApi(Type restApiType, IApplicationContext applicationContext) { - return _dictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x.Values) - .Where(x => x.RestApiClass.Equals(restApiType)) - .Where(x => x.RestApiContext.ApplicationContext.Equals(applicationContext)) - .Select(x => x.RestApiContext); + lock (_guard) + { + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.RestApiClass.Equals(restApiType)) + .Where(x => x.RestApiContext.ApplicationContext.Equals(applicationContext)) + .Select(x => x.RestApiContext) + .ToList(); + } } /// - /// Returns an enumeration of rest api resource contextes. + /// Returns an enumeration of rest api resource contexts for a given application. /// /// The rest api resource type. /// The context of the application. - /// An enumeration of rest api resource contextes. + /// An enumeration of rest api resource contexts. public IEnumerable GetRestApi(IApplicationContext applicationContext) where T : IRestApi { - return _dictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x.Values) - .Where(x => x.RestApiClass.Equals(typeof(T))) - .Where(x => x.RestApiContext.ApplicationContext.Equals(applicationContext)) - .Select(x => x.RestApiContext); + lock (_guard) + { + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.RestApiClass.Equals(typeof(T))) + .Where(x => x.RestApiContext.ApplicationContext.Equals(applicationContext)) + .Select(x => x.RestApiContext) + .ToList(); + } } /// @@ -188,13 +237,16 @@ public IEnumerable GetRestApi(IApplicationContext applicatio /// An rest api resource context or null. public IRestApiContext GetRestApi(IApplicationContext applicationContext, string restApiId) { - return _dictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x.Values) - .Where(x => x.RestApiContext.ApplicationContext.Equals(applicationContext)) - .Where(x => x.RestApiContext.EndpointId.Equals(restApiId)) - .Select(x => x.RestApiContext) - .FirstOrDefault(); + lock (_guard) + { + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.RestApiContext.ApplicationContext.Equals(applicationContext)) + .Where(x => x.RestApiContext.EndpointId.Equals(restApiId)) + .Select(x => x.RestApiContext) + .FirstOrDefault(); + } } /// @@ -205,47 +257,81 @@ public IRestApiContext GetRestApi(IApplicationContext applicationContext, string /// An rest api resource context or null. public IRestApiContext GetRestApi(string applicationId, string restApiId) { - return _dictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x.Values) - .Where(x => x.RestApiContext.ApplicationContext.ApplicationId.Equals(applicationId)) - .Where(x => x.RestApiContext.EndpointId.Equals(restApiId)) - .Select(x => x.RestApiContext) - .FirstOrDefault(); + lock (_guard) + { + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.RestApiContext.ApplicationContext.ApplicationId.Equals(applicationId)) + .Where(x => x.RestApiContext.EndpointId.Equals(restApiId)) + .Select(x => x.RestApiContext) + .FirstOrDefault(); + } } /// /// Creates a new rest api resource and returns it. If a rest api resource already exists (through caching), the existing instance is returned. + /// Thread-safe: cached instance creation and assignment is protected. /// /// The context used for rest api resource creation. /// The created or cached rest api resource. private IRestApi CreateApiInstance(IRestApiContext apiContext) { - var resourceItem = _dictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x.Values) - .FirstOrDefault(x => x.RestApiContext.Equals(apiContext)); + if (apiContext is null) + { + return null; + } - if (resourceItem != null && resourceItem.Instance == null) + RestApiItem resourceItem = null; + + // locate resourceItem inside lock to get a consistent view + lock (_guard) { - var instance = ComponentActivator.CreateInstance - ( - resourceItem.RestApiClass, - apiContext, - _httpServerContext, - _componentHub, - apiContext.ApplicationContext - ); + resourceItem = _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .FirstOrDefault(x => x.RestApiContext.Equals(apiContext)); - if (resourceItem.Cache) + if (resourceItem is null) + { + return null; + } + + // if instance already cached, return immediately + if (resourceItem.Instance is not null) { - resourceItem.Instance = instance; + return resourceItem.Instance as IRestApi; } - return instance; + // if caching is enabled, create and assign the instance under lock to avoid double-creation + if (resourceItem.Cache) + { + // create instance while holding the lock to ensure only one creation and assignment occurs + var instanceCached = ComponentActivator.CreateInstance + ( + resourceItem.RestApiClass, + apiContext, + _httpServerContext, + _componentHub, + apiContext.ApplicationContext + ); + + resourceItem.Instance = instanceCached; + return instanceCached; + } } - return resourceItem?.Instance as IRestApi; + // if not caching, create instance outside lock (no shared state to modify) + var instanceNoCache = ComponentActivator.CreateInstance + ( + resourceItem.RestApiClass, + apiContext, + _httpServerContext, + _componentHub, + apiContext.ApplicationContext + ); + + return instanceNoCache; } /// @@ -254,12 +340,15 @@ private IRestApi CreateApiInstance(IRestApiContext apiContext) /// The context of the plugin whose rest apis are to be associated. private void Register(IPluginContext pluginContext) { - if (_dictionary.ContainsKey(pluginContext)) + lock (_guard) { - return; - } + if (_dictionary.ContainsKey(pluginContext)) + { + return; + } - Register(pluginContext, _componentHub.ApplicationManager.GetApplications(pluginContext)); + Register(pluginContext, _componentHub.ApplicationManager.GetApplications(pluginContext)); + } } /// @@ -270,12 +359,15 @@ private void Register(IApplicationContext applicationContext) { foreach (var pluginContext in _componentHub.PluginManager.GetPlugins(applicationContext)) { - if (_dictionary.TryGetValue(pluginContext, out var appDict) && appDict.ContainsKey(applicationContext)) + lock (_guard) { - continue; + if (_dictionary.TryGetValue(pluginContext, out var appDict) && appDict.ContainsKey(applicationContext)) + { + continue; + } } - Register(pluginContext, [applicationContext]); + Register(pluginContext, new[] { applicationContext }); } } @@ -286,11 +378,12 @@ private void Register(IApplicationContext applicationContext) /// The application context (optional). private void Register(IPluginContext pluginContext, IEnumerable applicationContexts) { + // assembly and reflection operations are per-plugin and read-only; mutations to _dictionary are synchronized var assembly = pluginContext?.Assembly; foreach (var restApiType in assembly.GetTypes() .Where(x => x.IsClass == true && x.IsSealed && x.IsPublic) - .Where(x => x.GetInterface(typeof(IRestApi).Name) != null)) + .Where(x => x.GetInterface(typeof(IRestApi).Name) is not null)) { var id = restApiType.FullName?.ToLower(); var segment = default(ISegmentAttribute); @@ -305,7 +398,7 @@ private void Register(IPluginContext pluginContext, IEnumerable !x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute)) && - !x.AttributeType.GetInterfaces().Contains(typeof(IPageAttribute))); + !x.AttributeType.GetInterfaces().Contains(typeof(IPageAttribute))); foreach (var customAttribute in restApiType.CustomAttributes .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute)))) @@ -355,7 +448,7 @@ private void Register(IPluginContext pluginContext, IEnumerable("_apiVersion", $"{version}")], ["api", "restapi", "rest"] ).RemoveSegment(versionSegment); @@ -388,14 +481,19 @@ private void Register(IPluginContext pluginContext, IEnumerable x.AttributeType) }; - if (_dictionary.AddRestApiItem(pluginContext, applicationContext, restApiItem)) + // add mutation protected by lock to avoid concurrent modifications + var added = false; + lock (_guard) + { + added = _dictionary.AddRestApiItem(pluginContext, applicationContext, restApiItem); + } + + if (added) { OnAddRestApi(restApiItem.RestApiContext); - _httpServerContext?.Log.Debug - ( - I18N.Translate - ( + _httpServerContext?.Log.Debug( + I18N.Translate( "webexpress.webcore:restapimanager.addrestapi", id, applicationContext.ApplicationId @@ -407,53 +505,58 @@ private void Register(IPluginContext pluginContext, IEnumerable - /// Removes all pages associated with the specified plugin context. + /// Removes all rest apis associated with the specified plugin context. /// /// The context of the plugin that contains the rest api resources to remove. public void Remove(IPluginContext pluginContext) { - if (pluginContext == null) + if (pluginContext is null) { return; } - // the plugin has not been registered in the manager - if (_dictionary.TryGetValue(pluginContext, out var value)) + lock (_guard) { - foreach (var resourceItem in value.Values - .SelectMany(x => x.Values)) + if (_dictionary.TryGetValue(pluginContext, out var value)) { - OnRemoveRestApi(resourceItem.RestApiContext); - resourceItem.Dispose(); - } + foreach (var resourceItem in value.Values.SelectMany(x => x.Values)) + { + OnRemoveRestApi(resourceItem.RestApiContext); + resourceItem.Dispose(); + } - _dictionary.Remove(pluginContext); + _dictionary.Remove(pluginContext); + } } } /// - /// Removes all events associated with the specified application context. + /// Removes all rest apis associated with the specified application context. /// - /// The context of the application that contains the events to remove. + /// The context of the application that contains the resources to remove. internal void Remove(IApplicationContext applicationContext) { - if (applicationContext == null) + if (applicationContext is null) { return; } - foreach (var pluginDict in _dictionary.Values) + lock (_guard) { - foreach (var appDict in pluginDict.Where(x => x.Key == applicationContext).Select(x => x.Value)) + foreach (var pluginDict in _dictionary.Values) { - foreach (var resourceItem in appDict.Values) + foreach (var appDict in pluginDict.Where(x => x.Key == applicationContext).Select(x => x.Value)) { - OnRemoveRestApi(resourceItem.RestApiContext); - resourceItem.Dispose(); + foreach (var resourceItem in appDict.Values) + { + OnRemoveRestApi(resourceItem.RestApiContext); + resourceItem.Dispose(); + } } - } - pluginDict.Remove(applicationContext); + // remove the application mapping from the plugin dictionary + pluginDict.Remove(applicationContext); + } } } @@ -528,4 +631,4 @@ public void Dispose() GC.SuppressFinalize(this); } } -} +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs b/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs index 7524032..990f591 100644 --- a/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs +++ b/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs @@ -73,7 +73,7 @@ public void Add(params RestApiError[] errors) /// An array of error objects to add. public void AddRange(IEnumerable errors) { - if (errors != null) + if (errors is not null) { _errors.AddRange(errors); } diff --git a/src/WebExpress.WebCore/WebSession/Model/Session.cs b/src/WebExpress.WebCore/WebSession/Model/Session.cs index 709ee9b..d11339f 100644 --- a/src/WebExpress.WebCore/WebSession/Model/Session.cs +++ b/src/WebExpress.WebCore/WebSession/Model/Session.cs @@ -90,7 +90,7 @@ public T GetOrCreateProperty(params object[] parameters) where T : class, ISe var flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; var constructors = type.GetConstructors(flags); - if (constructors != null || parameters.Length > 0) + if (constructors is not null || parameters.Length > 0) { foreach (var constructor in constructors.OrderByDescending(x => x.GetParameters().Length)) { diff --git a/src/WebExpress.WebCore/WebSession/Model/SessionPropertyParameter.cs b/src/WebExpress.WebCore/WebSession/Model/SessionPropertyParameter.cs index e1a01d1..9ced217 100644 --- a/src/WebExpress.WebCore/WebSession/Model/SessionPropertyParameter.cs +++ b/src/WebExpress.WebCore/WebSession/Model/SessionPropertyParameter.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebParameter; namespace WebExpress.WebCore.WebSession.Model { diff --git a/src/WebExpress.WebCore/WebSession/SessionManager.cs b/src/WebExpress.WebCore/WebSession/SessionManager.cs index 465790d..17d8b2c 100644 --- a/src/WebExpress.WebCore/WebSession/SessionManager.cs +++ b/src/WebExpress.WebCore/WebSession/SessionManager.cs @@ -58,7 +58,7 @@ public Session GetSession(Request request) } - if (sessionCookie != null && _dictionary.TryGetValue(guid, out Session value)) + if (sessionCookie is not null && _dictionary.TryGetValue(guid, out Session value)) { session = value; session.Updated = DateTime.Now; diff --git a/src/WebExpress.WebCore/WebSettingPage/Model/SettingCategoryDictionary.cs b/src/WebExpress.WebCore/WebSettingPage/Model/SettingCategoryDictionary.cs index 9b27e21..8b76adf 100644 --- a/src/WebExpress.WebCore/WebSettingPage/Model/SettingCategoryDictionary.cs +++ b/src/WebExpress.WebCore/WebSettingPage/Model/SettingCategoryDictionary.cs @@ -61,7 +61,7 @@ public void Remove(IPluginContext pluginContext) /// An enumerable collection of setting categories contexts that were removed. public IEnumerable Remove(IApplicationContext applicationContext) { - if (applicationContext == null) + if (applicationContext is null) { yield break; } diff --git a/src/WebExpress.WebCore/WebSettingPage/Model/SettingGroupDictionary.cs b/src/WebExpress.WebCore/WebSettingPage/Model/SettingGroupDictionary.cs index d46cb95..1920417 100644 --- a/src/WebExpress.WebCore/WebSettingPage/Model/SettingGroupDictionary.cs +++ b/src/WebExpress.WebCore/WebSettingPage/Model/SettingGroupDictionary.cs @@ -61,7 +61,7 @@ public void Remove(IPluginContext pluginContext) /// An enumerable collection of setting groups contexts that were removed. public IEnumerable Remove(IApplicationContext applicationContext) { - if (applicationContext == null) + if (applicationContext is null) { yield break; } diff --git a/src/WebExpress.WebCore/WebSettingPage/Model/SettingPageDictionary.cs b/src/WebExpress.WebCore/WebSettingPage/Model/SettingPageDictionary.cs index cbbbcf2..923eaad 100644 --- a/src/WebExpress.WebCore/WebSettingPage/Model/SettingPageDictionary.cs +++ b/src/WebExpress.WebCore/WebSettingPage/Model/SettingPageDictionary.cs @@ -63,7 +63,7 @@ public void Remove(IPluginContext pluginContext) /// An enumerable collection of setting page contexts that were removed. public IEnumerable Remove(IApplicationContext applicationContext) { - if (applicationContext == null) + if (applicationContext is null) { yield break; } @@ -148,7 +148,7 @@ public IEndpoint CreateSettingPageInstance(ISettingPageContext settingPageContex .SelectMany(x => x) .FirstOrDefault(x => x.SettingPageContext.Equals(settingPageContext)); - if (settingPageItem != null && settingPageItem.Instance == null) + if (settingPageItem is not null && settingPageItem.Instance is null) { var instance = ComponentActivator.CreateInstance ( diff --git a/src/WebExpress.WebCore/WebSettingPage/Model/TimeSpanConverter.cs b/src/WebExpress.WebCore/WebSettingPage/Model/TimeSpanConverter.cs index 47d58b7..4f84560 100644 --- a/src/WebExpress.WebCore/WebSettingPage/Model/TimeSpanConverter.cs +++ b/src/WebExpress.WebCore/WebSettingPage/Model/TimeSpanConverter.cs @@ -18,7 +18,7 @@ public class TimeSpanConverter /// A formatted string representing the TimeSpan. public object Convert(object value, Type targetType, object parameter, string language) { - if (value == null) + if (value is null) { return null; } diff --git a/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs b/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs index 81d2fd4..5769ad6 100644 --- a/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs +++ b/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -22,14 +23,16 @@ namespace WebExpress.WebCore.WebSettingPage /// /// Management of settings pages. /// - public sealed class SettingPageManager : ISettingPageManager + public sealed class SettingPageManager : ISettingPageManager, IDisposable { private readonly IComponentHub _componentHub; private readonly IHttpServerContext _httpServerContext; private readonly SettingCategoryDictionary _categoryDictionary = new(); private readonly SettingGroupDictionary _groupDictionary = new(); private readonly SettingPageDictionary _pageDictionary = new(); - private static readonly Dictionary _delegateCache = []; + + // use a concurrent dictionary for delegate cache to avoid concurrent write corruption + private static readonly ConcurrentDictionary _delegateCache = new ConcurrentDictionary(); /// /// An event that fires when an setting page is added. @@ -94,76 +97,100 @@ private SettingPageManager(IComponentHub componentHub, IHttpServerContext httpSe var endpointtRegistration = new EndpointRegistration() { - EndpointResolver = (type, applicationContext) => applicationContext != null ? GetSettingPages(type, applicationContext) : GetSettingPages(type), + EndpointResolver = (type, applicationContext) => applicationContext is not null + ? GetSettingPages(type, applicationContext) + : GetSettingPages(type), EndpointsResolver = () => SettingPages, HandleRequest = (request, endpontContext) => { + // create or obtain page instance for this request var pageInstance = CreateSettingPageInstance(endpontContext as ISettingPageContext); var pageType = pageInstance.GetType(); var pageContext = endpontContext as IPageContext; var renderContext = new RenderContext(pageInstance, pageContext, request); var visualTreeContext = new VisualTreeContext(renderContext); - var visualTreeType = pageType.GetInterface(typeof(ISettingPage<>).Name).GetGenericArguments()[0]; + // determine visual tree type implemented by the setting page + var pageInterface = pageType.GetInterface(typeof(ISettingPage<>).Name); + if (pageInterface is null) + { + throw new InvalidOperationException($"Page type {pageType.FullName} does not implement ISettingPage<>."); + } + + var visualTreeType = pageInterface.GetGenericArguments()[0]; + + // obtain or create a cached open-instance delegate safely if (!_delegateCache.TryGetValue(pageType, out var del)) { - // create and compile the expression + // create an open-instance delegate: (instance, renderContext, visualTree) => instance.Process(renderContext, visualTree) + var instanceParam = Expression.Parameter(pageType, "instance"); var renderContextParam = Expression.Parameter(typeof(IRenderContext), "renderContext"); var visualTreeParam = Expression.Parameter(visualTreeType, "visualTree"); - var processMethod = pageType.GetMethod("Process", [typeof(IRenderContext), visualTreeType]); - var callProzessMethod = Expression.Call - ( - Expression.Constant(pageInstance), - processMethod, - renderContextParam, - visualTreeParam - ); - var lambda = Expression.Lambda(callProzessMethod, renderContextParam, visualTreeParam) - .Compile(); - - _delegateCache[pageType] = lambda; - del = lambda; + + // find Process method matching signature Process(IRenderContext, TVisualTree) + var processMethod = pageType.GetMethod("Process", new[] { typeof(IRenderContext), visualTreeType }); + if (processMethod is null) + { + throw new InvalidOperationException($"Process method not found on type {pageType.FullName}"); + } + + // call instance.Process(renderContext, visualTree) + var callProcess = Expression.Call(instanceParam, processMethod, renderContextParam, visualTreeParam); + + // compile lambda with signature (instance, renderContext, visualTree) + var lambda = Expression.Lambda(callProcess, instanceParam, renderContextParam, visualTreeParam).Compile(); + + // add to concurrent dictionary atomically; if another thread added concurrently, use the existing one + del = _delegateCache.GetOrAdd(pageType, lambda); } // create visual tree instance - var visualTreeInstance = default(IVisualTree); + IVisualTree visualTreeInstance = null; var flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; var constructors = visualTreeType?.GetConstructors(flags); - if (constructors != null) + if (constructors is not null) { foreach (var constructor in constructors.OrderByDescending(x => x.GetParameters().Length)) { // injection var parameters = constructor.GetParameters(); var hubProperties = _componentHub.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance); - var contextIdProperty = pageContext.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance) + var contextIdProperty = pageContext?.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(x => x.PropertyType == typeof(IComponentId)) .FirstOrDefault(); var parameterValues = parameters.Select(parameter => - parameter.ParameterType == typeof(IComponentHub) ? componentHub : - parameter.ParameterType == typeof(IHttpServerContext) ? httpServerContext : + parameter.ParameterType == typeof(IComponentHub) ? _componentHub : + parameter.ParameterType == typeof(IHttpServerContext) ? _httpServerContext : parameter.ParameterType == typeof(IPageContext) ? pageContext : parameter.ParameterType == typeof(IComponentId) ? contextIdProperty?.GetValue(pageContext) : hubProperties.Where(x => x.PropertyType == parameter.ParameterType) .FirstOrDefault()? - .GetValue(componentHub) ?? null + .GetValue(_componentHub) ?? null ).ToArray(); - if (constructor.Invoke(parameterValues) is IVisualTree visualTree) + var invoked = constructor.Invoke(parameterValues); + if (invoked is IVisualTree visualTree) { visualTreeInstance = visualTree; + break; } } } else { - visualTreeInstance = Activator.CreateInstance(); + // fallback: try parameterless creation + visualTreeInstance = Activator.CreateInstance(visualTreeType) as IVisualTree; + } + + if (visualTreeInstance is null) + { + throw new InvalidOperationException($"Could not create visual tree instance of type {visualTreeType.FullName} for page {pageType.FullName}."); } - // execute the cached delegate - del.DynamicInvoke(renderContext, visualTreeInstance); + // execute the cached open-instance delegate; pass the current pageInstance + del.DynamicInvoke(pageInstance, renderContext, visualTreeInstance); return new ResponseOK() { @@ -219,9 +246,9 @@ private void Register(IApplicationContext applicationContext) foreach (var pluginContext in _componentHub.PluginManager.GetPlugins(applicationContext)) { - RegisterCategory(pluginContext, [applicationContext]); - RegisterGroup(pluginContext, [applicationContext]); - RegisterPage(pluginContext, [applicationContext]); + RegisterCategory(pluginContext, new[] { applicationContext }); + RegisterGroup(pluginContext, new[] { applicationContext }); + RegisterPage(pluginContext, new[] { applicationContext }); } } @@ -236,7 +263,7 @@ private void RegisterCategory(IPluginContext pluginContext, IEnumerable x.IsClass == true && x.IsSealed && x.IsPublic) - .Where(x => x.GetInterface(typeof(ISettingCategory).Name) != null)) + .Where(x => x.GetInterface(typeof(ISettingCategory).Name) is not null)) { var id = settingCategoryType.FullName?.ToLower(); var icon = default(IIcon); @@ -298,15 +325,7 @@ private void RegisterCategory(IPluginContext pluginContext, IEnumerable x.IsClass == true && x.IsSealed && x.IsPublic) - .Where(x => x.GetInterface(typeof(ISettingGroup).Name) != null)) + .Where(x => x.GetInterface(typeof(ISettingGroup).Name) is not null)) { var id = settingGroupType.FullName?.ToLower(); var icon = default(IIcon); @@ -361,14 +380,7 @@ private void RegisterGroup(IPluginContext pluginContext, IEnumerable x.IsClass == true && x.IsSealed && x.IsPublic) - .Where(x => x.GetInterface(typeof(ISettingPage<>).Name) != null)) + .Where(x => x.GetInterface(typeof(ISettingPage<>).Name) is not null)) { var id = settingPageType.FullName?.ToLower(); var title = settingPageType.Name; @@ -445,7 +449,7 @@ private void RegisterPage(IPluginContext pluginContext, IEnumerable !x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute)) && - !x.AttributeType.GetInterfaces().Contains(typeof(IPageAttribute))); + !x.AttributeType.GetInterfaces().Contains(typeof(IPageAttribute))); // determining attributes foreach (var customAttribute in settingPageType.CustomAttributes @@ -455,15 +459,9 @@ private void RegisterPage(IPluginContext pluginContext, IEnumerable) - ) - { - group = customAttribute.AttributeType - .GenericTypeArguments - .FirstOrDefault(); + else if (customAttribute.AttributeType.IsGenericType && customAttribute.AttributeType.GetGenericTypeDefinition() == typeof(SettingGroupAttribute<>)) + { + group = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); } else if (customAttribute.AttributeType == typeof(SettingSectionAttribute)) { @@ -486,52 +484,27 @@ private void RegisterPage(IPluginContext pluginContext, IEnumerable).Name && - customAttribute.AttributeType.Namespace == typeof(ConditionAttribute<>).Namespace - ) - { - var condition = customAttribute.AttributeType - .GenericTypeArguments - .FirstOrDefault(); + else if (customAttribute.AttributeType.Name == typeof(ConditionAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(ConditionAttribute<>).Namespace) + { + var condition = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); conditions.Add(Activator.CreateInstance(condition) as ICondition); } } if (group == default) { - _httpServerContext?.Log.Warning - ( - I18N.Translate - ( - "webexpress.webcore:settingpagemanager.register.nogroup", - id - ) - ); + _httpServerContext?.Log.Warning(I18N.Translate("webexpress.webcore:settingpagemanager.register.nogroup", id)); } - foreach (var customAttribute in settingPageType.CustomAttributes.Where - ( - x => x.AttributeType - .GetInterfaces() - .Contains(typeof(ISettingPageAttribute)) - )) + foreach (var customAttribute in settingPageType.CustomAttributes.Where(x => x.AttributeType.GetInterfaces().Contains(typeof(ISettingPageAttribute)))) { if (customAttribute.AttributeType == typeof(TitleAttribute)) { - title = customAttribute.ConstructorArguments - .FirstOrDefault().Value?.ToString(); + title = customAttribute.ConstructorArguments.FirstOrDefault().Value?.ToString(); } - else if - ( - customAttribute.AttributeType.Name == typeof(ScopeAttribute<>).Name && - customAttribute.AttributeType.Namespace == typeof(ScopeAttribute<>).Namespace - ) - { - scopes.Add(customAttribute.AttributeType - .GenericTypeArguments - .FirstOrDefault()); + else if (customAttribute.AttributeType.Name == typeof(ScopeAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(ScopeAttribute<>).Namespace) + { + scopes.Add(customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault()); } } @@ -543,19 +516,9 @@ private void RegisterPage(IPluginContext pluginContext, IEnumerable /// true if root, otherwise false. - public bool IsRoot => Parent == null; + public bool IsRoot => Parent is null; /// /// Checks whether the node is a leaf. /// /// true if a leaf, otherwise false. - public bool IsLeaf => !Children.Any(); + public bool IsLeaf => Children.Count == 0; /// /// Returns the path. @@ -78,7 +78,7 @@ public ICollection Path }; var parent = Parent; - while (parent != null) + while (parent is not null) { list.Add(parent); diff --git a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs index 7ae21c5..d5f6990 100644 --- a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs +++ b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs @@ -7,7 +7,7 @@ using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebLog; -using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebSitemap.Model; using WebExpress.WebCore.WebUri; @@ -27,7 +27,7 @@ public sealed class SitemapManager : ISitemapManager, ISystemComponent /// Returns the side map. /// public IEnumerable SiteMap => _root.GetPreOrder() - .Where(x => x != null) + .Where(x => x is not null) .Select(x => x.EndpointContext); /// @@ -80,8 +80,8 @@ public void Refresh() } // endpoints - var resources = _componentHub.EndpointManager.Endpoints - .Where(x => x.Route != null) + var endpoints = _componentHub.EndpointManager.Endpoints + .Where(x => x.Route is not null) .Select(x => new { EndpointContext = x, @@ -89,7 +89,7 @@ public void Refresh() }) .OrderBy(x => x.PathSegments.Count()); - foreach (var item in resources) + foreach (var item in endpoints) { MergeSitemap(newSiteMapNode, CreateSiteMap ( @@ -120,7 +120,7 @@ public SearchResult SearchResource(Uri requestUri, SearchContext searchContext) searchContext ); - if (result != null && result.EndpointContext != null) + if (result is not null && result.EndpointContext is not null) { if (!result.EndpointContext.Conditions.Any() || result.EndpointContext.Conditions.All(x => x.Fulfillment(searchContext.HttpContext?.Request))) { @@ -190,7 +190,7 @@ public IUri GetUri(IEndpointContext endpointContext) /// The endpoint context if found, otherwise null. public IEndpointContext GetEndpoint(IUri uri) { - if (uri == null || uri.Empty) + if (uri is null || uri.Empty) { return null; } @@ -229,7 +229,7 @@ IApplicationContext applicationContext var root = new SitemapNode() { PathSegment = new UriPathSegmentRoot() }; var next = CreateSiteMap(contextPathSegments, applicationContext, root); - if (next != null) + if (next is not null) { root.Children.Add(next); } @@ -255,7 +255,7 @@ SitemapNode parent { var pathSegment = contextPathSegments.Count != 0 ? contextPathSegments.Dequeue() : null; - if (pathSegment == null) + if (pathSegment is null) { return null; } @@ -296,7 +296,7 @@ IEndpointContext endpointContext var root = new SitemapNode() { PathSegment = new UriPathSegmentRoot() }; var next = CreateSiteMap(contextPathSegments, endpointContext, root); - if (next != null) + if (next is not null) { root.Children.Add(next); } @@ -326,7 +326,7 @@ private static SitemapNode CreateSiteMap { var pathSegment = contextPathSegments.Count != 0 ? contextPathSegments.Dequeue() : null; - if (pathSegment == null) + if (pathSegment is null) { return null; } @@ -393,17 +393,20 @@ SearchContext searchContext if (IsMatched(node, pathSegment)) { - var copy = node.PathSegment.Copy(); - if (copy is UriPathSegmentVariable variable) + + if (node.PathSegment is IUriPathSegmentVariable variable) + { + var copy = variable.Copy(pathSegment); + outPathSegments.Enqueue(copy); + } + else { - variable.Value = pathSegment; + outPathSegments.Enqueue(node.PathSegment.Copy()); } var type = node.EndpointContext?.GetType(); - outPathSegments.Enqueue(copy); - - if (nextPathSegment == null) + if (nextPathSegment is null) { return new SearchResult() { @@ -424,8 +427,8 @@ SearchContext searchContext else if ( node.IsLeaf - && nextPathSegment != null - && node.EndpointContext != null + && nextPathSegment is not null + && node.EndpointContext is not null && node.EndpointContext.IncludeSubPaths ) { @@ -464,7 +467,7 @@ SearchContext searchContext /// True if the path element matched, false otherwise. private static bool IsMatched(SitemapNode node, string pathSegement) { - if (node == null || string.IsNullOrWhiteSpace(pathSegement)) + if (node is null || string.IsNullOrWhiteSpace(pathSegement)) { return false; } diff --git a/src/WebExpress.WebCore/WebStatusPage/Model/StatusPageDictionary.cs b/src/WebExpress.WebCore/WebStatusPage/Model/StatusPageDictionary.cs index 7ed442d..dd73a21 100644 --- a/src/WebExpress.WebCore/WebStatusPage/Model/StatusPageDictionary.cs +++ b/src/WebExpress.WebCore/WebStatusPage/Model/StatusPageDictionary.cs @@ -87,7 +87,7 @@ public void RemoveStatusPageItem(IPluginContext pluginContext, IApplicationConte /// The status page item if found, otherwise null. public StatusPageItem GetStatusPageItem(IApplicationContext applicationContext, int statusCode) { - if (applicationContext != null && ContainsKey(applicationContext?.PluginContext)) + if (applicationContext is not null && ContainsKey(applicationContext?.PluginContext)) { var appContextDict = this[applicationContext?.PluginContext]; diff --git a/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs b/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs index 4e83e14..dd296e6 100644 --- a/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs +++ b/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Linq.Expressions; using System.Reflection; +using System.Threading; using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebAttribute; @@ -23,11 +25,18 @@ namespace WebExpress.WebCore.WebStatusPage /// public class StatusPageManager : IStatusPageManager, ISystemComponent { + // synchronization guard for protecting _dictionary and related mutable state + private readonly Lock _guard = new(); + private readonly IComponentHub _componentHub; private readonly IHttpServerContext _httpServerContext; - private readonly StatusPageDictionary _dictionary = []; - private readonly Dictionary _defaults = []; - private static readonly Dictionary _delegateCache = []; + private readonly StatusPageDictionary _dictionary = new StatusPageDictionary(); + + // use a concurrent dictionary for defaults to make default lookups/updates thread-safe + private readonly ConcurrentDictionary _defaults = new(); + + // change delegate cache to concurrent dictionary (open-instance delegates are cached) + private static readonly ConcurrentDictionary _delegateCache = new(); /// /// An event that fires when an status page is added. @@ -42,10 +51,21 @@ public class StatusPageManager : IStatusPageManager, ISystemComponent /// /// Returns all status pages. /// - public IEnumerable StatusPages => _dictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x.Values) - .Select(x => x.StatusPageContext); + public IEnumerable StatusPages + { + get + { + // return a stable snapshot to avoid enumeration during concurrent modifications + lock (_guard) + { + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Select(x => x.StatusPageContext) + .ToList(); + } + } + } /// /// Initializes a new instance of the class. @@ -76,9 +96,12 @@ private StatusPageManager(IComponentHub componentHub, IHttpServerContext httpSer /// The context of the plugin whose status pages are to be associated. private void Register(IPluginContext pluginContext) { - if (_dictionary.ContainsKey(pluginContext)) + lock (_guard) { - return; + if (_dictionary.ContainsKey(pluginContext)) + { + return; + } } Register(pluginContext, _componentHub.ApplicationManager.GetApplications(pluginContext)); @@ -92,12 +115,18 @@ private void Register(IApplicationContext applicationContext) { foreach (var pluginContext in _componentHub.PluginManager.GetPlugins(applicationContext)) { - if (_dictionary.TryGetValue(pluginContext, out var appDict) && appDict.ContainsKey(applicationContext)) + bool already; + lock (_guard) + { + already = _dictionary.TryGetValue(pluginContext, out var appDict) && appDict.ContainsKey(applicationContext); + } + + if (already) { continue; } - Register(pluginContext, [applicationContext]); + Register(pluginContext, new[] { applicationContext }); } } @@ -112,7 +141,7 @@ private void Register(IPluginContext pluginContext, IEnumerable x.IsClass == true && x.IsSealed && x.IsPublic) - .Where(x => x.GetInterface(typeof(IStatusPage<>).Name) != null)) + .Where(x => x.GetInterface(typeof(IStatusPage<>).Name) is not null)) { var id = new ComponentId(resource.FullName); var statusResponse = typeof(ResponseInternalServerError); @@ -149,7 +178,9 @@ private void Register(IPluginContext pluginContext, IEnumerable()?.StatusCode == null) + // validate that a StatusCodeAttribute is present; if not, log and skip registration for this resource + var statusCodeAttr = statusResponse?.GetCustomAttribute(); + if (statusCodeAttr is null || statusCodeAttr.StatusCode == 0) { _httpServerContext.Log.Debug ( @@ -160,10 +191,13 @@ private void Register(IPluginContext pluginContext, IEnumerable().StatusCode; + var statusCode = statusCodeAttr.StatusCode; var statusPageContext = new StatusPageContext() { StatusPageId = id, @@ -175,14 +209,21 @@ private void Register(IPluginContext pluginContext, IEnumerable new StatusPageItem() + { + StatusPageContext = new StatusPageContext() + { + StatusPageId = id, + PluginContext = pluginContext, + ApplicationContext = applicationContext, + StatusCode = statusCode, + StatusTitle = title, + StatusIcon = stausIcon + }, + StatusPageClass = resource, + StatusResponse = statusResponse, + PluginContext = pluginContext, + }, + (_, __) => new StatusPageItem() + { + StatusPageContext = new StatusPageContext() + { + StatusPageId = id, + PluginContext = pluginContext, + ApplicationContext = applicationContext, + StatusCode = statusCode, + StatusTitle = title, + StatusIcon = stausIcon + }, + StatusPageClass = resource, + StatusResponse = statusResponse, + PluginContext = pluginContext, + }); + } } } } @@ -261,9 +318,11 @@ private void Register(IPluginContext pluginContext, IEnumerableThe context of the status page or null. public IStatusPageContext GetStatusPage(IApplicationContext applicationContext, Type statusPageClass) { - var item = _dictionary.GetStatusPageItem(applicationContext, statusPageClass); - - return item?.StatusPageContext; + lock (_guard) + { + var item = _dictionary.GetStatusPageItem(applicationContext, statusPageClass); + return item?.StatusPageContext; + } } /// @@ -276,14 +335,20 @@ public IStatusPageContext GetStatusPage(IApplicationContext applicationContext, /// The response or null. public Response CreateStatusResponse(string message, int status, IApplicationContext applicationContext, Request request) { - var statusPageItem = _dictionary.GetStatusPageItem(applicationContext, status); + StatusPageItem statusPageItem = null; - if (statusPageItem == null && _defaults.TryGetValue(status, out StatusPageItem value)) + // access dictionary safely + lock (_guard) + { + statusPageItem = _dictionary.GetStatusPageItem(applicationContext, status); + } + + if (statusPageItem is null && _defaults.TryGetValue(status, out StatusPageItem value)) { statusPageItem = value; } - if (statusPageItem == null) + if (statusPageItem is null) { return status switch { @@ -307,30 +372,36 @@ public Response CreateStatusResponse(string message, int status, IApplicationCon var pageContext = new PageContext() { ApplicationContext = applicationContext, - Scopes = [typeof(IScopeStatusPage)] + Scopes = new[] { typeof(IScopeStatusPage) } }; var renderContext = new RenderContext(pageInstance as IEndpoint, pageContext, request); var visualTreeContext = new VisualTreeContext(renderContext); var visualTreeType = pageType.GetInterface(typeof(IStatusPage<>).Name).GetGenericArguments()[0]; + + // obtain or create a cached open-instance delegate safely if (!_delegateCache.TryGetValue(pageType, out var del)) { - // create and compile the expression + // create an open-instance delegate: (instance, renderContext, visualTree) => instance.Process(renderContext, visualTree) + var instanceParam = Expression.Parameter(pageType, "instance"); var renderContextParam = Expression.Parameter(typeof(IRenderContext), "renderContext"); var visualTreeParam = Expression.Parameter(visualTreeType, "visualTree"); - var processMethod = pageType.GetMethod("Process", [typeof(IRenderContext), visualTreeType]); - var callProzessMethod = Expression.Call - ( - Expression.Constant(pageInstance), - processMethod, - renderContextParam, - visualTreeParam - ); - var lambda = Expression.Lambda(callProzessMethod, renderContextParam, visualTreeParam) - .Compile(); - _delegateCache[pageType] = lambda; - del = lambda; + var processMethod = pageType.GetMethod("Process", new[] { typeof(IRenderContext), visualTreeType }); + + if (processMethod is null) + { + throw new InvalidOperationException($"Process method not found on type {pageType.FullName}"); + } + + // call instance.Process(renderContext, visualTree) + var callProcess = Expression.Call(instanceParam, processMethod, renderContextParam, visualTreeParam); + + // create a lambda with signature (instance, renderContext, visualTree) and compile it + var lambda = Expression.Lambda(callProcess, instanceParam, renderContextParam, visualTreeParam).Compile(); + + // try to add to concurrent dictionary atomically; if another thread added concurrently, use the existing one + del = _delegateCache.GetOrAdd(pageType, lambda); } // create visual tree instance @@ -338,7 +409,7 @@ public Response CreateStatusResponse(string message, int status, IApplicationCon var flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; var constructors = visualTreeType?.GetConstructors(flags); - if (constructors != null) + if (constructors is not null) { foreach (var constructor in constructors.OrderByDescending(x => x.GetParameters().Length)) { @@ -363,6 +434,7 @@ public Response CreateStatusResponse(string message, int status, IApplicationCon if (constructor.Invoke(parameterValues) is IVisualTree visualTree) { visualTreeInstance = visualTree; + break; } } } @@ -371,8 +443,13 @@ public Response CreateStatusResponse(string message, int status, IApplicationCon visualTreeInstance = Activator.CreateInstance(visualTreeType) as IVisualTree; } - // execute the cached delegate - del.DynamicInvoke(renderContext, visualTreeInstance); + if (visualTreeInstance is null) + { + throw new InvalidOperationException($"Could not create visual tree instance of type {visualTreeType.FullName} for page {pageType.FullName}."); + } + + // execute the cached open-instance delegate; pass the current pageInstance + del.DynamicInvoke(pageInstance, renderContext, visualTreeInstance); var response = ComponentActivator.CreateInstance(statusPageItem.StatusResponse, _httpServerContext, _componentHub, new StatusMessage(message)); var content = visualTreeInstance.Render(new VisualTreeContext(renderContext))?.ToString(); @@ -389,18 +466,20 @@ public Response CreateStatusResponse(string message, int status, IApplicationCon /// The context of the plugin that contains the status pages to remove. internal void Remove(IPluginContext pluginContext) { - if (pluginContext == null) + if (pluginContext is null) { return; } - // the plugin has not been registered in the manager - if (!_dictionary.ContainsKey(pluginContext)) + lock (_guard) { - return; - } + if (!_dictionary.ContainsKey(pluginContext)) + { + return; + } - _dictionary.Remove(pluginContext); + _dictionary.Remove(pluginContext); + } } /// @@ -409,23 +488,26 @@ internal void Remove(IPluginContext pluginContext) /// The context of the application that contains the status pages to remove. internal void Remove(IApplicationContext applicationContext) { - if (applicationContext == null) + if (applicationContext is null) { return; } - foreach (var pluginDict in _dictionary.Values) + lock (_guard) { - foreach (var appDict in pluginDict.Where(x => x.Key == applicationContext).Select(x => x.Value)) + foreach (var pluginDict in _dictionary.Values) { - foreach (var resourceItem in appDict.Values) + foreach (var appDict in pluginDict.Where(x => x.Key == applicationContext).Select(x => x.Value)) { - OnRemoveStatusPage(resourceItem.StatusPageContext); - resourceItem.Dispose(); + foreach (var resourceItem in appDict.Values) + { + OnRemoveStatusPage(resourceItem.StatusPageContext); + resourceItem.Dispose(); + } } - } - pluginDict.Remove(applicationContext); + pluginDict.Remove(applicationContext); + } } } @@ -491,6 +573,7 @@ private void OnAddApplication(object sender, IApplicationContext e) /// private void Log() { + // use snapshot StatusPages property (which is already locked) to avoid concurrent enumeration exceptions if (!StatusPages.Any()) { return; @@ -526,4 +609,4 @@ public void Dispose() GC.SuppressFinalize(this); } } -} +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebTheme/Model/ThemeItemDictionary.cs b/src/WebExpress.WebCore/WebTheme/Model/ThemeItemDictionary.cs index bfc38df..6679d5d 100644 --- a/src/WebExpress.WebCore/WebTheme/Model/ThemeItemDictionary.cs +++ b/src/WebExpress.WebCore/WebTheme/Model/ThemeItemDictionary.cs @@ -63,7 +63,7 @@ public bool AddThemeItem(IPluginContext pluginContext, IApplicationContext appli /// An enumeration of theme contexts that were removed. public IEnumerable Remove(IPluginContext pluginContext) { - if (pluginContext == null) + if (pluginContext is null) { return []; } @@ -95,7 +95,7 @@ public IEnumerable Remove(IPluginContext pluginContext) /// An enumeration of theme contexts that were removed. internal IEnumerable Remove(IApplicationContext applicationContext) { - if (applicationContext == null) + if (applicationContext is null) { return []; } diff --git a/src/WebExpress.WebCore/WebTheme/ThemeManager.cs b/src/WebExpress.WebCore/WebTheme/ThemeManager.cs index 4e8c830..7465bdf 100644 --- a/src/WebExpress.WebCore/WebTheme/ThemeManager.cs +++ b/src/WebExpress.WebCore/WebTheme/ThemeManager.cs @@ -144,7 +144,7 @@ private void Register(IPluginContext pluginContext, IEnumerable string Fragment { get; } - /// - /// Returns the display string of the Uri - /// - string Display { get; } - /// /// Determines if the uri is empty. /// @@ -124,5 +121,27 @@ public interface IUri /// /// A new IUri instance with the updated fragment. The original URI remains unchanged. IUri SetFragment(string fragment); + + /// + /// Returns a string that represents the display text for the current instance. + /// + /// The render context. + /// + /// A string containing the display text associated with the instance. The + /// value may be empty if no display text is available. + /// + string GetDisplayText(IRenderContext renderContext); + + /// + /// Returns an icon that visually represents the parameter within the given render context. + /// + /// + /// The rendering context that provides information required to determine the appropriate icon. + /// + /// + /// An icon associated with the current instance. The value may be null or empty + /// if no icon is available. + /// + IIcon GetIcon(IRenderContext renderContext); } } diff --git a/src/WebExpress.WebCore/WebUri/IUriPathSegment.cs b/src/WebExpress.WebCore/WebUri/IUriPathSegment.cs index 9cd674c..4dc32b5 100644 --- a/src/WebExpress.WebCore/WebUri/IUriPathSegment.cs +++ b/src/WebExpress.WebCore/WebUri/IUriPathSegment.cs @@ -1,5 +1,3 @@ -using System.Globalization; - namespace WebExpress.WebCore.WebUri { /// @@ -17,11 +15,6 @@ public interface IUriPathSegment /// string Value { get; } - /// - /// Returns or sets the display text. - /// - string Display { get; set; } - /// /// Returns the tag. /// @@ -51,11 +44,5 @@ public interface IUriPathSegment /// The comparison object. /// true if equals, false otherwise bool Equals(IUriPathSegment obj); - - /// - /// Returns or sets the display text. - /// - /// The culture. - string GetDisplay(CultureInfo culture); } } \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebUri/IUriPathSegmentConstant.cs b/src/WebExpress.WebCore/WebUri/IUriPathSegmentConstant.cs index c3aab3b..bdb6ef8 100644 --- a/src/WebExpress.WebCore/WebUri/IUriPathSegmentConstant.cs +++ b/src/WebExpress.WebCore/WebUri/IUriPathSegmentConstant.cs @@ -5,6 +5,5 @@ namespace WebExpress.WebCore.WebUri /// public interface IUriPathSegmentConstant : IUriPathSegment { - } } \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebUri/IUriPathSegmentVariable.cs b/src/WebExpress.WebCore/WebUri/IUriPathSegmentVariable.cs index fc3c2ce..a0b98c4 100644 --- a/src/WebExpress.WebCore/WebUri/IUriPathSegmentVariable.cs +++ b/src/WebExpress.WebCore/WebUri/IUriPathSegmentVariable.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using WebExpress.WebCore.WebIcon; +using WebExpress.WebCore.WebPage; namespace WebExpress.WebCore.WebUri { @@ -23,10 +24,36 @@ public interface IUriPathSegmentVariable : IUriPathSegment string Expression { get; } /// - /// Returns the variable. + /// Creates a deep copy of the current path segment and assigns the specified value. /// - /// The value. - /// The variable value pair. - IDictionary GetVariable(string value); + /// + /// The string value to assign to the copied segment. + /// + /// + /// A new instance representing the copied segment with the assigned value. + /// + IUriPathSegment Copy(string value); + + /// + /// Returns a string that represents the display text for the current instance. + /// + /// The render context. + /// + /// A string containing the display text associated with the instance. The + /// value may be empty if no display text is available. + /// + string GetDisplayText(IRenderContext renderContext); + + /// + /// Returns an icon that visually represents the parameter within the given render context. + /// + /// + /// The rendering context that provides information required to determine the appropriate icon. + /// + /// + /// An icon associated with the current instance. The value may be null or empty + /// if no icon is available. + /// + IIcon GetIcon(IRenderContext renderContext); } } \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs index e98845d..2c33f44 100644 --- a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs +++ b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs @@ -2,6 +2,9 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using WebExpress.WebCore.WebIcon; +using WebExpress.WebCore.WebPage; +using WebExpress.WebCore.WebParameter; namespace WebExpress.WebCore.WebUri { @@ -64,30 +67,6 @@ public partial class UriEndpoint : IUri /// public string Fragment { get; set; } - /// - /// Returns the display string of the Uri - /// - public virtual string Display - { - get - { - if (PathSegments.LastOrDefault() is IUriPathSegment last) - { - return last?.Display; - } - - return null; - } - - set - { - if (PathSegments.LastOrDefault() is IUriPathSegment last) - { - last.Display = value; - } - } - } - /// /// Determines if the uri is empty. /// @@ -101,7 +80,7 @@ public virtual string Display /// /// Checks if it is a relative uri. /// - public bool IsRelative => Authority == null; + public bool IsRelative => Authority is null; /// /// Retrieves a collection of variables represented as key-value pairs. @@ -244,7 +223,9 @@ public UriEndpoint(IUri uri, IEnumerable segments) /// The path segments. /// Other segments. public UriEndpoint(IUri uri, IEnumerable segments, IEnumerable extendedSegments) - : this(uri.Scheme, uri.Authority, uri.Fragment, uri.Query, extendedSegments != null ? segments.Union(extendedSegments) : segments) + : this(uri.Scheme, uri.Authority, uri.Fragment, uri.Query, extendedSegments is not null + ? segments.Union(extendedSegments) + : segments) { } @@ -389,7 +370,7 @@ public bool StartsWith(IUri uri) /// /// The parameters that fill in the variable parts of the uri. /// A new endpoint uri with the populated parameters. - public IUri SetParameters(params WebMessage.Parameter[] parameters) + public IUri SetParameters(params Parameter[] parameters) { var pathSegments = PathSegments.AsEnumerable(); @@ -480,6 +461,48 @@ public static implicit operator string(UriEndpoint uri) return uri?.ToString(); } + /// + /// Returns a string that represents the display text for the current instance. + /// + /// The render context. + /// + /// A string containing the display text associated with the instance. The + /// value may be empty if no display text is available. + /// + public virtual string GetDisplayText(IRenderContext renderContext) + { + var last = PathSegments.LastOrDefault(); + + if (last is IUriPathSegmentVariable variable) + { + return variable.GetDisplayText(renderContext); + } + + return null; + } + + /// + /// Returns an icon that visually represents the parameter within the given render context. + /// + /// + /// The rendering context that provides information required to determine the appropriate icon. + /// + /// + /// An icon associated with the current instance. The value may be null or empty + /// if no icon is available. + /// + public virtual IIcon GetIcon(IRenderContext renderContext) + { + var last = PathSegments.LastOrDefault(); + + if (last is IUriPathSegmentVariable variable) + { + return variable.GetIcon(renderContext); + } + + return null; + } + /// /// Converts the uri to a string. /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentConstant.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentConstant.cs index 9abbc45..1583246 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentConstant.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentConstant.cs @@ -1,6 +1,5 @@ using System; -using System.Globalization; -using WebExpress.WebCore.Internationalization; +using WebExpress.WebCore.WebPage; namespace WebExpress.WebCore.WebUri { @@ -19,11 +18,6 @@ public class UriPathSegmentConstant : IUriPathSegmentConstant /// public string Value { get; set; } - /// - /// Returns or sets the display text. - /// - public string Display { get; set; } - /// /// Returns or sets the tag. /// @@ -40,20 +34,8 @@ public class UriPathSegmentConstant : IUriPathSegmentConstant /// The name. /// The tag or null public UriPathSegmentConstant(string value, object tag = null) - : this(value, null, tag) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The name. - /// The display text. - /// The tag or null - public UriPathSegmentConstant(string value, string display, object tag = null) { - Value = value ?? string.Empty; - Display = display; + Value = value; Tag = tag; } @@ -79,7 +61,7 @@ public bool IsMatched(string value) /// The copy. public virtual IUriPathSegment Copy() { - return new UriPathSegmentConstant(Value, Display, Tag); + return new UriPathSegmentConstant(Value, Tag); } /// @@ -89,7 +71,7 @@ public virtual IUriPathSegment Copy() /// true if equals, false otherwise public virtual bool Equals(IUriPathSegment obj) { - if (obj == null) + if (obj is null) { return false; } @@ -102,12 +84,16 @@ public virtual bool Equals(IUriPathSegment obj) } /// - /// Returns or sets the display text. + /// Returns a string that represents the display text for the current instance. /// - /// The culture. - public virtual string GetDisplay(CultureInfo culture) + /// The render context. + /// + /// A string containing the display text associated with the instance. The + /// value may be empty if no display text is available. + /// + public virtual string GetDisplayText(IRenderContext renderContext) { - return I18N.Translate(culture, Display); + return null; } /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentRoot.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentRoot.cs index a27075c..976ce05 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentRoot.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentRoot.cs @@ -1,6 +1,6 @@ using System; -using System.Globalization; using WebExpress.WebCore.Internationalization; +using WebExpress.WebCore.WebPage; namespace WebExpress.WebCore.WebUri { @@ -78,7 +78,7 @@ public virtual IUriPathSegment Copy() /// True if the objects are equal, false otherwise. public virtual bool Equals(IUriPathSegment obj) { - if (obj == null) + if (obj is null) { return false; } @@ -87,13 +87,16 @@ public virtual bool Equals(IUriPathSegment obj) } /// - /// Returns the display text for the specified culture. + /// Returns a string that represents the display text for the current instance. /// - /// The culture. - /// The display text for the specified culture. - public virtual string GetDisplay(CultureInfo culture) + /// The render context. + /// + /// A string containing the display text associated with the instance. The + /// value may be empty if no display text is available. + /// + public virtual string GetDisplayText(IRenderContext renderContext) { - return I18N.Translate(culture, Display); + return I18N.Translate(renderContext, Display); } /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs index 88325b1..f96b703 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs @@ -1,15 +1,19 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Text.RegularExpressions; using WebExpress.WebCore.Internationalization; +using WebExpress.WebCore.WebIcon; +using WebExpress.WebCore.WebPage; +using WebExpress.WebCore.WebParameter; namespace WebExpress.WebCore.WebUri { /// /// Variable path segment. /// - public abstract class UriPathSegmentVariable : IUriPathSegmentVariable + /// The parameter type. + public abstract class UriPathSegmentVariable : IUriPathSegmentVariable + where TParameter : IParameter { /// /// Returns or sets the id. @@ -26,11 +30,6 @@ public abstract class UriPathSegmentVariable : IUriPathSegmentVariable /// public string Value { get; set; } - /// - /// Returns or sets the display text. - /// - public string Display { get; set; } - /// /// Returns or sets the regex expression. /// @@ -52,20 +51,8 @@ public abstract class UriPathSegmentVariable : IUriPathSegmentVariable /// The name. /// The tag or null public UriPathSegmentVariable(string name, object tag = null) - : this(name, null, tag) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The name. - /// The display text. - /// The tag or null - public UriPathSegmentVariable(string name, string display, object tag = null) { VariableName = name; - Display = display; Tag = tag; } @@ -81,7 +68,7 @@ public UriPathSegmentVariable(string name, string display, object tag = null) /// /// The value to check. /// True if the path element matched, false otherwise. - public bool IsMatched(string value) + public virtual bool IsMatched(string value) { if (string.IsNullOrWhiteSpace(value)) { @@ -112,26 +99,74 @@ public bool IsMatched(string value) /// true if equals, false otherwise public virtual bool Equals(IUriPathSegment obj) { - if (obj == null) + if (obj is null) { return false; } - else if (obj is UriPathSegmentVariable segment) + else if (obj is UriPathSegmentVariable segment) { - return VariableName.Equals(segment.VariableName, StringComparison.OrdinalIgnoreCase) && - Expression.Equals(segment.Expression); + return VariableName.Equals(segment.VariableName, StringComparison.OrdinalIgnoreCase) + && ( + (Expression is null && segment.Expression is null) + || Expression.Equals(segment.Expression) + ); } return false; } /// - /// Returns or sets the display text. + /// Creates a deep copy of the current path segment and assigns the specified value. + /// + /// + /// The string value to assign to the copied segment. + /// + /// + /// A new instance representing the copied segment with the assigned value. + /// + public IUriPathSegment Copy(string value) + { + var copy = Copy(); + if (copy is UriPathSegmentVariable segment) + { + segment.Value = value; + } + + return copy; + } + + /// + /// Returns a string that represents the display text for the current instance. /// - /// The culture. - public virtual string GetDisplay(CultureInfo culture) + /// The render context. + /// + /// A string containing the display text associated with the instance. The + /// value may be empty if no display text is available. + /// + public virtual string GetDisplayText(IRenderContext renderContext) { - return string.Format(I18N.Translate(culture, Display), Value); + var parameter = renderContext.Request.GetParameter(); + var displayText = parameter.GetDisplayText(renderContext); + + return string.Format(I18N.Translate(renderContext, displayText), Value); + } + + /// + /// Returns an icon that visually represents the parameter within the given render context. + /// + /// + /// The rendering context that provides information required to determine the appropriate icon. + /// + /// + /// An icon associated with the current instance. The value may be null or empty + /// if no icon is available. + /// + public virtual IIcon GetIcon(IRenderContext renderContext) + { + var parameter = renderContext.Request.GetParameter(); + var icon = parameter.GetIcon(renderContext); + + return icon; } /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableApiVersion.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableApiVersion.cs new file mode 100644 index 0000000..acaa9f0 --- /dev/null +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableApiVersion.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using WebExpress.WebCore.WebParameter; + +namespace WebExpress.WebCore.WebUri +{ + /// + /// A variable path segment for the api version (e.g., /api/1/...). + /// + /// The parameter type. + internal class UriPathSegmentVariableApiVersion : UriPathSegmentVariable + where TParameter : IParameter + { + /// + /// Initializes a new instance of the class. + /// + /// The name. + /// The value. + public UriPathSegmentVariableApiVersion(string name, string value) + : base(name) + { + VariableName = name; + Value = value; + } + + /// + /// Initializes a new instance of the class. + /// + /// The path segment to copy. + public UriPathSegmentVariableApiVersion(UriPathSegmentVariableApiVersion segment) + : base(segment.VariableName, segment.Tag) + { + } + + /// + /// Returns the variable. + /// + /// The value. + /// The variable value pair. + public override IDictionary GetVariable(string value) + { + return new Dictionary(); + } + + /// + /// Checks whether the node matches the path element. + /// + /// The value to check. + /// True if the path element matched, false otherwise. + public override bool IsMatched(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + else if (value.Equals(Value)) + { + return true; + } + + return false; + } + + /// + /// Make a deep copy. + /// + /// The copy. + public override IUriPathSegment Copy() + { + return new UriPathSegmentVariableApiVersion(this) { Value = Value }; + } + + /// + /// Compare the object. + /// + /// The comparison object. + /// true if equals, false otherwise + public override bool Equals(IUriPathSegment obj) + { + if (obj is null) + { + return false; + } + else if (obj is UriPathSegmentVariable segment) + { + return VariableName.Equals(segment.VariableName, StringComparison.OrdinalIgnoreCase) + && Value.Equals(segment.Value); + } + + return false; + } + + /// + /// Converts the segment to a string. + /// + /// A string that represents the current segment. + public override string ToString() + { + return base.ToString(); + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs index d165294..2b8a465 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs @@ -1,11 +1,14 @@ using System.Collections.Generic; +using WebExpress.WebCore.WebParameter; namespace WebExpress.WebCore.WebUri { /// /// Variable path segment. /// - public class UriPathSegmentVariableDouble : UriPathSegmentVariable + /// The parameter type. + public class UriPathSegmentVariableDouble : UriPathSegmentVariable + where TParameter : IParameter { /// /// Initializes a new instance of the class. @@ -17,23 +20,6 @@ public UriPathSegmentVariableDouble(string name, object tag = null) { VariableName = name; Value = name; - Display = name; - Expression = @"^[+-]?(\d*,\d+|\d+(,\d*)?)( +[eE][+-]?\d+)?$"; - Tag = tag; - } - - /// - /// Initializes a new instance of the class. - /// - /// The name. - /// The display text. - /// The tag or null - public UriPathSegmentVariableDouble(string name, string display, object tag = null) - : base(name, tag) - { - VariableName = name; - Value = name; - Display = display; Expression = @"^[+-]?(\d*,\d+|\d+(,\d*)?)( +[eE][+-]?\d+)?$"; Tag = tag; } @@ -42,8 +28,8 @@ public UriPathSegmentVariableDouble(string name, string display, object tag = nu /// Initializes a new instance of the class. /// /// The path segment to copy. - public UriPathSegmentVariableDouble(UriPathSegmentVariableDouble segment) - : base(segment.VariableName, segment.Display, segment.Tag) + public UriPathSegmentVariableDouble(UriPathSegmentVariableDouble segment) + : base(segment.VariableName, segment.Tag) { Expression = segment.Expression; } @@ -64,7 +50,7 @@ public override IDictionary GetVariable(string value) /// The copy. public override IUriPathSegment Copy() { - return new UriPathSegmentVariableDouble(this) { Value = Value }; + return new UriPathSegmentVariableDouble(this) { Value = Value }; } /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableGuid.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableGuid.cs index 0b536f9..ffd0e64 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableGuid.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableGuid.cs @@ -1,14 +1,17 @@ using System.Collections.Generic; -using System.Globalization; using System.Text.RegularExpressions; using WebExpress.WebCore.Internationalization; +using WebExpress.WebCore.WebPage; +using WebExpress.WebCore.WebParameter; namespace WebExpress.WebCore.WebUri { /// /// Represents a URI path segment variable for GUIDs. /// - public class UriPathSegmentVariableGuid : UriPathSegmentVariable + /// The parameter type. + public class UriPathSegmentVariableGuid : UriPathSegmentVariable + where TParameter : IParameter { /// /// The display formats of the guid. @@ -37,7 +40,7 @@ public enum Format /// The path text. /// The tag or null public UriPathSegmentVariableGuid(string name, object tag = null) - : this(name, null, tag) + : this(name, Format.Simple, tag) { } @@ -45,39 +48,16 @@ public UriPathSegmentVariableGuid(string name, object tag = null) /// Initializes a new instance of the class. /// /// The name. - /// The display text. - /// The tag or null - public UriPathSegmentVariableGuid(string name, string display, object tag = null) - : this(name, display, Format.Full, tag) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The name. - /// The display text. /// The display format. /// The tag or null - public UriPathSegmentVariableGuid(string name, string display, Format displayFormat, object tag = null) - : base(name, display, tag) + public UriPathSegmentVariableGuid(string name, Format displayFormat, object tag = null) + : base(name, tag) { VariableName = name; DisplayFormat = displayFormat; Expression = @"^(\{){0,1}(([0-9a-fA-F]{8})\-([0-9a-fA-F]{4})\-([0-9a-fA-F]{4})\-([0-9a-fA-F]{4})\-([0-9a-fA-F]{12}))(\}){0,1}$"; } - /// - /// Initializes a new instance of the class. - /// - /// The path segment to copy. - public UriPathSegmentVariableGuid(UriPathSegmentVariableGuid segment) - : base(segment.VariableName, segment.Display, segment.Tag) - { - DisplayFormat = segment.DisplayFormat; - Expression = segment.Expression; - } - /// /// Returns the variable. /// @@ -106,26 +86,34 @@ public override IDictionary GetVariable(string value) /// The copy. public override IUriPathSegment Copy() { - return new UriPathSegmentVariableGuid(this) { Value = Value }; + return new UriPathSegmentVariableGuid(VariableName, DisplayFormat) + { + Expression = Expression, + Value = Value + }; } /// - /// Returns or sets the display text. + /// Returns a string that represents the display text for the current instance. /// - /// The culture. - public override string GetDisplay(CultureInfo culture) + /// The render context. + /// + /// A string containing the display text associated with the instance. The + /// value may be empty if no display text is available. + /// + public override string GetDisplayText(IRenderContext renderContext) { var match = Regex.Match(Value, Expression, RegexOptions.IgnoreCase | RegexOptions.Compiled); var guid = DisplayFormat == Format.Simple ? match.Groups[7].ToString() : match.Groups[2].ToString(); - if (string.IsNullOrWhiteSpace(Display) || !Display.Contains("{0}")) + if (string.IsNullOrWhiteSpace(Value) || !Value.Contains("{0}")) { return guid; } return string.Format ( - I18N.Translate(culture, Display), + I18N.Translate(renderContext, Value), guid ); } diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs index a2a3b04..8e76448 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs @@ -1,39 +1,25 @@ using System.Collections.Generic; +using WebExpress.WebCore.WebParameter; namespace WebExpress.WebCore.WebUri { /// - /// Variable path segment. + /// Int variable path segment. /// - public class UriPathSegmentVariableInt : UriPathSegmentVariable + /// The parameter type. + public class UriPathSegmentVariableInt : UriPathSegmentVariable + where TParameter : IParameter { - /// - /// Initializes a new instance of the class. - /// - /// The path text. - /// The tag or null - public UriPathSegmentVariableInt(string name, object tag = null) - : base(name, tag) - { - VariableName = name; - Value = name; - Display = name; - Expression = @"^[+-]*\d$"; - Tag = tag; - } - /// /// Initializes a new instance of the class. /// /// The name. - /// The display text. /// The tag or null - public UriPathSegmentVariableInt(string name, string display, object tag = null) + public UriPathSegmentVariableInt(string name, object tag = null) : base(name, tag) { VariableName = name; Value = name; - Display = display; Expression = @"^[+-]*\d$"; Tag = tag; } @@ -42,8 +28,8 @@ public UriPathSegmentVariableInt(string name, string display, object tag = null) /// Initializes a new instance of the class. /// /// The path segment to copy. - public UriPathSegmentVariableInt(UriPathSegmentVariableInt segment) - : base(segment.VariableName, segment.Display, segment.Tag) + public UriPathSegmentVariableInt(UriPathSegmentVariableInt segment) + : base(segment.VariableName, segment.Tag) { Expression = segment.Expression; } @@ -64,7 +50,7 @@ public override IDictionary GetVariable(string value) /// The copy. public override IUriPathSegment Copy() { - return new UriPathSegmentVariableInt(this) { Value = Value }; + return new UriPathSegmentVariableInt(this) { Value = Value }; } /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs index 4a63792..b8686be 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs @@ -1,11 +1,14 @@ using System.Collections.Generic; +using WebExpress.WebCore.WebParameter; namespace WebExpress.WebCore.WebUri { /// /// Variable path segment. /// - public class UriPathSegmentVariableRegex : UriPathSegmentVariable + /// The parameter type. + public class UriPathSegmentVariableRegex : UriPathSegmentVariable + where TParameter : IParameter { /// /// Initializes a new instance of the class. @@ -18,24 +21,6 @@ public UriPathSegmentVariableRegex(string name, string regex, object tag = null) { VariableName = name; Value = name; - Display = name; - Expression = regex; - Tag = tag; - } - - /// - /// Initializes a new instance of the class. - /// - /// The name. - /// The regular expression. - /// The display text. - /// The tag or null - public UriPathSegmentVariableRegex(string name, string regex, string display, object tag = null) - : base(name, tag) - { - VariableName = name; - Value = name; - Display = display; Expression = regex; Tag = tag; } @@ -44,8 +29,8 @@ public UriPathSegmentVariableRegex(string name, string regex, string display, ob /// Initializes a new instance of the class. /// /// The path segment to copy. - public UriPathSegmentVariableRegex(UriPathSegmentVariableRegex segment) - : base(segment.VariableName, segment.Display, segment.Tag) + public UriPathSegmentVariableRegex(UriPathSegmentVariableRegex segment) + : base(segment.VariableName, segment.Tag) { Expression = segment.Expression; } @@ -66,7 +51,7 @@ public override IDictionary GetVariable(string value) /// The copy. public override IUriPathSegment Copy() { - return new UriPathSegmentVariableRegex(this) { Value = Value }; + return new UriPathSegmentVariableRegex(this) { Value = Value }; } /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableString.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableString.cs index ba56c3f..b4c9fec 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableString.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableString.cs @@ -1,11 +1,14 @@ using System.Collections.Generic; +using WebExpress.WebCore.WebParameter; namespace WebExpress.WebCore.WebUri { /// - /// Variable path segment. + /// String variable path segment. /// - public class UriPathSegmentVariableString : UriPathSegmentVariable + /// The parameter type. + public class UriPathSegmentVariableString : UriPathSegmentVariable + where TParameter : IParameter { /// /// Initializes a new instance of the class. @@ -17,23 +20,6 @@ public UriPathSegmentVariableString(string name, object tag = null) { VariableName = name; Value = name; - Display = name; - Expression = "^[^\"]*$"; - Tag = tag; - } - - /// - /// Initializes a new instance of the class. - /// - /// The name. - /// The display text. - /// The tag or null - public UriPathSegmentVariableString(string name, string display, object tag = null) - : base(name, tag) - { - VariableName = name; - Value = name; - Display = display; Expression = "^[^\"]*$"; Tag = tag; } @@ -42,8 +28,8 @@ public UriPathSegmentVariableString(string name, string display, object tag = nu /// Initializes a new instance of the class. /// /// The path segment to copy. - public UriPathSegmentVariableString(UriPathSegmentVariableString segment) - : base(segment.VariableName, segment.Display, segment.Tag) + public UriPathSegmentVariableString(UriPathSegmentVariableString segment) + : base(segment.VariableName, segment.Tag) { Expression = segment.Expression; } @@ -64,7 +50,7 @@ public override IDictionary GetVariable(string value) /// The copy. public override IUriPathSegment Copy() { - return new UriPathSegmentVariableString(this) { Value = Value }; + return new UriPathSegmentVariableString(this) { Value = Value }; } /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableUInt.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableUInt.cs index b7ad4c7..721612c 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableUInt.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableUInt.cs @@ -1,11 +1,14 @@ using System.Collections.Generic; +using WebExpress.WebCore.WebParameter; namespace WebExpress.WebCore.WebUri { /// - /// Variable path segment. + /// UInt variable path segment. /// - public class UriPathSegmentVariableUInt : UriPathSegmentVariable + /// The parameter type. + public class UriPathSegmentVariableUInt : UriPathSegmentVariable + where TParameter : IParameter { /// /// Initializes a new instance of the class. @@ -17,23 +20,6 @@ public UriPathSegmentVariableUInt(string name, object tag = null) { VariableName = name; Value = name; - Display = name; - Expression = @"^\d$"; - Tag = tag; - } - - /// - /// Initializes a new instance of the class. - /// - /// The name. - /// The display text. - /// The tag or null - public UriPathSegmentVariableUInt(string name, string display, object tag = null) - : base(name, display, tag) - { - VariableName = name; - Value = name; - Display = display; Expression = @"^\d$"; Tag = tag; } @@ -42,8 +28,8 @@ public UriPathSegmentVariableUInt(string name, string display, object tag = null /// Initializes a new instance of the class. /// /// The path segment to copy. - public UriPathSegmentVariableUInt(UriPathSegmentVariableUInt segment) - : base(segment.VariableName, segment.Display, segment.Tag) + public UriPathSegmentVariableUInt(UriPathSegmentVariableUInt segment) + : base(segment.VariableName, segment.Tag) { Expression = segment.Expression; } @@ -64,7 +50,7 @@ public override IDictionary GetVariable(string value) /// The copy. public override IUriPathSegment Copy() { - return new UriPathSegmentVariableUInt(this) { Value = Value }; + return new UriPathSegmentVariableUInt(this) { Value = Value }; } /// From 3de683108099d258bc69d8d65a8e9705f195c6c8 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 23 Nov 2025 21:33:21 +0100 Subject: [PATCH 07/53] feat: general improvements and minor bugs --- src/WebExpress.WebCore/WebUri/IUri.cs | 12 ++++++++++++ src/WebExpress.WebCore/WebUri/UriEndpoint.cs | 17 ++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/WebExpress.WebCore/WebUri/IUri.cs b/src/WebExpress.WebCore/WebUri/IUri.cs index 8a507c4..4166abd 100644 --- a/src/WebExpress.WebCore/WebUri/IUri.cs +++ b/src/WebExpress.WebCore/WebUri/IUri.cs @@ -69,6 +69,18 @@ public interface IUri /// IDictionary Parameters { get; } + /// + /// Appends one or more query parameters to the current URI and returns a new instance with + /// the updated query + /// string. + /// + /// An array of objects representing the query parameters to add. Each + /// parameter must not be null. + /// + /// An uri instance containing the original URI with the specified query parameters appended. + /// + IUri Add(params UriQuery[] query); + /// /// Concatenates the given path segment to the current URI and returns a new instance of IUri with the updated path. /// diff --git a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs index 2c33f44..0766c81 100644 --- a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs +++ b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs @@ -60,7 +60,7 @@ public partial class UriEndpoint : IUri /// /// The query part (e.g. ?title=Uniform_Resource_Identifier). /// - public IEnumerable Query { get; } = []; + public IEnumerable Query { get; private set; } = []; /// /// References a position within a resource (e.g. #Anchor). @@ -246,6 +246,21 @@ public UriEndpoint(UriScheme scheme, UriAuthority authority, string fragment, IE Fragment = fragment; } + /// + /// Appends one or more query parameters to the current URI and returns a new instance with + /// the updated query + /// string. + /// + /// An array of objects representing the query parameters to add. Each + /// parameter must not be null. + /// The current instance for method chaining. + public virtual IUri Add(params UriQuery[] query) + { + Query = Query.Concat(query.Where(x => x is not null)); + + return this; + } + /// /// Concatenates the given path segment to the current URI and returns a new instance of IUri with the updated path. /// From 2efcf7eb18583a1e84165fb3e1f64dbf559f4b13 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 14 Dec 2025 22:35:39 +0100 Subject: [PATCH 08/53] refactor: REST API layer --- .../Manager/UnitTestRestApiManager.cs | 10 +- .../WWW/Api/1/TestRestApiA.cs | 4 +- .../WWW/Api/2/TestRestApiB.cs | 2 +- .../WWW/Api/3/TestRestApiC.cs | 20 +-- .../WebAttribute/MethodAttribute.cs | 15 +- .../WebRestAPI/CrudMethod.cs | 35 ---- src/WebExpress.WebCore/WebRestAPI/IRestApi.cs | 28 --- .../WebRestAPI/IRestApiContext.cs | 3 +- .../WebRestAPI/IRestApiValidationResult.cs | 85 +++++++++ .../WebRestAPI/Model/RestApiItem.cs | 29 ++- src/WebExpress.WebCore/WebRestAPI/RestApi.cs | 72 -------- .../WebRestAPI/RestApiContext.cs | 3 +- .../WebRestAPI/RestApiManager.cs | 170 ++++++++++++------ .../WebRestApi/RestApiValidationResult.cs | 17 +- src/WebExpress.WebCore/WebUri/IUri.cs | 34 +++- src/WebExpress.WebCore/WebUri/UriEndpoint.cs | 22 ++- 16 files changed, 308 insertions(+), 241 deletions(-) delete mode 100644 src/WebExpress.WebCore/WebRestAPI/CrudMethod.cs create mode 100644 src/WebExpress.WebCore/WebRestAPI/IRestApiValidationResult.cs delete mode 100644 src/WebExpress.WebCore/WebRestAPI/RestApi.cs diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs index 79c9a46..aafbca6 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs @@ -3,6 +3,7 @@ using WebExpress.WebCore.Test.WWW.Api._2; using WebExpress.WebCore.Test.WWW.Api._3; using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebRestApi; @@ -128,11 +129,10 @@ public void Version(Type applicationType, Type resourceType, string expected) /// Test the context path property of the rest api. /// [Theory] - [InlineData(typeof(TestApplicationA), typeof(TestRestApiA), CrudMethod.POST)] - [InlineData(typeof(TestApplicationA), typeof(TestRestApiA), CrudMethod.GET)] - [InlineData(typeof(TestApplicationA), typeof(TestRestApiB), CrudMethod.GET)] - [InlineData(typeof(TestApplicationA), typeof(TestRestApiC), CrudMethod.GET)] - public void Method(Type applicationType, Type resourceType, CrudMethod method) + [InlineData(typeof(TestApplicationA), typeof(TestRestApiA), RequestMethod.POST)] + [InlineData(typeof(TestApplicationA), typeof(TestRestApiA), RequestMethod.GET)] + [InlineData(typeof(TestApplicationA), typeof(TestRestApiB), RequestMethod.GET)] + public void Method(Type applicationType, Type resourceType, RequestMethod method) { // preconditions var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); diff --git a/src/WebExpress.WebCore.Test/WWW/Api/1/TestRestApiA.cs b/src/WebExpress.WebCore.Test/WWW/Api/1/TestRestApiA.cs index 152ed01..99c97db 100644 --- a/src/WebExpress.WebCore.Test/WWW/Api/1/TestRestApiA.cs +++ b/src/WebExpress.WebCore.Test/WWW/Api/1/TestRestApiA.cs @@ -8,8 +8,6 @@ namespace WebExpress.WebCore.Test.WWW.Api._1 /// /// A dummy class for testing purposes. /// - [Method(CrudMethod.POST)] - [Method(CrudMethod.GET)] public sealed class TestRestApiA : IRestApi { /// @@ -40,6 +38,7 @@ public Response CreateData(Request request) /// /// The request. /// The response containing the result of the operation. + [Method(RequestMethod.GET)] public Response GetData(Request request) { return new ResponseBadRequest(new StatusMessage("Not implemented.")); @@ -50,6 +49,7 @@ public Response GetData(Request request) /// /// The request. /// The response containing the result of the operation. + [Method(RequestMethod.POST)] public Response UpdateData(Request request) { // test the request diff --git a/src/WebExpress.WebCore.Test/WWW/Api/2/TestRestApiB.cs b/src/WebExpress.WebCore.Test/WWW/Api/2/TestRestApiB.cs index 56b4e3d..d5ef343 100644 --- a/src/WebExpress.WebCore.Test/WWW/Api/2/TestRestApiB.cs +++ b/src/WebExpress.WebCore.Test/WWW/Api/2/TestRestApiB.cs @@ -8,7 +8,6 @@ namespace WebExpress.WebCore.Test.WWW.Api._2 /// /// A dummy class for testing purposes. /// - [Method(CrudMethod.GET)] public sealed class TestRestApiB : IRestApi { /// @@ -39,6 +38,7 @@ public Response CreateData(Request request) /// /// The request. /// The response containing the result of the operation. + [Method(RequestMethod.GET)] public Response GetData(Request request) { return new ResponseBadRequest(new StatusMessage("Not implemented.")); diff --git a/src/WebExpress.WebCore.Test/WWW/Api/3/TestRestApiC.cs b/src/WebExpress.WebCore.Test/WWW/Api/3/TestRestApiC.cs index e406ca1..95425e7 100644 --- a/src/WebExpress.WebCore.Test/WWW/Api/3/TestRestApiC.cs +++ b/src/WebExpress.WebCore.Test/WWW/Api/3/TestRestApiC.cs @@ -9,8 +9,7 @@ namespace WebExpress.WebCore.Test.WWW.Api._3 /// /// A dummy class for testing purposes. /// - [Method(CrudMethod.GET)] - public sealed class TestRestApiC : RestApi + public sealed class TestRestApiC : IRestApi { /// /// Initialization of the rest api resource. Here, for example, managed resources can be loaded. @@ -18,7 +17,6 @@ public sealed class TestRestApiC : RestApi /// The component hub. /// The context of the restapi resource. public TestRestApiC(IComponentHub componentHub, IRestApiContext restApiContext) - : base(restApiContext) { // test the injection if (componentHub is null) @@ -38,7 +36,7 @@ public TestRestApiC(IComponentHub componentHub, IRestApiContext restApiContext) /// /// The request. /// The response containing the result of the operation. - public override Response CreateData(Request request) + public Response CreateData(Request request) { return new ResponseBadRequest(new StatusMessage("Not implemented.")); } @@ -48,7 +46,8 @@ public override Response CreateData(Request request) /// /// The request. /// The response containing the result of the operation. - public override Response GetData(Request request) + [Method(RequestMethod.GET)] + public Response GetData(Request request) { return new ResponseBadRequest(new StatusMessage("Not implemented.")); } @@ -58,7 +57,7 @@ public override Response GetData(Request request) /// /// The request. /// The response containing the result of the operation. - public override Response UpdateData(Request request) + public Response UpdateData(Request request) { // test the request if (request is null) @@ -74,7 +73,7 @@ public override Response UpdateData(Request request) /// /// The request. /// The response containing the result of the operation. - public override Response DeleteData(Request request) + public Response DeleteData(Request request) { // test the request if (request is null) @@ -84,12 +83,5 @@ public override Response DeleteData(Request request) return new ResponseBadRequest(new StatusMessage("Not implemented.")); } - - /// - /// Release of unmanaged resources reserved during use. - /// - public override void Dispose() - { - } } } diff --git a/src/WebExpress.WebCore/WebAttribute/MethodAttribute.cs b/src/WebExpress.WebCore/WebAttribute/MethodAttribute.cs index c78a190..92cf0bb 100644 --- a/src/WebExpress.WebCore/WebAttribute/MethodAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/MethodAttribute.cs @@ -1,20 +1,27 @@ using System; -using WebExpress.WebCore.WebRestApi; +using WebExpress.WebCore.WebMessage; namespace WebExpress.WebCore.WebAttribute { /// /// The range in which the attribute is valid. /// - [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] public class MethodAttribute : Attribute, IEndpointAttribute { + /// + /// Returns the CRUD (Create, Read, Update, Delete) operation type + /// associated with the current request. + /// + public RequestMethod RequestMethod { get; private set; } + /// /// Initializes a new instance of the class. /// - public MethodAttribute(CrudMethod crudMethod) + /// The request method. + public MethodAttribute(RequestMethod requestMethod) { - + RequestMethod = requestMethod; } } } diff --git a/src/WebExpress.WebCore/WebRestAPI/CrudMethod.cs b/src/WebExpress.WebCore/WebRestAPI/CrudMethod.cs deleted file mode 100644 index a41d357..0000000 --- a/src/WebExpress.WebCore/WebRestAPI/CrudMethod.cs +++ /dev/null @@ -1,35 +0,0 @@ -using WebExpress.WebCore.WebMessage; - -namespace WebExpress.WebCore.WebRestApi -{ - /// - /// Represents the crud methods for a rest api. - /// - public enum CrudMethod - { - /// - /// Represents the HTTP POST method. - /// - POST = RequestMethod.POST, - - /// - /// Represents the HTTP GET method. - /// - GET = RequestMethod.GET, - - /// - /// Represents the HTTP PATCH method. - /// - PATCH = RequestMethod.PATCH, - - /// - /// Represents the HTTP PATCH method. - /// - PUT = RequestMethod.PUT, - - /// - /// Represents the HTTP DELETE method. - /// - DELETE = RequestMethod.DELETE - } -} diff --git a/src/WebExpress.WebCore/WebRestAPI/IRestApi.cs b/src/WebExpress.WebCore/WebRestAPI/IRestApi.cs index bc3368b..d27f081 100644 --- a/src/WebExpress.WebCore/WebRestAPI/IRestApi.cs +++ b/src/WebExpress.WebCore/WebRestAPI/IRestApi.cs @@ -1,5 +1,4 @@ using WebExpress.WebCore.WebEndpoint; -using WebExpress.WebCore.WebMessage; namespace WebExpress.WebCore.WebRestApi { @@ -8,32 +7,5 @@ namespace WebExpress.WebCore.WebRestApi /// public interface IRestApi : IEndpoint { - /// - /// Creates data. - /// - /// The request. - /// The response containing the result of the operation. - Response CreateData(Request request); - - /// - /// Gets data. - /// - /// The request. - /// The response containing the result of the operation. - Response GetData(Request request); - - /// - /// Updates data. - /// - /// The request. - /// The response containing the result of the operation. - Response UpdateData(Request request); - - /// - /// Deletes data. - /// - /// The request. - /// The response containing the result of the operation. - Response DeleteData(Request request); } } diff --git a/src/WebExpress.WebCore/WebRestAPI/IRestApiContext.cs b/src/WebExpress.WebCore/WebRestAPI/IRestApiContext.cs index 5960b70..b37df27 100644 --- a/src/WebExpress.WebCore/WebRestAPI/IRestApiContext.cs +++ b/src/WebExpress.WebCore/WebRestAPI/IRestApiContext.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.WebMessage; namespace WebExpress.WebCore.WebRestApi { @@ -11,7 +12,7 @@ public interface IRestApiContext : IEndpointContext /// /// Returns the crud methods. /// - IEnumerable Methods { get; } + IEnumerable Methods { get; } /// /// Returns the version number of the rest api. diff --git a/src/WebExpress.WebCore/WebRestAPI/IRestApiValidationResult.cs b/src/WebExpress.WebCore/WebRestAPI/IRestApiValidationResult.cs new file mode 100644 index 0000000..aa1cfe3 --- /dev/null +++ b/src/WebExpress.WebCore/WebRestAPI/IRestApiValidationResult.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; + +namespace WebExpress.WebCore.WebRestApi +{ + /// + /// Represents the result of a REST API validation, including any validation errors encountered. + /// + /// + /// This class provides a way to collect and inspect validation errors that occur + /// during the processing of a REST API request. It includes methods to add individual + /// or multiple errors, and properties to check the overall validity of the result. + /// + public interface IRestApiValidationResult + { + /// + /// Returns a read-only collection of errors encountered during the API operation. + /// + IEnumerable Errors { get; } + + /// + /// Returns a value indicating whether the current state is valid. + /// + /// The state is considered valid if there are no errors present. + bool IsValid { get; } + + /// + /// Adds a new error to the collection with the specified message, field, and code. + /// + /// + /// Use this method to record errors encountered during an operation, + /// optionally associating them with a specific field or error code. + /// + /// + /// The error message describing the issue. This parameter is required and + /// cannot be null or empty. + /// + /// + /// The name of the field associated with the error, if applicable. This + /// parameter is optional and can be null. + /// + /// + /// A code representing the type or category of the error, if applicable. + /// This parameter is optional and can be null. + /// + /// The current instance for method chaining. + IRestApiValidationResult Add(string message, string field = null, string code = null); + + /// + /// Adds one or more instances to the collection. + /// + /// + /// This method appends the specified errors to the existing collection. If + /// the array is empty, no changes are made. + /// + /// An array of error objects to add. + /// The current instance for method chaining. + IRestApiValidationResult Add(params RestApiError[] errors); + + /// + /// Adds one or more instances to the collection. + /// + /// + /// This method appends the specified errors to the existing collection. If + /// the array is empty, no changes are made. + /// + /// An array of error objects to add. + /// The current instance for method chaining. + IRestApiValidationResult AddRange(IEnumerable errors); + + /// + /// Converts the collection of errors to a JSON-formatted string. + /// + /// + /// Each error in the collection is serialized as an object containing the + /// properties Code, Message, and Field. The resulting + /// JSON string represents an array of these objects. + /// + /// + /// A JSON-formatted string representing the collection of errors. If + /// the collection is empty, the method returns an empty JSON array + /// ([]). + /// + string ToJson(); + } +} diff --git a/src/WebExpress.WebCore/WebRestAPI/Model/RestApiItem.cs b/src/WebExpress.WebCore/WebRestAPI/Model/RestApiItem.cs index ff43210..310ac85 100644 --- a/src/WebExpress.WebCore/WebRestAPI/Model/RestApiItem.cs +++ b/src/WebExpress.WebCore/WebRestAPI/Model/RestApiItem.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; +using System.Reflection; using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebCondition; using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPlugin; using WebExpress.WebCore.WebUri; @@ -62,7 +64,7 @@ internal class RestApiItem : IDisposable /// /// Returns the crud methods. /// - public IEnumerable Methods { get; internal set; } + public IEnumerable Methods { get; internal set; } /// /// Returns the version number of the rest api. @@ -74,6 +76,31 @@ internal class RestApiItem : IDisposable /// public bool Cache { get; internal set; } + /// + /// Returns the reflection information for the associated get method. + /// + public MethodInfo GetMethod { get; internal set; } + + /// + /// Returns the reflection information for the associated post method. + /// + public MethodInfo PostMethod { get; internal set; } + + /// + /// Returns the reflection information for the associated patch method. + /// + public MethodInfo PatchMethod { get; internal set; } + + /// + /// Returns the reflection information for the associated put method. + /// + public MethodInfo PutMethod { get; internal set; } + + /// + /// Returns the reflection information for the associated delete method. + /// + public MethodInfo DeleteMethod { get; internal set; } + /// /// Returns the attributes associated with the page. /// diff --git a/src/WebExpress.WebCore/WebRestAPI/RestApi.cs b/src/WebExpress.WebCore/WebRestAPI/RestApi.cs deleted file mode 100644 index 647894f..0000000 --- a/src/WebExpress.WebCore/WebRestAPI/RestApi.cs +++ /dev/null @@ -1,72 +0,0 @@ -using WebExpress.WebCore.WebMessage; -using WebExpress.WebCore.WebStatusPage; - -namespace WebExpress.WebCore.WebRestApi -{ - /// - /// The prototype of a rest api. - /// - public abstract class RestApi : IRestApi - { - /// - /// Returns the rest api context. - /// - public IRestApiContext RestApiContext { get; private set; } - - /// - /// Initializes a new instance of the class. - /// - /// The context of the rest api resource. - public RestApi(IRestApiContext restApiContext) - { - RestApiContext = restApiContext; - } - - /// - /// Creates data. - /// - /// The request. - /// The response containing the result of the operation. - public virtual Response CreateData(Request request) - { - return new ResponseBadRequest(new StatusMessage("Not implemented.")); - } - - /// - /// Gets data. - /// - /// The response containing the result of the operation. - public virtual Response GetData(Request request) - { - return new ResponseBadRequest(new StatusMessage("Not implemented.")); - } - - /// - /// Updates data. - /// - /// The request. - /// The response containing the result of the operation. - public virtual Response UpdateData(Request request) - { - return new ResponseBadRequest(new StatusMessage("Not implemented.")); - } - - /// - /// Deletes data. - /// - /// The request. - /// The response containing the result of the operation. - public virtual Response DeleteData(Request request) - { - return new ResponseBadRequest(new StatusMessage("Not implemented.")); - } - - /// - /// Performs application-specific tasks related to sharing, returning, or resetting unmanaged resources. - /// - public virtual void Dispose() - { - - } - } -} diff --git a/src/WebExpress.WebCore/WebRestAPI/RestApiContext.cs b/src/WebExpress.WebCore/WebRestAPI/RestApiContext.cs index 9a21399..b52587b 100644 --- a/src/WebExpress.WebCore/WebRestAPI/RestApiContext.cs +++ b/src/WebExpress.WebCore/WebRestAPI/RestApiContext.cs @@ -4,6 +4,7 @@ using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebCondition; using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPlugin; using WebExpress.WebCore.WebUri; @@ -32,7 +33,7 @@ public class RestApiContext : IRestApiContext /// /// Returns the crud methods. /// - public IEnumerable Methods { get; internal set; } = []; + public IEnumerable Methods { get; internal set; } = []; /// /// Returns the endpoint id. diff --git a/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs b/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs index e51e9fc..1b5e4dc 100644 --- a/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs +++ b/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Reflection; using System.Text.RegularExpressions; using System.Threading; using WebExpress.WebCore.Internationalization; @@ -55,11 +56,10 @@ public IEnumerable RestApis // return a stable snapshot to avoid enumeration during concurrent modifications lock (_guard) { - return _dictionary.Values + return [.. _dictionary.Values .SelectMany(x => x.Values) .SelectMany(x => x.Values) - .Select(x => x.RestApiContext) - .ToList(); + .Select(x => x.RestApiContext)]; } } } @@ -90,7 +90,12 @@ private RestApiManager(IComponentHub componentHub, IHttpServerContext httpServer { // get rest api context and create or obtain instance var restApiContext = endpointContext as IRestApiContext; - var restApi = CreateApiInstance(restApiContext) as IRestApi; + var restApiItem = _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .FirstOrDefault(x => x.RestApiContext.Equals(restApiContext)); + + var restApi = CreateApiInstance(restApiItem); // if no resource found, return bad request if (restApiContext is null || restApi is null) @@ -102,20 +107,50 @@ private RestApiManager(IComponentHub componentHub, IHttpServerContext httpServer } // execute according to allowed methods - if (restApiContext.Methods.Any(x => x.Equals((CrudMethod)request.Method))) + if (restApiContext.Methods.Any(x => x.Equals((RequestMethod)request.Method))) { switch (request.Method) { case RequestMethod.POST: - return restApi.CreateData(request) ?? new ResponseOK(); + if (restApiItem.GetMethod is not null) + { + return (Response)(restApiItem.PostMethod + .Invoke(restApi, [request]) + ?? new ResponseOK()); + } + break; case RequestMethod.GET: - return restApi.GetData(request) ?? new ResponseOK(); + if (restApiItem.GetMethod is not null) + { + return (Response)(restApiItem.GetMethod + .Invoke(restApi, [request]) + ?? new ResponseOK()); + } + break; case RequestMethod.PATCH: - return restApi.UpdateData(request) ?? new ResponseOK(); + if (restApiItem.GetMethod is not null) + { + return (Response)(restApiItem.PatchMethod + .Invoke(restApi, [request]) + ?? new ResponseOK()); + } + break; case RequestMethod.PUT: - return restApi.UpdateData(request) ?? new ResponseOK(); + if (restApiItem.GetMethod is not null) + { + return (Response)(restApiItem.PutMethod + .Invoke(restApi, [request]) + ?? new ResponseOK()); + } + break; case RequestMethod.DELETE: - return restApi.DeleteData(request) ?? new ResponseOK(); + if (restApiItem.GetMethod is not null) + { + return (Response)(restApiItem.DeleteMethod + .Invoke(restApi, [request]) + ?? new ResponseOK()); + } + break; default: return new ResponseBadRequest() { @@ -151,13 +186,12 @@ public IEnumerable GetRestApi(IPluginContext pluginContext) if (_dictionary.TryGetValue(pluginContext, out var pluginResources)) { // return snapshot list - return pluginResources + return [.. pluginResources .SelectMany(x => x.Value) - .Select(x => x.Value.RestApiContext) - .ToList(); + .Select(x => x.Value.RestApiContext)]; } - return Enumerable.Empty(); + return []; } } @@ -180,12 +214,11 @@ public IEnumerable GetRestApi(Type restApiType) { lock (_guard) { - return _dictionary.Values + return [.. _dictionary.Values .SelectMany(x => x.Values) .SelectMany(x => x.Values) .Where(x => x.RestApiClass.Equals(restApiType)) - .Select(x => x.RestApiContext) - .ToList(); + .Select(x => x.RestApiContext)]; } } @@ -199,13 +232,12 @@ public IEnumerable GetRestApi(Type restApiType, IApplicationCon { lock (_guard) { - return _dictionary.Values + return [.. _dictionary.Values .SelectMany(x => x.Values) .SelectMany(x => x.Values) .Where(x => x.RestApiClass.Equals(restApiType)) .Where(x => x.RestApiContext.ApplicationContext.Equals(applicationContext)) - .Select(x => x.RestApiContext) - .ToList(); + .Select(x => x.RestApiContext)]; } } @@ -219,13 +251,12 @@ public IEnumerable GetRestApi(IApplicationContext applicatio { lock (_guard) { - return _dictionary.Values + return [.. _dictionary.Values .SelectMany(x => x.Values) .SelectMany(x => x.Values) .Where(x => x.RestApiClass.Equals(typeof(T))) .Where(x => x.RestApiContext.ApplicationContext.Equals(applicationContext)) - .Select(x => x.RestApiContext) - .ToList(); + .Select(x => x.RestApiContext)]; } } @@ -273,50 +304,38 @@ public IRestApiContext GetRestApi(string applicationId, string restApiId) /// Creates a new rest api resource and returns it. If a rest api resource already exists (through caching), the existing instance is returned. /// Thread-safe: cached instance creation and assignment is protected. /// - /// The context used for rest api resource creation. + /// The item used for rest api resource creation. /// The created or cached rest api resource. - private IRestApi CreateApiInstance(IRestApiContext apiContext) + private IRestApi CreateApiInstance(RestApiItem apiItem) { - if (apiContext is null) + if (apiItem is null) { return null; } - RestApiItem resourceItem = null; - // locate resourceItem inside lock to get a consistent view lock (_guard) { - resourceItem = _dictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x.Values) - .FirstOrDefault(x => x.RestApiContext.Equals(apiContext)); - - if (resourceItem is null) - { - return null; - } - // if instance already cached, return immediately - if (resourceItem.Instance is not null) + if (apiItem.Instance is not null) { - return resourceItem.Instance as IRestApi; + return apiItem.Instance; } // if caching is enabled, create and assign the instance under lock to avoid double-creation - if (resourceItem.Cache) + if (apiItem.Cache) { // create instance while holding the lock to ensure only one creation and assignment occurs var instanceCached = ComponentActivator.CreateInstance ( - resourceItem.RestApiClass, - apiContext, + apiItem.RestApiClass, + apiItem.RestApiContext, _httpServerContext, _componentHub, - apiContext.ApplicationContext + apiItem.ApplicationContext ); - resourceItem.Instance = instanceCached; + apiItem.Instance = instanceCached; return instanceCached; } } @@ -324,11 +343,11 @@ private IRestApi CreateApiInstance(IRestApiContext apiContext) // if not caching, create instance outside lock (no shared state to modify) var instanceNoCache = ComponentActivator.CreateInstance ( - resourceItem.RestApiClass, - apiContext, + apiItem.RestApiClass, + apiItem.RestApiContext, _httpServerContext, _componentHub, - apiContext.ApplicationContext + apiItem.ApplicationContext ); return instanceNoCache; @@ -392,13 +411,53 @@ private void Register(IPluginContext pluginContext, IEnumerable(); var cache = false; - var methods = new List(); var match = ApiVersionRegex().Match(id); var versionSegment = match.Success ? match.Groups[0].Value.Replace(".", "") : ""; var version = match.Success && uint.TryParse(match.Groups[1].Value, out var result) ? result : 1u; var attributes = restApiType.CustomAttributes .Where(x => !x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute)) && !x.AttributeType.GetInterfaces().Contains(typeof(IPageAttribute))); + var getMethod = restApiType + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(m => m.GetCustomAttributes(typeof(MethodAttribute), false) + .Cast() + .Any(attr => attr.RequestMethod == RequestMethod.GET)) + .FirstOrDefault(); + var postMethod = restApiType + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(m => m.GetCustomAttributes(typeof(MethodAttribute), false) + .Cast() + .Any(attr => attr.RequestMethod == RequestMethod.POST)) + .FirstOrDefault(); + var patchMethod = restApiType + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(m => m.GetCustomAttributes(typeof(MethodAttribute), false) + .Cast() + .Any(attr => attr.RequestMethod == RequestMethod.PATCH)) + .FirstOrDefault(); + var putMethod = restApiType + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(m => m.GetCustomAttributes(typeof(MethodAttribute), false) + .Cast() + .Any(attr => attr.RequestMethod == RequestMethod.PUT)) + .FirstOrDefault(); + var deleteMethod = restApiType + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(m => m.GetCustomAttributes(typeof(MethodAttribute), false) + .Cast() + .Any(attr => attr.RequestMethod == RequestMethod.DELETE)) + .FirstOrDefault(); + + var methods = new[] + { + (getMethod, RequestMethod.GET), + (postMethod, RequestMethod.POST), + (patchMethod, RequestMethod.PATCH), + (putMethod, RequestMethod.PUT), + (deleteMethod, RequestMethod.DELETE) + } + .Where(x => x.Item1 is not null) + .Select(x => x.Item2); foreach (var customAttribute in restApiType.CustomAttributes .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute)))) @@ -421,12 +480,6 @@ private void Register(IPluginContext pluginContext, IEnumerable x.AttributeType) + Attributes = attributes.Select(x => x.AttributeType), + GetMethod = getMethod, + PostMethod = postMethod, + PatchMethod = patchMethod, + PutMethod = putMethod, + DeleteMethod = deleteMethod }; // add mutation protected by lock to avoid concurrent modifications diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs b/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs index 990f591..ddf42b1 100644 --- a/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs +++ b/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs @@ -11,7 +11,7 @@ namespace WebExpress.WebCore.WebRestApi /// during the processing of a REST API request. It includes methods to add individual /// or multiple errors, and properties to check the overall validity of the result. /// - public class RestApiValidationResult + public class RestApiValidationResult : IRestApiValidationResult { private readonly List _errors = []; @@ -45,9 +45,12 @@ public class RestApiValidationResult /// A code representing the type or category of the error, if applicable. /// This parameter is optional and can be null. /// - public void Add(string message, string field = null, string code = null) + /// The current instance for method chaining. + public IRestApiValidationResult Add(string message, string field = null, string code = null) { _errors.Add(new RestApiError(message, code, field)); + + return this; } /// @@ -58,9 +61,12 @@ public void Add(string message, string field = null, string code = null) /// the array is empty, no changes are made. /// /// An array of error objects to add. - public void Add(params RestApiError[] errors) + /// The current instance for method chaining. + public IRestApiValidationResult Add(params RestApiError[] errors) { _errors.AddRange(errors); + + return this; } /// @@ -71,12 +77,15 @@ public void Add(params RestApiError[] errors) /// the array is empty, no changes are made. /// /// An array of error objects to add. - public void AddRange(IEnumerable errors) + /// The current instance for method chaining. + public IRestApiValidationResult AddRange(IEnumerable errors) { if (errors is not null) { _errors.AddRange(errors); } + + return this; } /// diff --git a/src/WebExpress.WebCore/WebUri/IUri.cs b/src/WebExpress.WebCore/WebUri/IUri.cs index 4166abd..2fcadc4 100644 --- a/src/WebExpress.WebCore/WebUri/IUri.cs +++ b/src/WebExpress.WebCore/WebUri/IUri.cs @@ -1,15 +1,18 @@ using System.Collections.Generic; using WebExpress.WebCore.WebIcon; using WebExpress.WebCore.WebPage; +using WebExpress.WebCore.WebParameter; namespace WebExpress.WebCore.WebUri { /// - /// An Uri represents a complete, fully qualified Uniform Resource Identifier (URI) that uniquely identifies a endpoint. - /// - /// This interface encapsulates all components of a typical URI, such as the scheme (e.g., "http", "https"), - /// the authority (e.g., "example.com"), path segments, query parameters, and fragment. It provides the external - /// address used for resource identification and linking (e.g., "http://example.com/users/123"). + /// An Uri represents a complete, fully qualified Uniform Resource + /// Identifier (URI) that uniquely identifies a endpoint. + /// This interface encapsulates all components of a typical URI, such as + /// the scheme (e.g., "http", "https"), the authority (e.g., "example.com"), + /// path segments, query parameters, and fragment. It provides the external + /// address used for resource identification and linking + /// (e.g., "http://example.com/users/123"). /// public interface IUri { @@ -145,15 +148,28 @@ public interface IUri string GetDisplayText(IRenderContext renderContext); /// - /// Returns an icon that visually represents the parameter within the given render context. + /// Returns an icon that visually represents the parameter within + /// the given render context. /// /// - /// The rendering context that provides information required to determine the appropriate icon. + /// The rendering context that provides information required to + /// determine the appropriate icon. /// /// - /// An icon associated with the current instance. The value may be null or empty - /// if no icon is available. + /// An icon associated with the current instance. The value may be + /// null or empty if no icon is available. /// IIcon GetIcon(IRenderContext renderContext); + + /// + /// Creates a new endpoint uri and fills it with the given parameters. + /// + /// + /// The parameters that fill in the variable parts of the uri. + /// + /// + /// A new endpoint uri with the populated parameters. + /// + IUri SetParameters(params Parameter[] parameters); } } diff --git a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs index 0766c81..865922d 100644 --- a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs +++ b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs @@ -9,11 +9,13 @@ namespace WebExpress.WebCore.WebUri { /// - /// An Uri represents a complete, fully qualified Uniform Resource Identifier (URI) that uniquely - /// identifies a endpoint (see RFC 3986). - /// This interface encapsulates all components of a typical URI, such as the scheme (e.g., "http", "https"), - /// the authority (e.g., "example.com"), path segments, query parameters, and fragment. It provides the external - /// address used for resource identification and linking (e.g., "http://example.com/users/123"). + /// An Uri represents a complete, fully qualified Uniform Resource + /// Identifier (URI) that uniquely identifies a endpoint (see RFC 3986). + /// This interface encapsulates all components of a typical URI, such as + /// the scheme (e.g., "http", "https"), the authority (e.g., "example.com"), + /// path segments, query parameters, and fragment. It provides the external + /// address used for resource identification and linking + /// (e.g., "http://example.com/users/123"). /// public partial class UriEndpoint : IUri { @@ -383,9 +385,13 @@ public bool StartsWith(IUri uri) /// /// Creates a new endpoint uri and fills it with the given parameters. /// - /// The parameters that fill in the variable parts of the uri. - /// A new endpoint uri with the populated parameters. - public IUri SetParameters(params Parameter[] parameters) + /// + /// The parameters that fill in the variable parts of the uri. + /// + /// + /// A new endpoint uri with the populated parameters. + /// + public virtual IUri SetParameters(params Parameter[] parameters) { var pathSegments = PathSegments.AsEnumerable(); From c57268c1b25c0923db545fe22b132638a4a8ccd7 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Tue, 23 Dec 2025 19:16:43 +0100 Subject: [PATCH 09/53] add: websocket support --- ...ation.cs => UnitTestApplicationManager.cs} | 8 +- .../Manager/UnitTestSitemapManager.cs | 2 +- .../Manager/UnitTestSocketManager.cs | 119 +++ src/WebExpress.WebCore.Test/TestSocketA.cs | 104 +++ src/WebExpress.WebCore/HttpServer.cs | 355 ++++++--- .../Internationalization/de | 4 + .../Internationalization/en | 4 + .../WebAttribute/ISocketAttribute.cs | 11 + .../WebAttribute/MaxMessageSizeAttribute.cs | 29 + .../WebAttribute/MessageTypeAttribute.cs | 26 + .../WebAttribute/MethodAttribute.cs | 17 +- .../WebAttribute/SubProtocolAttribute.cs | 26 + .../WebComponent/ComponentHub.cs | 10 + .../WebComponent/IComponentHub.cs | 7 + .../WebEndpoint/RouteEndpoint.cs | 13 + src/WebExpress.WebCore/WebExpress.WebCore.sln | 25 + .../WebInclude/IncludeManager.cs | 8 + .../WebMessage/RequestHeaderFields.cs | 44 +- .../WebMessage/ResponseBadRequest.cs | 2 +- .../WebMessage/ResponseHeaderFields.cs | 12 + .../WebMessage/ResponsePayloadTooLarge.cs | 40 + .../WebMessage/ResponseUpgradeRequired.cs | 38 + .../WebSocket/ISocketContext.cs | 40 + .../WebSocket/ISocketManager.cs | 103 +++ .../WebSocket/ISocketMessage.cs | 74 ++ .../WebSocket/ISocketReadStream.cs | 63 ++ .../WebSocket/ISocketWriteStream.cs | 30 + src/WebExpress.WebCore/WebSocket/ISockt.cs | 50 ++ .../WebSocket/Model/SocketDictionary.cs | 314 ++++++++ .../WebSocket/Model/SocketItem.cs | 108 +++ .../WebSocket/SocketContext.cs | 99 +++ .../WebSocket/SocketHandshakeException.cs | 23 + .../WebSocket/SocketManager.cs | 699 ++++++++++++++++++ .../WebSocket/SocketMessageBinary.cs | 86 +++ .../WebSocket/SocketMessageText.cs | 87 +++ .../SocketMessageTooLargeException.cs | 64 ++ .../WebSocket/SocketReadStream.cs | 127 ++++ .../WebSocket/SocketReadStreamExtensions.cs | 161 ++++ .../WebSocket/SocketWriteStream.cs | 88 +++ .../WebSocket/SocketWriteStreamExtensions.cs | 42 ++ 40 files changed, 3026 insertions(+), 136 deletions(-) rename src/WebExpress.WebCore.Test/Manager/{UnitTestApplication.cs => UnitTestApplicationManager.cs} (97%) create mode 100644 src/WebExpress.WebCore.Test/Manager/UnitTestSocketManager.cs create mode 100644 src/WebExpress.WebCore.Test/TestSocketA.cs create mode 100644 src/WebExpress.WebCore/WebAttribute/ISocketAttribute.cs create mode 100644 src/WebExpress.WebCore/WebAttribute/MaxMessageSizeAttribute.cs create mode 100644 src/WebExpress.WebCore/WebAttribute/MessageTypeAttribute.cs create mode 100644 src/WebExpress.WebCore/WebAttribute/SubProtocolAttribute.cs create mode 100644 src/WebExpress.WebCore/WebExpress.WebCore.sln create mode 100644 src/WebExpress.WebCore/WebMessage/ResponsePayloadTooLarge.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseUpgradeRequired.cs create mode 100644 src/WebExpress.WebCore/WebSocket/ISocketContext.cs create mode 100644 src/WebExpress.WebCore/WebSocket/ISocketManager.cs create mode 100644 src/WebExpress.WebCore/WebSocket/ISocketMessage.cs create mode 100644 src/WebExpress.WebCore/WebSocket/ISocketReadStream.cs create mode 100644 src/WebExpress.WebCore/WebSocket/ISocketWriteStream.cs create mode 100644 src/WebExpress.WebCore/WebSocket/ISockt.cs create mode 100644 src/WebExpress.WebCore/WebSocket/Model/SocketDictionary.cs create mode 100644 src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs create mode 100644 src/WebExpress.WebCore/WebSocket/SocketContext.cs create mode 100644 src/WebExpress.WebCore/WebSocket/SocketHandshakeException.cs create mode 100644 src/WebExpress.WebCore/WebSocket/SocketManager.cs create mode 100644 src/WebExpress.WebCore/WebSocket/SocketMessageBinary.cs create mode 100644 src/WebExpress.WebCore/WebSocket/SocketMessageText.cs create mode 100644 src/WebExpress.WebCore/WebSocket/SocketMessageTooLargeException.cs create mode 100644 src/WebExpress.WebCore/WebSocket/SocketReadStream.cs create mode 100644 src/WebExpress.WebCore/WebSocket/SocketReadStreamExtensions.cs create mode 100644 src/WebExpress.WebCore/WebSocket/SocketWriteStream.cs create mode 100644 src/WebExpress.WebCore/WebSocket/SocketWriteStreamExtensions.cs diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestApplication.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestApplicationManager.cs similarity index 97% rename from src/WebExpress.WebCore.Test/Manager/UnitTestApplication.cs rename to src/WebExpress.WebCore.Test/Manager/UnitTestApplicationManager.cs index c9f5d1d..ec0b2c2 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestApplication.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestApplicationManager.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebCore.Test.Manager /// Test the application manager. /// [Collection("NonParallelTests")] - public class UnitTestApplication + public class UnitTestApplicationManager { /// /// Test the register function of the application manager. @@ -24,6 +24,7 @@ public void Register() // test execution pluginManager.Register(); + // validation Assert.Equal(3, componentHub.ApplicationManager.Applications.Count()); Assert.Equal("webexpress.webcore.test.testapplicationa", componentHub.ApplicationManager.GetApplications(typeof(TestApplicationA)).FirstOrDefault()?.ApplicationId); Assert.Equal("webexpress.webcore.test.testapplicationb", componentHub.ApplicationManager.GetApplications(typeof(TestApplicationB)).FirstOrDefault()?.ApplicationId); @@ -44,11 +45,12 @@ public void Remove() // test execution applicationManager.Remove(plugin); - Assert.Empty(componentHub.ApplicationManager.Applications); + // validation + Assert.Empty(applicationManager.Applications); } /// - /// Test the name property of the application. + /// Test the id property of the application. /// [Theory] [InlineData(typeof(TestApplicationA), "webexpress.webcore.test.testapplicationa")] diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs index 0f2a35c..bd036a4 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs @@ -19,7 +19,7 @@ public class UnitTestSitemapManager /// Test the refresh function of the sitemap manager. /// [Theory] - [InlineData(103)] + [InlineData(106)] public void Refresh(int expected) { // preconditions diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSocketManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSocketManager.cs new file mode 100644 index 0000000..e704689 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSocketManager.cs @@ -0,0 +1,119 @@ +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebPlugin; +using WebExpress.WebCore.WebSocket; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Test the websocket manager. + /// + [Collection("NonParallelTests")] + public class UnitTestSocketManager + { + /// + /// Test the register function of the socket manager. + /// + [Fact] + public void Register() + { + // preconditions + var componentHub = UnitTestFixture.CreateComponentHubMock(); + var pluginManager = componentHub.PluginManager as PluginManager; + var socketManager = componentHub.SocketManager as SocketManager; + + // test execution + pluginManager.Register(); + + // validation + Assert.Equal(3, socketManager.Sockets.Count()); + Assert.Equal("webexpress.webcore.test.testsocketa", socketManager.GetSockets()?.FirstOrDefault()?.EndpointId?.ToString()); + Assert.Equal("webexpress.webcore.test.testsocketa", socketManager.GetSockets(typeof(TestSocketA))?.FirstOrDefault()?.EndpointId?.ToString()); + } + + /// + /// Test the remove function of the socket manager. + /// + [Fact] + public void Remove() + { + // preconditions + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var socketManager = componentHub.SocketManager as SocketManager; + var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); + + // test execution + socketManager.Remove(plugin); + + // validation + Assert.Empty(socketManager.Sockets); + } + + /// + /// Test the id property of the socket. + /// + [Theory] + [InlineData(typeof(TestApplicationA), "webexpress.webcore.test.testsocketa")] + [InlineData(typeof(TestApplicationB), "webexpress.webcore.test.testsocketa")] + [InlineData(typeof(TestApplicationC), "webexpress.webcore.test.testsocketa")] + public void Id(Type applicationType, string id) + { + // preconditions + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var applicationContext = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); + var socket = componentHub.SocketManager.GetSockets(applicationContext) + .FirstOrDefault(); + + // test execution + Assert.Equal(id, socket.EndpointId?.ToString()); + } + + /// + /// Test the context path property of the socket. + /// + [Theory] + [InlineData(typeof(TestApplicationA), "/server/appa/testsocketa")] + [InlineData(typeof(TestApplicationB), "/server/appb/testsocketa")] + [InlineData(typeof(TestApplicationC), "/server/testsocketa")] + public void ContextPath(Type applicationType, string contextPath) + { + // preconditions + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var applicationContext = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); + var socket = componentHub.SocketManager.GetSockets(applicationContext) + .FirstOrDefault(); + + // test execution + Assert.Equal(contextPath, socket.Route.ToString()); + } + + /// + /// Tests whether the socket manager implements interface IComponentManager. + /// + [Fact] + public void IsIComponentManager() + { + // preconditions + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + + // test execution + Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.SocketManager.GetType())); + } + + /// + /// Tests whether the application context implements interface IContext. + /// + [Fact] + public void IsIContext() + { + // preconditions + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + + // test execution + foreach (var application in componentHub.SocketManager.Sockets) + { + Assert.True(typeof(IContext).IsAssignableFrom(application.GetType()), $"Socket context {application.GetType().Name} does not implement IContext."); + } + } + } +} diff --git a/src/WebExpress.WebCore.Test/TestSocketA.cs b/src/WebExpress.WebCore.Test/TestSocketA.cs new file mode 100644 index 0000000..cf046ad --- /dev/null +++ b/src/WebExpress.WebCore.Test/TestSocketA.cs @@ -0,0 +1,104 @@ +using System.Net.WebSockets; +using WebExpress.WebCore.WebSocket; + +namespace WebExpress.WebCore.Test +{ + /// + /// A dummy web socket for testing purposes. + /// + public sealed class TestSocketA : ISocket + { + private readonly ISocketContext _socketContext; + private readonly ISocketWriteStream _stream; + + /// + /// Initializes a new instance of the TestSocketA class using the specified + /// socket context and write stream. + /// + /// + /// The socket context that manages the state and configuration for the socket + /// connection. + /// + /// + /// The write stream used to send data through the socket. Cannot be null. + /// + public TestSocketA(ISocketContext socketContext, ISocketWriteStream stream) + { + _socketContext = socketContext ?? throw new ArgumentNullException(nameof(stream), "Parameter cannot be null or empty."); + _stream = stream ?? throw new ArgumentNullException(nameof(stream), "Parameter cannot be null or empty."); + } + + /// + /// Handles logic to be executed when a new connection is established with the + /// socket server. + /// + /// + /// An optional message containing information about the connection request. May be + /// null if no message is provided. + /// + /// + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// + /// A task that represents the asynchronous operation. + /// + public async Task OnConnectedAsync(ISocketMessage connectMessage = null, CancellationToken cancellationToken = default) + { + } + + /// + /// Handles an incoming socket message asynchronously. + /// + /// + /// The message received from the socket to be processed. Cannot be null. + /// + /// + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// + /// A task that represents the asynchronous message handling operation. + /// + public async Task OnReceiveAsync(ISocketMessage message, CancellationToken cancellationToken = default) + { + } + + /// + /// Handles logic to be executed when the WebSocket connection is closed. + /// + /// + /// The status code indicating the reason for the WebSocket closure. + /// + /// + /// A description providing additional details about the reason for closure. May be + /// null or empty. + /// + /// + /// A task that represents the asynchronous operation. + /// + public async Task OnDisconnectedAsync(WebSocketCloseStatus closeStatus, string closeDescription) + { + throw new NotImplementedException(); + } + + /// + /// Handles an error that has occurred during asynchronous processing. + /// + /// + /// The exception that represents the error to handle. Cannot be null. + /// + /// + /// A task that represents the asynchronous error handling operation. + /// + public async Task OnErrorAsync(Exception exception) + { + throw new NotImplementedException(); + } + + /// + /// Releases all resources used by the current instance of the class. + /// + public void Dispose() + { + } + } +} diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index 546460e..a919f9b 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; @@ -7,12 +7,18 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; +using System.Buffers; +using System.Collections.Generic; using System.Diagnostics; using System.Globalization; +using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; +using System.Net.WebSockets; using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using WebExpress.WebCore.Config; @@ -23,6 +29,8 @@ using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebSitemap; +using WebExpress.WebCore.WebSocket; +using WebExpress.WebCore.WebStatusPage; using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore @@ -45,10 +53,10 @@ public class HttpServer : IHost, IHttpApplication /// /// Server thread termination. /// - private CancellationToken ServerToken { get; } = new CancellationToken(); + private CancellationTokenSource ServerTokenSource { get; } = new CancellationTokenSource(); /// - /// Returns or sets the configuration + /// Returns or sets the configuration. /// public HttpServerConfig Config { get; set; } @@ -75,7 +83,7 @@ public class HttpServer : IHost, IHttpApplication /// /// Initializes a new instance of the class. /// - /// Der Serverkontext. + /// The server context. public HttpServer(HttpServerContext context) { HttpServerContext = new HttpServerContext @@ -99,7 +107,7 @@ public HttpServer(HttpServerContext context) /// public void Start() { - if (HttpServerContext is not null && HttpServerContext.Log is not null) + if (HttpServerContext != null && HttpServerContext.Log != null) { HttpServerContext.Log.Info(message: I18N.Translate("webexpress.webcore:httpserver.run")); } @@ -131,7 +139,6 @@ public void Start() AllowResponseHeaderCompression = true, AddServerHeader = true, ApplicationServices = serviceCollection.BuildServiceProvider() - }); serverOptions.Value.Limits.MaxConcurrentConnections = Config?.Limit?.ConnectionLimit > 0 ? Config?.Limit?.ConnectionLimit : serverOptions.Value.Limits.MaxConcurrentConnections; @@ -143,10 +150,9 @@ public void Start() } Kestrel = new KestrelServer(serverOptions, transport, logger); + Kestrel.StartAsync(this, ServerTokenSource.Token); - Kestrel.StartAsync(this, ServerToken); - - HttpServerContext.Log.Info(message: I18N.Translate("webexpress.webcore:httpserver.start"), args: [ExecutionTime.ToShortDateString(), ExecutionTime.ToLongTimeString()]); + HttpServerContext.Log.Info(message: I18N.Translate("webexpress.webcore:httpserver.start"), args: new object[] { ExecutionTime.ToShortDateString(), ExecutionTime.ToLongTimeString() }); Started?.Invoke(this, new EventArgs()); } @@ -166,7 +172,7 @@ private void AddEndpoint(OptionsWrapper serverOptions, End var port = uri.Port; var host = asterisk ? Dns.GetHostEntry(Dns.GetHostName()) : Dns.GetHostEntry(uri.Host); var addressList = host.AddressList - .Union(asterisk ? Dns.GetHostEntry("localhost").AddressList : []) + .Union(asterisk ? Dns.GetHostEntry("localhost").AddressList : Array.Empty()) .Where(x => x.AddressFamily == AddressFamily.InterNetwork || x.AddressFamily == AddressFamily.InterNetworkV6); HttpServerContext.Log.Info(message: I18N.Translate("webexpress.webcore:httpserver.endpoint"), args: endPoint.Uri); @@ -177,10 +183,17 @@ private void AddEndpoint(OptionsWrapper serverOptions, End switch (uri.Scheme) { - case "HTTPS": { AddEndpoint(serverOptions, ep, endPoint.PfxFile, endPoint.Password); break; } - default: { AddEndpoint(serverOptions, ep); break; } + case "HTTPS": + { + AddEndpoint(serverOptions, ep, endPoint.PfxFile, endPoint.Password); + break; + } + default: + { + AddEndpoint(serverOptions, ep); + break; + } } - } } catch (Exception ex) @@ -198,7 +211,6 @@ private void AddEndpoint(OptionsWrapper serverOptions, End private void AddEndpoint(OptionsWrapper serverOptions, IPEndPoint endPoint) { serverOptions.Value.Listen(endPoint); - HttpServerContext.Log.Info(message: I18N.Translate("webexpress.webcore:httpserver.listen"), args: endPoint.ToString()); } @@ -214,7 +226,6 @@ private void AddEndpoint(OptionsWrapper serverOptions, IPE serverOptions.Value.Listen(endPoint, configure => { var cert = X509CertificateLoader.LoadPkcs12FromFile(pfxFile, password, X509KeyStorageFlags.DefaultKeySet); - configure.UseHttps(cert); }); @@ -222,27 +233,26 @@ private void AddEndpoint(OptionsWrapper serverOptions, IPE } /// - /// Stops the HTTP(S) server + /// Stops the HTTP(S) server. /// public void Stop() { - // End running threads - Kestrel.StopAsync(ServerToken); + // signal cancellation and stop server + ServerTokenSource.Cancel(); + Kestrel.StopAsync(ServerTokenSource.Token); } /// - /// Handles an incoming request - /// Concurrent execution + /// Handles an incoming request. /// /// The context of the web request. + /// The previously resolved search result for the request. /// The response to be sent back to the caller. - private Response HandleClient(HttpContext context) + private Response HandleClient(HttpContext context, SearchResult searchResult) { var stopwatch = Stopwatch.StartNew(); var request = context.Request; var response = default(Response); - var culture = request.Culture; - var uri = request?.Uri; HttpServerContext.Log.Debug(message: I18N.Translate("webexpress.webcore:httpserver.connected"), args: context.RemoteEndPoint); HttpServerContext.Log.Info(I18N.Translate @@ -253,105 +263,89 @@ private Response HandleClient(HttpContext context) $"{request?.Method} {request?.Uri} {request?.Protocoll}" )); - // search page in sitemap - var searchResult = WebEx.ComponentHub.SitemapManager.SearchResource(context.Uri, new SearchContext() - { - Culture = culture, - HttpContext = context, - HttpServerContext = HttpServerContext - }); + var resourceUri = new UriEndpoint(request.Uri, searchResult.Uri.PathSegments); + request.Uri = resourceUri; - if (searchResult is not null) + try { - var resourceUri = new UriEndpoint(request.Uri, searchResult.Uri.PathSegments); - request.Uri = resourceUri; + // execute resource + request.AddParameter(searchResult.Uri.Parameters.Select(x => new Parameter(x.Key, x.Value, ParameterScope.Url))); - try + if (searchResult.EndpointContext != null) { - // execute resource - request.AddParameter(searchResult.Uri.Parameters.Select(x => new Parameter(x.Key, x.Value, ParameterScope.Url))); + response = WebEx.ComponentHub.EndpointManager.HandleRequest(request, searchResult.EndpointContext); - if (searchResult.EndpointContext is not null) + if (response is ResponseNotFound) { - response = WebEx.ComponentHub.EndpointManager.HandleRequest(request, searchResult.EndpointContext); - - if (response is ResponseNotFound) - { - response = CreateStatusPage - ( - string.Empty, - request, - searchResult - ); - } - - if - ( - !response.Header.Cookies.Where(x => x.Name.Equals("session")).Any() && - !request.Header.Cookies.Where(x => x.Name.Equals("session")).Any() && - request.Session is not null - ) - { - var cookie = new Cookie("session", request.Session.Id.ToString()) { Expires = DateTime.MaxValue }; - response.Header.Cookies.Add(cookie); - } - } - else - { - // Resource not found response = CreateStatusPage ( - "Resource not found", + string.Empty, request, searchResult ); } - } - catch (RedirectException ex) - { - if (ex.Permanet) - { - response = new ResponseMovedPermanently(ex.Uri); - } - else + + if + ( + !response.Header.Cookies.Where(x => x.Name.Equals("session")).Any() && + !request.Header.Cookies.Where(x => x.Name.Equals("session")).Any() && + request.Session != null + ) { - response = new ResponseMovedTemporarily(ex.Uri); + var cookie = new Cookie("session", request.Session.Id.ToString()) { Expires = DateTime.MaxValue }; + response.Header.Cookies.Add(cookie); } } - catch (BadRequestException ex) + else { - var message = $"

Message

{ex.Message}

" + - $"
Source
{ex.Source}

" + - $"
StackTrace
{ex.StackTrace.Replace("\n", "
\n")}"; - - response = CreateStatusPage + // resource not found + response = CreateStatusPage ( - message, + "Resource not found", request, searchResult ); } - catch (Exception ex) + } + catch (RedirectException ex) + { + if (ex.Permanet) { - HttpServerContext.Log.Exception(ex); - - var message = $"

Message

{ex.Message}

" + - $"
Source
{ex.Source}

" + - $"
StackTrace
{ex.StackTrace.Replace("\n", "
\n")}

" + - $"
InnerException
{ex.InnerException?.ToString().Replace("\n", "
\n")}"; - - response = CreateStatusPage - ( - message, - request, - searchResult - ); + response = new ResponseMovedPermanently(ex.Uri); + } + else + { + response = new ResponseMovedTemporarily(ex.Uri); } } - else + catch (BadRequestException ex) { - // Resource not found - response = CreateStatusPage("Resource not found", request); + var message = $"

Message

{ex.Message}

" + + $"
Source
{ex.Source}

" + + $"
StackTrace
{ex.StackTrace.Replace("\n", "
\n")}"; + + response = CreateStatusPage + ( + message, + request, + searchResult + ); + } + catch (Exception ex) + { + HttpServerContext.Log.Exception(ex); + + var message = $"

Message

{ex.Message}

" + + $"
Source
{ex.Source}

" + + $"
StackTrace
{ex.StackTrace.Replace("\n", "
\n")}

" + + $"
InnerException
{ex.InnerException?.ToString().Replace("\n", "
\n")}"; + + response = CreateStatusPage + ( + message, + request, + searchResult + ); } stopwatch.Stop(); @@ -369,10 +363,10 @@ request.Session is not null } /// - /// Sends the response message + /// Sends the response message. /// - /// The context of the request - /// The reply message + /// The context of the request. + /// The reply message. /// Sending the message as a task, which is executed concurrently. private async Task SendResponseAsync(HttpContext context, Response response) { @@ -385,7 +379,7 @@ private async Task SendResponseAsync(HttpContext context, Response response) responseFeature.ReasonPhrase = response.Reason; responseFeature.Headers.KeepAlive = "true"; - if (response.Header.Location is not null) + if (response.Header.Location != null) { responseFeature.Headers.Location = response.Header.Location; } @@ -442,7 +436,7 @@ private async Task SendResponseAsync(HttpContext context, Response response) } /// - /// Creates a status page + /// Creates a status page. /// /// The error message. /// The request. @@ -453,12 +447,12 @@ private async Task SendResponseAsync(HttpContext context, Response response) var response = new T() as Response; var statusPageManager = WebEx.ComponentHub.StatusPageManager; var applicationManager = WebEx.ComponentHub.ApplicationManager; - var route = new RouteEndpoint([.. request.Uri.PathSegments])?.ToString(); + var route = new RouteEndpoint(request.Uri.PathSegments)?.ToString(); var applicationContext = applicationManager.Applications .Where(x => route.StartsWith(x.Route.ToString())) .FirstOrDefault(); - if (searchResult is not null) + if (searchResult != null) { return statusPageManager.CreateStatusResponse ( @@ -469,7 +463,7 @@ private async Task SendResponseAsync(HttpContext context, Response response) ); } - if (applicationContext is not null) + if (applicationContext != null) { return statusPageManager.CreateStatusResponse ( @@ -508,14 +502,66 @@ public HttpContext CreateContext(IFeatureCollection contextFeatures) } } + /// + /// Determines whether the incoming context represents a websocket upgrade request. + /// + /// The incoming http context. + /// True when the request is a websocket upgrade request, false otherwise. + private static bool IsWebSocketRequest(HttpContext httpContext) + { + var wsFeature = httpContext.Features.Get(); + if (wsFeature != null) + { + return true; + } + + try + { + var upgrade = httpContext.Request?.Header?.Upgrade; + if (!string.IsNullOrWhiteSpace(upgrade) && upgrade.Equals("websocket", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + catch + { + // ignore + } + + return false; + } + + /// + /// Determines whether the provided searchResult represents a websocket endpoint by checking for IWebSocketContext. + /// + /// The sitemap search result. + /// True when the search result indicates a websocket endpoint. + private static bool IsWebSocketSearchResult(SearchResult searchResult) + { + if (searchResult == null) + { + return false; + } + + // endpoint context is websocket context + if (searchResult.EndpointContext is ISocketContext) + { + return true; + } + + return false; + } + /// /// Processes an http context asynchronously. + /// If the request is a websocket upgrade to a configured endpoint, handle websocket lifecycle instead of request/response. + /// Handles missing sitemap endpoints directly here. /// - /// The http context that the operation processes. + /// The http context that the operation processes. /// Provides an asynchronous operation that handles the http context. - public async Task ProcessRequestAsync(HttpContext context) + public async Task ProcessRequestAsync(HttpContext httpContext) { - if (context is HttpExceptionContext exceptionContext) + if (httpContext is HttpExceptionContext exceptionContext) { var message = "404" + $"

Message

{exceptionContext.Exception.Message}

" + @@ -524,16 +570,101 @@ public async Task ProcessRequestAsync(HttpContext context) $"
InnerException
{exceptionContext.Exception.InnerException?.ToString().Replace("\n", "
\n")}" + ""; - var response500 = CreateStatusPage(message, context?.Request); + var response500 = CreateStatusPage(message, httpContext?.Request); await SendResponseAsync(exceptionContext, response500); return; } - var response = HandleClient(context); + var culture = httpContext.Request.Culture; + var searchResult = WebEx.ComponentHub.SitemapManager.SearchResource(httpContext.Uri, new SearchContext() + { + Culture = culture, + HttpContext = httpContext, + HttpServerContext = HttpServerContext + }); + + if (searchResult == null) + { + var notFoundResponse = CreateStatusPage("Resource not found", httpContext.Request); + await SendResponseAsync(httpContext, notFoundResponse); + return; + } + + if (IsWebSocketSearchResult(searchResult) && IsWebSocketRequest(httpContext)) + { + // try to obtain websocket context and optional handler + var socketContext = searchResult.EndpointContext as ISocketContext; + + await HandleWebSocketAsync(httpContext, socketContext); + return; + } - await SendResponseAsync(context, response); + var response = HandleClient(httpContext, searchResult); + + await SendResponseAsync(httpContext, response); + } + + /// + /// Handles the complete WebSocket request lifecycle for the given HTTP context. + /// Validates the upgrade request, delegates the connection handling to the socket manager, + /// and returns appropriate HTTP error responses when the handshake or connection setup fails. + /// + /// + /// The current HTTP context containing the incoming WebSocket upgrade request. + /// + /// + /// Optional WebSocket endpoint context resolved from the sitemap. May be null + /// if the endpoint does not define additional metadata. + /// + public async Task HandleWebSocketAsync(HttpContext httpContext, ISocketContext socketContext) + { + var socketManager = WebEx.ComponentHub.SocketManager; + + // validate that the request is a websocket upgrade + if (!IsWebSocketRequest(httpContext)) + { + // websocket not requested by client; return 400 Bad Request + await SendResponseAsync(httpContext, new ResponseBadRequest(new StatusMessage("WebSocket upgrade required"))); + return; + } + + try + { + await socketManager.HandleConnectionAsync(httpContext, socketContext); + } + catch (SocketHandshakeException) + { + // missing or invalid websocket handshake headers -> respond with 426 + var response = new ResponseUpgradeRequired(new StatusMessage("Invalid WebSocket handshake headers")); + response.Header.Upgrade = "websocket"; + + await SendResponseAsync(httpContext, response); + } + catch (SocketMessageTooLargeException ex) + { + // the client sent a WebSocket message exceeding the configured maximum size -> respond with 413 + var response = new ResponsePayloadTooLarge(new StatusMessage($"WebSocket message exceeds the maximum allowed size of {ex.MaxSize} bytes.") ); + await SendResponseAsync(httpContext, response); + } + catch (SocketException ex) + { + HttpServerContext.Log.Exception(ex); + + // return 500 when socket error + var response = new ResponseInternalServerError(new StatusMessage("A transport-level socket error occurred during WebSocket communication.")); + await SendResponseAsync(httpContext, response); + } + catch (Exception ex) + { + // log unhandled exceptions during websocket processing + HttpServerContext.Log.Exception(ex); + + // return 500 when handshake did not succeed and no websocket established + var response = new ResponseInternalServerError(new StatusMessage("An unexpected server error occurred during WebSocket processing.")); + await SendResponseAsync(httpContext, response); + } } /// @@ -545,4 +676,4 @@ public void DisposeContext(HttpContext context, Exception exception) { } } -} +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/Internationalization/de b/src/WebExpress.WebCore/Internationalization/de index febb19d..cb7a647 100644 --- a/src/WebExpress.WebCore/Internationalization/de +++ b/src/WebExpress.WebCore/Internationalization/de @@ -170,6 +170,10 @@ includemanager.initialization=Der Includemanager wurde initialisiert. includemanager.addinclude=Die Client-Ressource '{0}' wurde in der Anwendung '{1}' registiert. includemanager.removeinclude=Die Client-Ressource '{0}' wurde aus der Anwendung '{1}' entfernt. +socketmanager.initialization=Der Socketmanager wurde initialisiert. +socketmanager.addsocket=Der WebSocket '{0}' wurde in der Anwendung '{1}' registiert. +socketmanager.removesocket=Der WebSocket '{0}' wurde aus der Anwendung '{1}' entfernt. + thememanager.initialization=Der Thememanager wurde initialisiert. thememanager.addtheme=Das Theme '{0}' wurde in der Anwendung '{1}' registiert. thememanager.titel=Designvorlagen: diff --git a/src/WebExpress.WebCore/Internationalization/en b/src/WebExpress.WebCore/Internationalization/en index 2308ed3..155cdbf 100644 --- a/src/WebExpress.WebCore/Internationalization/en +++ b/src/WebExpress.WebCore/Internationalization/en @@ -170,6 +170,10 @@ includemanager.initialization=The include manager has been initialized. includemanager.addinclude=The client resource '{0}' has been registered in the application '{1}'. includemanager.removeinclude=The client resource '{0}' has been removed from the application '{1}'. +socketmanager.initialization=The WebSocket manager has been initialized. +socketmanager.addsocket=The WebSocket '{0}' has been registered in the application '{1}'. +socketmanager.removesocket=The WebSocket '{0}' has been removed from the application '{1}'. + thememanager.initialization=The theme manager has been initialized. thememanager.addtheme=The theme '{0}' has been registered in the application '{1}'. thememanager.titel=Themes: diff --git a/src/WebExpress.WebCore/WebAttribute/ISocketAttribute.cs b/src/WebExpress.WebCore/WebAttribute/ISocketAttribute.cs new file mode 100644 index 0000000..08b82b3 --- /dev/null +++ b/src/WebExpress.WebCore/WebAttribute/ISocketAttribute.cs @@ -0,0 +1,11 @@ +using System; + +namespace WebExpress.WebCore.WebAttribute +{ + /// + /// Interface of a socket assignment attribute. + /// + public interface ISocketAttribute + { + } +} diff --git a/src/WebExpress.WebCore/WebAttribute/MaxMessageSizeAttribute.cs b/src/WebExpress.WebCore/WebAttribute/MaxMessageSizeAttribute.cs new file mode 100644 index 0000000..68696f4 --- /dev/null +++ b/src/WebExpress.WebCore/WebAttribute/MaxMessageSizeAttribute.cs @@ -0,0 +1,29 @@ +using System; + +namespace WebExpress.WebCore.WebAttribute +{ + /// + /// Specifies the maximum allowed message size for WebSocket messages + /// processed by the decorated endpoint. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class MaxMessageSizeAttribute : Attribute, ISocketAttribute + { + /// + /// Gets the maximum allowed message size in bytes. + /// + public ulong MaxMessageSize { get; } + + /// + /// Initializes a new instance of the class + /// with the specified maximum message size. + /// + /// + /// The maximum allowed message size in bytes. + /// + public MaxMessageSizeAttribute(ulong maxMessageSize) + { + MaxMessageSize = maxMessageSize; + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebAttribute/MessageTypeAttribute.cs b/src/WebExpress.WebCore/WebAttribute/MessageTypeAttribute.cs new file mode 100644 index 0000000..02d624e --- /dev/null +++ b/src/WebExpress.WebCore/WebAttribute/MessageTypeAttribute.cs @@ -0,0 +1,26 @@ +using System; +using System.Net.WebSockets; + +namespace WebExpress.WebCore.WebAttribute +{ + /// + /// Specifies the status code for an HTTP response (see RFC 7231). + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class MessageTypeAttribute : Attribute, ISocketAttribute + { + /// + /// Returns the message type code. + /// + public WebSocketMessageType MessageType { get; } + + /// + /// Initializes a new instance of the class with the specified status code. + /// + /// The message type. + public MessageTypeAttribute(WebSocketMessageType messageType) + { + MessageType = messageType; + } + } +} diff --git a/src/WebExpress.WebCore/WebAttribute/MethodAttribute.cs b/src/WebExpress.WebCore/WebAttribute/MethodAttribute.cs index 92cf0bb..26cf128 100644 --- a/src/WebExpress.WebCore/WebAttribute/MethodAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/MethodAttribute.cs @@ -4,21 +4,26 @@ namespace WebExpress.WebCore.WebAttribute { /// - /// The range in which the attribute is valid. + /// Specifies the HTTP method or CRUD operation that an endpoint method + /// is intended to handle. This attribute can be applied multiple times + /// to the same method to declare support for multiple request methods. /// - [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] public class MethodAttribute : Attribute, IEndpointAttribute { /// - /// Returns the CRUD (Create, Read, Update, Delete) operation type - /// associated with the current request. + /// Returns the CRUD (Create, Read, Update, Delete) operation or request + /// method associated with the decorated endpoint method. /// public RequestMethod RequestMethod { get; private set; } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class + /// with the specified request method. /// - /// The request method. + /// + /// The request method that the endpoint method should handle. + /// public MethodAttribute(RequestMethod requestMethod) { RequestMethod = requestMethod; diff --git a/src/WebExpress.WebCore/WebAttribute/SubProtocolAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SubProtocolAttribute.cs new file mode 100644 index 0000000..51e56de --- /dev/null +++ b/src/WebExpress.WebCore/WebAttribute/SubProtocolAttribute.cs @@ -0,0 +1,26 @@ +using System; +using System.Net.WebSockets; + +namespace WebExpress.WebCore.WebAttribute +{ + /// + /// Specifies the sub protocole for an WebSocket (see RFC 6455). + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class SubProtocolAttribute : Attribute, ISocketAttribute + { + /// + /// Returns the sub protocol. + /// + public string SubProtocol { get; } + + /// + /// Initializes a new instance of the class with the specified sub protocol. + /// + /// The sub protocol. + public SubProtocolAttribute(string subProtocol) + { + SubProtocol = subProtocol; + } + } +} diff --git a/src/WebExpress.WebCore/WebComponent/ComponentHub.cs b/src/WebExpress.WebCore/WebComponent/ComponentHub.cs index 8708788..e455777 100644 --- a/src/WebExpress.WebCore/WebComponent/ComponentHub.cs +++ b/src/WebExpress.WebCore/WebComponent/ComponentHub.cs @@ -20,6 +20,7 @@ using WebExpress.WebCore.WebSession; using WebExpress.WebCore.WebSettingPage; using WebExpress.WebCore.WebSitemap; +using WebExpress.WebCore.WebSocket; using WebExpress.WebCore.WebStatusPage; using WebExpress.WebCore.WebTask; using WebExpress.WebCore.WebTheme; @@ -53,6 +54,7 @@ public class ComponentHub : IComponentHub private readonly JobManager _jobManager; private readonly TaskManager _taskManager; private readonly IdentityManager _identityManager; + private readonly SocketManager _socketManager; private readonly ThemeManager _themeManager; private int _lastCounter = 0; @@ -91,6 +93,7 @@ public class ComponentHub : IComponentHub _identityManager, _sessionManager, _taskManager, + _socketManager, _themeManager }.Concat(_dictionary.Values.SelectMany(x => x).Select(x => x.ComponentInstance)); @@ -214,6 +217,12 @@ public class ComponentHub : IComponentHub /// The instance of the session manager. public ISessionManager SessionManager => _sessionManager; + /// + /// Returns the socket manager. + /// + /// The instance of the socket manager. + public ISocketManager SocketManager => _socketManager; + /// /// Returns the theme manager. /// @@ -250,6 +259,7 @@ protected ComponentHub(IHttpServerContext httpServerContext) _sessionManager = CreateInstance(typeof(SessionManager)) as SessionManager; _taskManager = CreateInstance(typeof(TaskManager)) as TaskManager; _identityManager = CreateInstance(typeof(IdentityManager)) as IdentityManager; + _socketManager = CreateInstance(typeof(SocketManager)) as SocketManager; _themeManager = CreateInstance(typeof(ThemeManager)) as ThemeManager; _internationalizationManager.Register(typeof(HttpServer).Assembly, typeof(HttpServer).Assembly.GetName().Name?.ToLower()); diff --git a/src/WebExpress.WebCore/WebComponent/IComponentHub.cs b/src/WebExpress.WebCore/WebComponent/IComponentHub.cs index c5bbaef..6ac352d 100644 --- a/src/WebExpress.WebCore/WebComponent/IComponentHub.cs +++ b/src/WebExpress.WebCore/WebComponent/IComponentHub.cs @@ -18,6 +18,7 @@ using WebExpress.WebCore.WebSession; using WebExpress.WebCore.WebSettingPage; using WebExpress.WebCore.WebSitemap; +using WebExpress.WebCore.WebSocket; using WebExpress.WebCore.WebStatusPage; using WebExpress.WebCore.WebTask; using WebExpress.WebCore.WebTheme; @@ -164,6 +165,12 @@ public interface IComponentHub : IComponentManager /// The instance of the session manager. ISessionManager SessionManager { get; } + /// + /// Returns the socket manager. + /// + /// The instance of the socket manager. + ISocketManager SocketManager { get; } + /// /// Returns the theme manager. /// diff --git a/src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs b/src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs index a1ae1f1..57277c7 100644 --- a/src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs +++ b/src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs @@ -86,6 +86,19 @@ public RouteEndpoint(params IUriPathSegment[] segments) } } + /// + /// Initializes a new instance of the class. + /// + /// The path segments. + public RouteEndpoint(IEnumerable segments) + { + if (segments.Any()) + { + PathSegments = PathSegments + .Concat(segments.Where(x => !x.IsEmpty).Select(x => x.Copy())); + } + } + /// /// Initializes a new instance of the class. /// diff --git a/src/WebExpress.WebCore/WebExpress.WebCore.sln b/src/WebExpress.WebCore/WebExpress.WebCore.sln new file mode 100644 index 0000000..00a922b --- /dev/null +++ b/src/WebExpress.WebCore/WebExpress.WebCore.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebExpress.WebCore", "WebExpress.WebCore.csproj", "{01E99C8B-0B21-46C0-93A9-6A8C2E89B619}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {01E99C8B-0B21-46C0-93A9-6A8C2E89B619}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {01E99C8B-0B21-46C0-93A9-6A8C2E89B619}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01E99C8B-0B21-46C0-93A9-6A8C2E89B619}.Release|Any CPU.ActiveCfg = Release|Any CPU + {01E99C8B-0B21-46C0-93A9-6A8C2E89B619}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {FD0FF9EA-12BF-4F03-B62F-F720D51C23DB} + EndGlobalSection +EndGlobal diff --git a/src/WebExpress.WebCore/WebInclude/IncludeManager.cs b/src/WebExpress.WebCore/WebInclude/IncludeManager.cs index af622c4..9f5067c 100644 --- a/src/WebExpress.WebCore/WebInclude/IncludeManager.cs +++ b/src/WebExpress.WebCore/WebInclude/IncludeManager.cs @@ -250,6 +250,14 @@ internal void Remove(IApplicationContext applicationContext) { OnRemoveInclude(includeItem.IncludeContext); includeItem.Dispose(); + + _httpServerContext?.Log.Debug( + I18N.Translate( + "webexpress.webcore:includemanager.removeinclude", + includeItem.IncludeId, + includeItem.ApplicationContext.ApplicationId + ) + ); } } diff --git a/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs b/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs index 7c1f998..26bb453 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs @@ -79,6 +79,26 @@ public class RequestHeaderFields /// public string Referer { get; private set; } + /// + /// Returns the upgrade header (e.g. "websocket" for WebSocket upgrades). + /// + public string Upgrade { get; private set; } + + /// + /// Returns the Sec-WebSocket-Key header value if present. + /// + public string SecWebSocketKey { get; private set; } + + /// + /// Returns the Sec-WebSocket-Protocol header value if present. + /// + public string SecWebSocketProtocol { get; private set; } + + /// + /// Returns the Sec-WebSocket-Version header value if present. + /// + public string SecWebSocketVersion { get; private set; } + /// /// Initializes a new instance of the class. /// @@ -98,19 +118,17 @@ internal RequestHeaderFields(IFeatureCollection contextFeatures) AcceptLanguage = requestFeature.Headers.AcceptLanguage.SelectMany(x => x.Split(';', StringSplitOptions.RemoveEmptyEntries)); UserAgent = requestFeature.Headers.UserAgent; Referer = requestFeature.Headers.Referer; - - var cookies = new List(); - - foreach (var cookie in requestFeature.Headers.Cookie) - { - var split = cookie.Split('='); - var key = split[0]; - var value = split[1]; - - cookies.Add(new Cookie(key, value)); - } - - Cookies = cookies; + Upgrade = requestFeature.Headers.Upgrade; + SecWebSocketKey = requestFeature.Headers.SecWebSocketKey; + SecWebSocketProtocol = requestFeature.Headers.SecWebSocketProtocol; + SecWebSocketVersion = requestFeature.Headers.SecWebSocketVersion; + + Cookies = requestFeature.Headers.Cookie + .Select(c => + { + var split = c.Split('='); + return new Cookie(split[0], split[1]); + }); Authorization = RequestAuthorization.Parse(requestFeature.Headers.Authorization); } diff --git a/src/WebExpress.WebCore/WebMessage/ResponseBadRequest.cs b/src/WebExpress.WebCore/WebMessage/ResponseBadRequest.cs index 1032f9e..b3a4c61 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseBadRequest.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseBadRequest.cs @@ -23,7 +23,7 @@ public ResponseBadRequest() /// The user defined status message or null. public ResponseBadRequest(StatusMessage message) { - var content = message?.Message ?? "404404 - Bad Request"; + var content = message?.Message ?? "400400 - Bad Request"; Reason = "Bad Request"; Header.ContentType = "text/html"; diff --git a/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs b/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs index 303ff8f..84553c0 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs @@ -54,11 +54,17 @@ public class ResponseHeaderFields ///
public CookieCollection Cookies { get; } = []; + /// + /// Returns or sets the Upgrade header (for protocol upgrade responses, e.g. "websocket"). + /// + public string Upgrade { get; set; } + /// /// Initializes a new instance of the class. /// public ResponseHeaderFields() { + // set defaults CustomHeader = new Dictionary(); WWWAuthenticate = false; ContentLength = -1; @@ -119,6 +125,12 @@ public override string ToString() sb.AppendLine("Location: " + Location); } + if (!string.IsNullOrWhiteSpace(Upgrade)) + { + sb.AppendLine("Upgrade: " + Upgrade); + sb.AppendLine("Connection: Upgrade"); + } + foreach (var c in CustomHeader) { sb.AppendLine(c.Key + ": " + c.Value); diff --git a/src/WebExpress.WebCore/WebMessage/ResponsePayloadTooLarge.cs b/src/WebExpress.WebCore/WebMessage/ResponsePayloadTooLarge.cs new file mode 100644 index 0000000..5c911b0 --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/ResponsePayloadTooLarge.cs @@ -0,0 +1,40 @@ +using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebStatusPage; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Represents an HTTP 413 (Payload Too Large) response. + /// According to RFC 7231, section 6.5.11, this status code indicates + /// that the server is refusing to process a request because its payload + /// exceeds the size limits defined by the server. + /// + [StatusCode(413)] + public class ResponsePayloadTooLarge : Response + { + /// + /// Initializes a new instance of the class. + /// + public ResponsePayloadTooLarge() + : this(null) + { + } + + /// + /// Initializes a new instance of the class + /// with an optional user-defined status message. + /// + /// The user-defined status message, or null to use a default HTML body. + public ResponsePayloadTooLarge(StatusMessage message) + { + var content = message?.Message + ?? "413 Payload Too Large413 - Payload Too Large"; + + Reason = "Payload Too Large"; + + Header.ContentType = "text/html"; + Header.ContentLength = content.Length; + Content = content; + } + } +} diff --git a/src/WebExpress.WebCore/WebMessage/ResponseUpgradeRequired.cs b/src/WebExpress.WebCore/WebMessage/ResponseUpgradeRequired.cs new file mode 100644 index 0000000..2b7c00d --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/ResponseUpgradeRequired.cs @@ -0,0 +1,38 @@ +using System.Text; +using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebStatusPage; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Represents a response for upgrade required (426) according to RFC 7231 section 6.5.15. + /// + [StatusCode(426)] + public class ResponseUpgradeRequired : Response + { + /// + /// Initializes a new instance of the class. + /// + public ResponseUpgradeRequired() + : this(null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The user defined status message or null. + public ResponseUpgradeRequired(StatusMessage message) + { + var content = message?.Message ?? "426426 - Upgrade Required"; + + // reason phrase for 426 (Upgrade Required) + Reason = "Upgrade Required"; + + Header.ContentType = "text/html"; + // set content length in bytes using UTF-8 encoding + Header.ContentLength = Encoding.UTF8.GetByteCount(content); + Content = content; + } + } +} diff --git a/src/WebExpress.WebCore/WebSocket/ISocketContext.cs b/src/WebExpress.WebCore/WebSocket/ISocketContext.cs new file mode 100644 index 0000000..69c8af1 --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/ISocketContext.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Net.WebSockets; +using WebExpress.WebCore.WebEndpoint; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// Defines the context for a WebSocket endpoint, providing access to configuration and metadata + /// that are relevant during the websocket lifecycle. + /// + public interface ISocketContext : IEndpointContext + { + /// + /// Returns the collection of supported websocket subprotocols. + /// implementations should return the subprotocol identifiers the endpoint can speak. + /// + IEnumerable SupportedSubProtocols { get; } + + /// + /// Returns the default WebSocket message type used by this endpoint when + /// sending data. Implementations may choose + /// for JSON or human-readable content, or + /// for binary payloads. + /// + WebSocketMessageType MessageType { get; } + + /// + /// Returns the maximum allowed message size in bytes, or null when the endpoint imposes no limit. + /// servers and hosts may use this to protect against excessively large frames. + /// + ulong MaxMessageSize { get; } + + /// + /// Indicates whether this websocket endpoint requires an authenticated client. + /// the host can check this and reject upgrades when authentication is missing. + /// + bool RequiresAuthentication { get; } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/ISocketManager.cs b/src/WebExpress.WebCore/WebSocket/ISocketManager.cs new file mode 100644 index 0000000..1199c37 --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/ISocketManager.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// The socket manager manages socket endpoints (see RFC 6455 – The WebSocket Protocol) which can be called with a URI. + /// + public interface ISocketManager : IComponentManager + { + /// + /// An event that fires when a websocket context is added. + /// + event EventHandler AddSocket; + + /// + /// An event that fires when a websocket context is removed. + /// + event EventHandler RemoveSocket; + + /// + /// Returns all registered websocket contexts. + /// + IEnumerable Sockets { get; } + + /// + /// Returns an enumeration of all websocket contexts provided by a plugin. + /// + /// A context of a plugin whose sockets are to be returned. + /// An enumeration of websocket contexts. + IEnumerable GetSockets(IPluginContext pluginContext); + + /// + /// Returns an enumeration of websocket contexts filtered by endpoint type. + /// + /// The websocket endpoint type. + /// An enumeration of websocket contexts. + IEnumerable GetSockets() + where T : ISocket; + + /// + /// Returns an enumeration of websocket contexts filtered by endpoint type. + /// + /// The websocket endpoint type. + /// An enumeration of websocket contexts. + IEnumerable GetSockets(Type socketType); + + /// + /// Returns an enumeration of websocket contexts filtered by endpoint type and application context. + /// + /// The websocket endpoint type. + /// The context of the application. + /// An enumeration of websocket contexts. + IEnumerable GetSockets(Type socketType, IApplicationContext applicationContext); + + /// + /// Returns an enumeration of websocket contexts filtered by endpoint type and application context. + /// + /// The websocket endpoint type. + /// The context of the application. + /// An enumeration of websocket contexts. + IEnumerable GetSockets(IApplicationContext applicationContext) where T : ISocket; + + /// + /// Returns the websocket context by application context and socket id. + /// + /// The context of the application. + /// The socket id. + /// A websocket context or null. + ISocketContext GetSocket(IApplicationContext applicationContext, string socketId); + + /// + /// Returns the websocket context by application id and socket id. + /// + /// The application id. + /// The socket id. + /// A websocket context or null. + ISocketContext GetSocket(string applicationId, string socketId); + + /// + /// Handles the complete lifecycle of a WebSocket connection for the given HTTP context. + /// Performs the server-side upgrade, invokes connection callbacks, processes incoming + /// WebSocket frames, parses messages, dispatches them to the socket handler, and triggers + /// disconnect and error callbacks as required. + /// + /// + /// The current HTTP context containing the WebSocket upgrade request and connection metadata. + /// + /// + /// Context information for the WebSocket endpoint, including supported subprotocols + /// and application-level metadata. + /// + /// + /// A task that represents the asynchronous handling of the WebSocket connection. + /// + Task HandleConnectionAsync(HttpContext httpContext, ISocketContext socketContext); + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/ISocketMessage.cs b/src/WebExpress.WebCore/WebSocket/ISocketMessage.cs new file mode 100644 index 0000000..842cbc6 --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/ISocketMessage.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// Base class for structured WebSocket messages exchanged between client and server. + /// Contains routing metadata common to all message types. + /// + public interface ISocketMessage + { + /// + /// Application-defined message type used for routing. + /// + string Type { get; } + + /// + /// The message identifier for deduplication or request/response correlation. + /// + string MessageId { get; } + + /// + /// The application id this payload belongs to, if applicable. + /// + string ApplicationId { get; } + + /// + /// The socket id (endpoint id) this payload targets or originates from. + /// + string SocketId { get; } + + /// + /// The connection id assigned by the socket manager on registration. + /// + string ConnectionId { get; } + + /// + /// Optional sender identifier. + /// + string Sender { get; } + + /// + /// Optional list of target identifiers. + /// + IEnumerable Targets { get; } + + /// + /// Timestamp in UTC when the message was created. + /// + DateTime Timestamp { get; } + + /// + /// Arbitrary metadata as key/value pairs. + /// + IDictionary Meta { get; } + + /// + /// Indicates whether this message contains binary payload. + /// + [JsonIgnore] + abstract bool IsBinary { get; } + + /// + /// Serializes the message to JSON. + /// + /// + /// A JSON string containing the serialized form of the message, including + /// routing metadata and payload fields. + /// + string ToJson(); + } +} diff --git a/src/WebExpress.WebCore/WebSocket/ISocketReadStream.cs b/src/WebExpress.WebCore/WebSocket/ISocketReadStream.cs new file mode 100644 index 0000000..7e2937e --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/ISocketReadStream.cs @@ -0,0 +1,63 @@ +using System; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// Represents an asynchronous read-only stream abstraction for receiving + /// WebSocket message data in one or more frames. + /// + public interface ISocketReadStream : IAsyncDisposable + { + /// + /// Returns the context associated with the underlying socket connection. + /// + ISocketContext SocketContext { get; } + + /// + /// Returns the unique identifier for the current connection. + /// + string ConnectionId { get; } + + /// + /// Reads a chunk of data from the underlying WebSocket transport. + /// This method does not assume the message is complete; callers may + /// invoke it multiple times to receive a fragmented message. + /// + /// + /// The buffer into which the received data will be written. + /// + /// + /// A token to observe while waiting for the operation to complete. + /// + /// + /// The number of bytes read. Returns 0 if the end of the message + /// has been reached. + /// + Task ReadAsync(ArraySegment buffer, CancellationToken cancellationToken = default); + + /// + /// Signals that the current message has been fully consumed. + /// After calling this method, no further reads for the current + /// message are allowed. + /// + /// + /// A token to observe while waiting for the operation to complete. + /// + Task CompleteAsync(CancellationToken cancellationToken = default); + + /// + /// Asynchronously closes the underlying WebSocket connection using a normal + /// closure status. + /// + /// + /// A cancellation token that can be used to cancel the close operation. + /// + /// + /// A task that represents the asynchronous close operation. + /// + Task CloseAsync(CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/ISocketWriteStream.cs b/src/WebExpress.WebCore/WebSocket/ISocketWriteStream.cs new file mode 100644 index 0000000..7b4b51c --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/ISocketWriteStream.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// Represents an asynchronous write-only stream abstraction for sending + /// WebSocket message data in one or more frames. + /// + public interface ISocketWriteStream : IAsyncDisposable + { + /// + /// Writes a chunk of data to the underlying WebSocket transport. + /// This method does not finalize the message; callers may invoke it + /// multiple times to send a message in fragments. + /// + /// The data buffer to write. + /// A token to observe while waiting for the operation to complete. + Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default); + + /// + /// Completes the message by sending the final WebSocket frame with + /// endOfMessage set to true. + /// After calling this method, no further writes are allowed. + /// + /// A token to observe while waiting for the operation to complete. + Task CompleteAsync(CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/ISockt.cs b/src/WebExpress.WebCore/WebSocket/ISockt.cs new file mode 100644 index 0000000..77a9a2b --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/ISockt.cs @@ -0,0 +1,50 @@ +using System; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using WebExpress.WebCore.WebEndpoint; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// Defines the contract for WebSocket endpoints. + /// + public interface ISocket : IEndpoint, IDisposable + { + /// + /// Invoked after the websocket handshake has been accepted. + /// Implementers may use the optional cancellation token to abort long-running startup tasks. + /// the optional connectMessage provides initial metadata from the client (may be null). + /// + /// Optional initial message or metadata sent by the client during/after connect. + /// A token to cancel startup work. + /// An asynchronous task. + Task OnConnectedAsync(ISocketMessage connectMessage = null, CancellationToken cancellationToken = default); + + /// + /// Invoked for each received message (complete message or assembled fragments). + /// Implementations receive a parsed SocketMessage rather than raw byte buffers. + /// + /// The parsed message originated from the client. + /// Cancellation token for cooperative cancellation. + /// An asynchronous task. + Task OnReceiveAsync(ISocketMessage message, CancellationToken cancellationToken = default); + + /// + /// Invoked when the websocket connection is closed or is about to be closed. + /// Implementers should perform cleanup and release resources. + /// + /// Optional close status. + /// Optional close description. + /// An asynchronous task. + Task OnDisconnectedAsync(WebSocketCloseStatus closeStatus, string closeDescription); + + /// + /// Invoked when an unhandled exception occurs during websocket processing. + /// Implementers should use this to log and perform cleanup. + /// + /// The exception that occurred. + /// An asynchronous task. + Task OnErrorAsync(Exception exception); + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Model/SocketDictionary.cs b/src/WebExpress.WebCore/WebSocket/Model/SocketDictionary.cs new file mode 100644 index 0000000..5f46087 --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/Model/SocketDictionary.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.WebSocket.Model +{ + /// + /// Represents a dictionary that maps plugin contexts to application contexts, socket types, and socket items. + /// key = plugin context + /// value = application context { key = socket type, value = socket item } + /// + internal class SocketDictionary + { + private readonly Dictionary>> _dict = new Dictionary>>(); + + /// + /// Returns all socket contexts. + /// + public IEnumerable All => _dict.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Select(x => x.SocketContext); + + /// + /// Adds a socket item to the dictionary. + /// + /// The plugin context. + /// The application context. + /// The socket item. + /// + /// True if the socket item was added successfully, false if an element with the same status code already exists. + /// + public bool AddSocketItem(IPluginContext pluginContext, IApplicationContext applicationContext, SocketItem socketItem) + { + var type = socketItem.SocketClass; + + if (type.GetInterface(typeof(ISocket).Name) is null) + { + return false; + } + + if (!_dict.TryGetValue(pluginContext, out Dictionary> appContextDict)) + { + appContextDict = new Dictionary>(); + _dict[pluginContext] = appContextDict; + } + + if (!appContextDict.TryGetValue(applicationContext, out Dictionary socketDict)) + { + socketDict = new Dictionary(); + appContextDict[applicationContext] = socketDict; + } + + if (!socketDict.ContainsKey(type)) + { + socketDict[type] = socketItem; + return true; + } + + return false; + } + + /// + /// Removes all socket items from the dictionary for the given plugin context. + /// + /// The plugin context. + public IEnumerable RemoveSocket(IPluginContext pluginContext) + { + var removed = GetSocketItems(pluginContext); + + _dict.Remove(pluginContext); + + foreach (var item in removed) + { + item.Dispose(); + } + + return removed.Select(x => x.SocketContext); + } + + /// + /// Removes all socket items from the dictionary for the given application context. + /// + /// The application context. + public IEnumerable RemoveSocket(IApplicationContext applicationContext) + { + var removed = GetSocketItems(applicationContext); + + foreach (var applicationDict in _dict.Values) + { + applicationDict.Remove(applicationContext); + } + + foreach (var item in removed) + { + item.Dispose(); + } + + return removed.Select(x => x.SocketContext); + } + + /// + /// Returns the socket items associated with the specified plugin context. + /// + /// + /// The plugin context to retrieve docket items for. + /// + /// + /// An IEnumerable of associated with the specified + /// application context. + /// + public IEnumerable GetSocketItems(IPluginContext pluginContext) + { + return _dict.Where(x => x.Key.Equals(pluginContext)) + .Select(x => x.Value) + .SelectMany(x => x.Values) + .SelectMany(x => x.Values); + } + + /// + /// Returns the items associated with the specified application context. + /// + /// + /// The application context to retrieve items for. + /// + /// + /// An IEnumerable of associated with the specified + /// application context. + /// + public IEnumerable GetSocketItems(IApplicationContext applicationContext) + { + return _dict.Values + .SelectMany(x => x) + .Where(x => x.Key.Equals(applicationContext)) + .SelectMany(x => x.Value) + .Select(x => x.Value); + } + + /// + /// Returns the socket item associated with the specified socket context. + /// + /// + /// The context of the socket to retrieve. + /// + /// + /// The associated with the specified socket context, or + /// null if no such item exists. + /// + public SocketItem GetSocketItem(ISocketContext socketContext) + { + return _dict.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .FirstOrDefault(x => x.SocketContext.Equals(socketContext)); + } + + /// + /// Returns the socket items from the dictionary for a specific application + /// context and socket type. + /// + /// The application context. + /// The type of the socket. + /// An IEnumerable of socket items. + public IEnumerable GetSocketItems(IApplicationContext applicationContext, Type socketType) + { + if (!typeof(ISocket).IsAssignableFrom(socketType)) + { + return Enumerable.Empty(); + } + + if (_dict.ContainsKey(applicationContext?.PluginContext)) + { + var appContextDict = _dict[applicationContext?.PluginContext]; + + if (appContextDict.TryGetValue(applicationContext, out Dictionary socketDict)) + { + if (socketDict.TryGetValue(socketType, out SocketItem value)) + { + return [value]; + } + } + } + + return Enumerable.Empty(); + } + + /// + /// Returns an enumeration of all containing socket contexts of a plugin. + /// + /// + /// A context of a plugin whose sockets are to be registered. + /// + /// An enumeration of socket contexts. + public IEnumerable GetSockets(IPluginContext pluginContext) + { + if (_dict.TryGetValue(pluginContext, out var pluginResources)) + { + return pluginResources + .SelectMany(x => x.Value) + .Select(x => x.Value.SocketContext); + } + + return Enumerable.Empty(); + } + + /// + /// Returns an enumeration of socket contexts. + /// + /// The socket type. + /// An enumeration of socket contexts. + public IEnumerable GetSockets(Type socketType) + { + return _dict.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.SocketClass.Equals(socketType)) + .Select(x => x.SocketContext); + } + + /// + /// Returns an enumeration of socket contexts. + /// + /// The socket type. + /// The context of the application. + /// An enumeration of socket contexts. + public IEnumerable GetSockets(Type socketType, IApplicationContext applicationContext) + { + return _dict.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.SocketClass.Equals(socketType)) + .Where(x => x.SocketContext.ApplicationContext.Equals(applicationContext)) + .Select(x => x.SocketContext); + } + + /// + /// Returns an enumeration of socket contexts. + /// + /// The socket type. + /// The context of the application. + /// An enumeration of socket contexts. + public IEnumerable GetSockets(IApplicationContext applicationContext) where TSocket : ISocket + { + return _dict.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.SocketClass.Equals(typeof(TSocket))) + .Where(x => x.SocketContext.ApplicationContext.Equals(applicationContext)) + .Select(x => x.SocketContext); + } + + /// + /// Returns the socket context. + /// + /// The context of the application. + /// The socket id. + /// An socket context or null. + public ISocketContext GetSocket(IApplicationContext applicationContext, string socketId) + { + return _dict.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.SocketContext.ApplicationContext.Equals(applicationContext)) + .Where(x => x.SocketContext.EndpointId.Equals(socketId)) + .Select(x => x.SocketContext) + .FirstOrDefault(); + } + + /// + /// Returns the socket context. + /// + /// The application id. + /// The socket id. + /// An socket context or null. + public ISocketContext GetSocket(string applicationId, string socketId) + { + return _dict.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.SocketContext.ApplicationContext.ApplicationId.Equals(applicationId)) + .Where(x => x.SocketContext.EndpointId.Equals(socketId)) + .Select(x => x.SocketContext) + .FirstOrDefault(); + } + + /// + /// Checks if the dictionary contains the specified plugin context. + /// + /// The plugin context to check for. + /// + /// True if the plugin context exists in the dictionary, otherwise false. + /// + public bool Contains(IPluginContext pluginContext) + { + return _dict.ContainsKey(pluginContext); + } + + /// + /// Checks if the dictionary contains the specified plugin context and application context. + /// + /// The plugin context to check for. + /// The application context to check for. + /// + /// True if the plugin context and application context exist in the dictionary, + /// otherwise false. + /// + public bool Contains(IPluginContext pluginContext, IApplicationContext applicationContext) + { + return _dict.TryGetValue(pluginContext, out var appDict) && appDict.ContainsKey(applicationContext); + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs b/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs new file mode 100644 index 0000000..5873a72 --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Net.WebSockets; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebCondition; +using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.WebSocket.Model +{ + /// + /// A socket element that contains meta information about a socket endpoint. + /// + internal class SocketItem : IDisposable + { + /// + /// Returns the endpoint id. + /// + public IComponentId EndpointId { get; internal set; } + + /// + /// Returns the associated plugin context. + /// + public IPluginContext PluginContext { get; internal set; } + + /// + /// Returns the corresponding application context. + /// + public IApplicationContext ApplicationContext { get; internal set; } + + /// + /// Returns or sets the type of socket class. + /// + public Type SocketClass { get; set; } + + /// + /// Returns the collection of supported websocket subprotocols. + /// implementations should return the subprotocol identifiers the endpoint can speak. + /// + public IEnumerable SupportedSubProtocols { get; set; } + + /// + /// Returns the default WebSocket message type used by this endpoint when + /// sending data. Implementations may choose + /// for JSON or human-readable content, or + /// for binary payloads. + /// + public WebSocketMessageType MessageType { get; set; } + + /// + /// Returns the maximum allowed message size in bytes, or null when the endpoint imposes no limit. + /// servers and hosts may use this to protect against excessively large frames. + /// + public ulong MaxMessageSize { get; set; } + + /// + /// Returns the conditions that must be met for the resource to be active. + /// + public IEnumerable Conditions { get; set; } + + /// + /// Returns whether the resource is created once and reused each time it is called. + /// + public bool Cache { get; set; } + + /// + /// Returns the attributes associated with the socket. + /// + public IEnumerable Attributes { get; internal set; } + + /// + /// Returns the socket context. + /// note: reuses ISocketContext to remain compatible with existing contexts; + /// replace with a dedicated socket context type if one exists. + /// + public ISocketContext SocketContext { get; internal set; } + + /// + /// Returns or sets the instance of the socket endpoint, if the endpoint is cached, otherwise null. + /// + public IEndpoint Instance { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The endpoint manager responsible for managing endpoints. + internal SocketItem(IEndpointManager endpointManager) + { + } + + /// + /// Performs application-specific tasks related to sharing, returning, or resetting unmanaged resources. + /// + public void Dispose() + { + } + + /// + /// Convert the resource element to a string. + /// + /// The resource element in its string representation. + public override string ToString() + { + return $"Socket: '{SocketContext?.EndpointId}'"; + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/SocketContext.cs b/src/WebExpress.WebCore/WebSocket/SocketContext.cs new file mode 100644 index 0000000..8585c13 --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/SocketContext.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Net.WebSockets; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebCondition; +using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// Default implementation of ISocketContext. + /// this class provides a simple, mutable context object usable for websocket endpoints. + /// + public class SocketContext : ISocketContext + { + /// + /// Returns the associated plugin context. + /// + public IPluginContext PluginContext { get; internal set; } + + /// + /// Returns the corresponding application context. + /// + public IApplicationContext ApplicationContext { get; internal set; } + + /// + /// Returns the conditions that must be met for the resource to be active. + /// + public IEnumerable Conditions { get; internal set; } = []; + + /// + /// Returns the endpoint id. + /// + public IComponentId EndpointId { get; internal set; } + + /// + /// Collection of supported websocket subprotocols. + /// + public IEnumerable SupportedSubProtocols { get; set; } + + /// + /// Returns the default WebSocket message type used by this endpoint when + /// sending data. Implementations may choose + /// for JSON or human-readable content, or + /// for binary payloads. + /// + public WebSocketMessageType MessageType { get; set; } + + /// + /// Maximum allowed message size in bytes, or null when no limit is imposed. + /// + public ulong MaxMessageSize { get; set; } + + /// + /// Indicates whether this websocket endpoint requires an authenticated client. + /// + public bool RequiresAuthentication { get; set; } + + /// + /// Returns whether the resource is created once and reused each time it is called. + /// + public bool Cache { get; internal set; } + + /// + /// Returns or sets whether all subpaths should be taken into sitemap. + /// + public bool IncludeSubPaths { get; internal set; } + + /// + /// Returns the internal routing path for the endpoint. + /// + public IRoute Route { get; internal set; } + + /// + /// Returns the attributes associated with the page. + /// + public IEnumerable Attributes { get; internal set; } + + /// + /// Creates a new instance of DefaultWebSocketContext. + /// + public SocketContext() + { + } + + /// + /// Returns a short diagnostic representation. + /// + /// string + public override string ToString() + { + // return a compact representation with name and supported subprotocols + var protocols = SupportedSubProtocols != null ? string.Join(",", SupportedSubProtocols) : string.Empty; + return $"{EndpointId} (protocols: {protocols})"; + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/SocketHandshakeException.cs b/src/WebExpress.WebCore/WebSocket/SocketHandshakeException.cs new file mode 100644 index 0000000..bc95495 --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/SocketHandshakeException.cs @@ -0,0 +1,23 @@ +using System; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// Represents an error that occurs when a WebSocket handshake request + /// is invalid or does not meet the required protocol specifications. + /// + public class SocketHandshakeException : Exception + { + /// + /// Initializes a new instance of the class + /// with the specified error message. + /// + /// + /// A descriptive message that explains the reason for the handshake failure. + /// + public SocketHandshakeException(string message) + : base(message) + { + } + } +} diff --git a/src/WebExpress.WebCore/WebSocket/SocketManager.cs b/src/WebExpress.WebCore/WebSocket/SocketManager.cs new file mode 100644 index 0000000..b6a21cf --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/SocketManager.cs @@ -0,0 +1,699 @@ +using Microsoft.AspNetCore.Http.Features; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using WebExpress.WebCore.Internationalization; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebCondition; +using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebPlugin; +using WebExpress.WebCore.WebSocket.Model; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// The socket manager manages socket endpoints (see RFC 6455 – The WebSocket Protocol) which can be called with a URI. + /// + public class SocketManager : ISocketManager + { + private readonly IComponentHub _componentHub; + private readonly IHttpServerContext _httpServerContext; + private readonly SocketDictionary _dictionary = new(); + + /// + /// An event that fires when a socket context is added. + /// + public event EventHandler AddSocket; + + /// + /// An event that fires when a socket context is removed. + /// + public event EventHandler RemoveSocket; + + /// + /// Returns all socket contexts. + /// + public IEnumerable Sockets => _dictionary.All; + + /// + /// Initializes a new instance of the class. + /// + /// The component hub. + /// The reference to the context of the host. + [SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "Used via Reflection.")] + private SocketManager(IComponentHub componentHub, IHttpServerContext httpServerContext) + { + _componentHub = componentHub; + + _componentHub.PluginManager.AddPlugin += OnAddPlugin; + _componentHub.PluginManager.RemovePlugin += OnRemovePlugin; + _componentHub.ApplicationManager.AddApplication += OnAddApplication; + _componentHub.ApplicationManager.RemoveApplication += OnRemoveApplication; + + var endpointRegistration = new EndpointRegistration() + { + EndpointResolver = (type, applicationContext) => applicationContext is not null + ? GetSockets(type, applicationContext) + : GetSockets(type), + EndpointsResolver = () => Sockets + }; + + AddSocket += (sender, e) => endpointRegistration.AddEndpoint?.Invoke(sender, e); + RemoveSocket += (sender, e) => endpointRegistration.RemoveEndpoint?.Invoke(sender, e); + + _componentHub.EndpointManager.Register(endpointRegistration); + + _httpServerContext = httpServerContext; + + _httpServerContext.Log.Debug + ( + I18N.Translate("webexpress.webcore:socketmanager.initialization") + ); + } + + /// + /// Handles the complete lifecycle of a WebSocket connection for the given HTTP context. + /// Performs the server-side upgrade, invokes connection callbacks, processes incoming + /// WebSocket frames, parses messages, dispatches them to the socket handler, and triggers + /// disconnect and error callbacks as required. + /// + /// + /// The current HTTP context containing the WebSocket upgrade request and connection metadata. + /// + /// + /// Context information for the WebSocket endpoint, including supported subprotocols + /// and application-level metadata. + /// + /// + /// A task that represents the asynchronous handling of the WebSocket connection. + /// + public async Task HandleConnectionAsync(HttpContext httpContext, ISocketContext socketContext) + { + var connectionId = Guid.NewGuid().ToString(); + var closeStatus = WebSocketCloseStatus.NormalClosure; + var closeDescription = "closing"; + var webSocket = await CreateWebSocket(httpContext, socketContext); + var instance = CreateSocketInstance(socketContext, webSocket) as ISocket; + + // notify connected event on server side if available + try + { + // if an ISocket implementation is registered for this endpoint, + // try to call OnConnectedAsync + await instance.OnConnectedAsync(); + } + catch + { + // ignore errors from optional OnConnected handling + } + + try + { + // receive loop: handle fragmented frames and large payloads + while (webSocket.State == WebSocketState.Open) + { + var stream = new SocketReadStream(webSocket, socketContext, connectionId); + var message = await stream.ReadMessageAsync(CancellationToken.None); + + // dispatch + await DispatchMessage(instance, message); + } + } + catch (WebSocketException ex) + { + closeStatus = WebSocketCloseStatus.InternalServerError; + closeDescription = "transport error"; + await instance.OnErrorAsync(ex); + } + + await instance.OnDisconnectedAsync(closeStatus, closeDescription); + } + + /// + /// Returns an enumeration of all socket contexts provided by a plugin. + /// + /// A context of a plugin whose sockets are to be returned. + /// An enumeration of socket contexts. + public IEnumerable GetSockets(IPluginContext pluginContext) + { + return _dictionary.GetSockets(pluginContext); + } + + /// + /// Returns an enumeration of socket contexts filtered by endpoint type. + /// + /// The socket endpoint type. + /// An enumeration of socket contexts. + public IEnumerable GetSockets() where T : ISocket + { + return GetSockets(typeof(T)); + } + + /// + /// Returns an enumeration of socket contexts filtered by endpoint type. + /// + /// The socket endpoint type. + /// An enumeration of socket contexts. + public IEnumerable GetSockets(Type socketType) + { + return _dictionary.GetSockets(socketType); + } + + /// + /// Returns an enumeration of socket contexts filtered by endpoint type and application context. + /// + /// The socket endpoint type. + /// The context of the application. + /// An enumeration of socket contexts. + public IEnumerable GetSockets(Type socketType, IApplicationContext applicationContext) + { + return _dictionary.GetSockets(socketType, applicationContext); + } + + /// + /// Returns an enumeration of socket contexts filtered by endpoint type and application context. + /// + /// The socket endpoint type. + /// The context of the application. + /// An enumeration of socket contexts. + public IEnumerable GetSockets(IApplicationContext applicationContext) where T : ISocket + { + return _dictionary.GetSockets(applicationContext); + } + + /// + /// Returns the socket context by application context and socket id. + /// + /// The context of the application. + /// The socket id. + /// A socket context or null. + public ISocketContext GetSocket(IApplicationContext applicationContext, string socketId) + { + return _dictionary.GetSocket(applicationContext, socketId); + } + + /// + /// Returns the socket context by application id and socket id. + /// + /// The application id. + /// The socket id. + /// A socket context or null. + public ISocketContext GetSocket(string applicationId, string socketId) + { + return _dictionary.GetSocket(applicationId, socketId); + } + + /// + /// Creates a new socket endpoint instance and returns it. If an instance is cached it is returned. + /// + /// The context used for socket creation. + /// The accepted websocket instance. + /// The created or cached endpoint. + private async Task CreateSocketInstance(ISocketContext socketContext, System.Net.WebSockets.WebSocket webSocket) + { + var resourceItem = _dictionary.GetSocketItem(socketContext); + + if (resourceItem is not null && resourceItem.Instance is null) + { + await using var stream = new SocketWriteStream(webSocket, socketContext.MessageType); + + var instance = ComponentActivator.CreateInstance + ( + resourceItem.SocketClass, + socketContext, + _httpServerContext, + _componentHub, + socketContext.ApplicationContext, + stream + ); + + if (resourceItem.Cache) + { + resourceItem.Instance = instance; + } + + return instance; + } + + return resourceItem?.Instance; + } + + /// + /// Discovers and binds sockets to an application. + /// + /// The context of the plugin whose sockets are to be associated. + private void Register(IPluginContext pluginContext) + { + if (_dictionary.Contains(pluginContext)) + { + return; + } + + Register(pluginContext, _componentHub.ApplicationManager.GetApplications(pluginContext)); + } + + /// + /// Discovers and binds sockets to an application. + /// + /// The context of the application whose sockets are to be associated. + private void Register(IApplicationContext applicationContext) + { + foreach (var pluginContext in _componentHub.PluginManager.GetPlugins(applicationContext)) + { + if (_dictionary.Contains(pluginContext, applicationContext)) + { + continue; + } + + Register(pluginContext, new[] { applicationContext }); + } + } + + /// + /// Registers sockets for a given plugin and application contexts. + /// + /// The plugin context. + /// The application context(s). + private void Register(IPluginContext pluginContext, IEnumerable applicationContexts) + { + var assembly = pluginContext?.Assembly; + + foreach (var socketType in assembly.GetTypes() + .Where(x => x.IsClass == true && x.IsSealed && x.IsPublic) + .Where(x => x.GetInterfaces().Where(i => i == typeof(ISocket)).Any())) + { + var id = socketType.FullName?.ToLower(); + var includeSubPaths = false; + var conditions = new List(); + var cache = false; + var subProtocols = new List(); + var messageType = WebSocketMessageType.Text; + var maxMessageSize = ulong.MinValue; + var attributes = socketType.CustomAttributes + .Where(x => !x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute))); + + foreach (var customAttribute in socketType.CustomAttributes + .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute)))) + { + if (customAttribute.AttributeType == typeof(IncludeSubPathsAttribute)) + { + includeSubPaths = Convert.ToBoolean(customAttribute.ConstructorArguments.FirstOrDefault().Value); + } + else if (customAttribute.AttributeType.Name == typeof(ConditionAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(ConditionAttribute<>).Namespace) + { + var condition = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); + conditions.Add(Activator.CreateInstance(condition) as ICondition); + } + else if (customAttribute.AttributeType == typeof(CacheAttribute)) + { + cache = true; + } + } + + foreach (var customAttribute in socketType.CustomAttributes + .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(ISocketAttribute)))) + { + if (customAttribute.AttributeType == typeof(MessageTypeAttribute)) + { + messageType = Enum.Parse(customAttribute.ConstructorArguments.FirstOrDefault().Value.ToString()); + } + else if (customAttribute.AttributeType == typeof(SubProtocolAttribute)) + { + subProtocols.Add(customAttribute.ConstructorArguments.FirstOrDefault().Value.ToString()); + } + else if (customAttribute.AttributeType == typeof(MaxMessageSizeAttribute)) + { + maxMessageSize = Convert.ToUInt64(customAttribute.ConstructorArguments.FirstOrDefault().Value.ToString()); + } + } + + // assign the socket to existing applications + foreach (var applicationContext in applicationContexts) + { + var prefix = applicationContext.Route.Concat + ( + applicationContext.PluginContext != pluginContext + ? pluginContext.PluginName.ToLower() + : "" + ); + var routePath = EndpointManager.CreateEndpointRoute(socketType, prefix, null); + var socketContext = new SocketContext() + { + EndpointId = new ComponentId(id), + PluginContext = pluginContext, + ApplicationContext = applicationContext, + Route = routePath, + MessageType = messageType, + SupportedSubProtocols = subProtocols, + MaxMessageSize = maxMessageSize, + Cache = cache, + Conditions = conditions, + IncludeSubPaths = includeSubPaths, + Attributes = EndpointManager.GetAttributeInstances(attributes) + }; + + var socketItem = new SocketItem(_componentHub.EndpointManager) + { + EndpointId = new ComponentId(id), + PluginContext = pluginContext, + ApplicationContext = applicationContext, + SocketContext = socketContext, + SocketClass = socketType, + MessageType = messageType, + SupportedSubProtocols = subProtocols, + MaxMessageSize = maxMessageSize, + Cache = cache, + Conditions = conditions, + Attributes = attributes.Select(x => x.AttributeType) + }; + + if (_dictionary.AddSocketItem(pluginContext, applicationContext, socketItem)) + { + OnAddSocket(socketItem.SocketContext); + + _httpServerContext?.Log.Debug + ( + I18N.Translate + ( + "webexpress.webcore:socketmanager.addsocket", + id, + applicationContext.ApplicationId + ) + ); + } + } + } + } + + /// + /// Removes all sockets associated with the specified plugin context. + /// + /// The context of the plugin that contains the sockets to remove. + public void Remove(IPluginContext pluginContext) + { + if (pluginContext is null) + { + return; + } + + // deregister all sockets associated with this plugin context + foreach (var socketContext in _dictionary.RemoveSocket(pluginContext)) + { + OnRemoveSocket(socketContext); + + _httpServerContext?.Log.Debug + ( + I18N.Translate + ( + "webexpress.webcore:socketmanager.removesocket", + socketContext.EndpointId, + socketContext.ApplicationContext.ApplicationId + ) + ); + } + } + + /// + /// Removes all sockets associated with the specified application context. + /// + /// The context of the application that contains the sockets to remove. + internal void Remove(IApplicationContext applicationContext) + { + if (applicationContext is null) + { + return; + } + + // deregister all sockets associated with this application context + foreach (var socketContext in _dictionary.RemoveSocket(applicationContext)) + { + OnRemoveSocket(socketContext); + + _httpServerContext?.Log.Debug + ( + I18N.Translate + ( + "webexpress.webcore:socketmanager.removesocket", + socketContext.EndpointId, + socketContext.ApplicationContext.ApplicationId + ) + ); + } + } + + /// + /// Raises the AddSocket event. + /// + /// The socket context. + private void OnAddSocket(ISocketContext socketContext) + { + AddSocket?.Invoke(this, socketContext); + } + + /// + /// Raises the RemoveSocket event. + /// + /// The socket context. + private void OnRemoveSocket(ISocketContext socketContext) + { + RemoveSocket?.Invoke(this, socketContext); + } + + /// + /// Raises the event when a plugin is added. + /// + /// The source of the event. + /// The context of the plugin being added. + private void OnAddPlugin(object sender, IPluginContext pluginContext) + { + Register(pluginContext); + } + + /// + /// Raises the event when a plugin is removed. + /// + /// The source of the event. + /// The context of the plugin being removed. + private void OnRemovePlugin(object sender, IPluginContext pluginContext) + { + Remove(pluginContext); + } + + /// + /// Raises the event when an application is added. + /// + /// The source of the event. + /// The context of the application being added. + private void OnAddApplication(object sender, IApplicationContext applicationContext) + { + Register(applicationContext); + } + + /// + /// Raises the event when an application is removed. + /// + /// The source of the event. + /// The application context. + private void OnRemoveApplication(object sender, IApplicationContext applicationContext) + { + Remove(applicationContext); + } + + /// + /// Performs the server-side WebSocket upgrade handshake and creates a WebSocket instance. + /// Validates the required handshake headers, negotiates an optional subprotocol, + /// and delegates the protocol switch to the ASP.NET Core WebSocket feature. + /// + /// + /// The current HTTP context containing the incoming WebSocket upgrade request. + /// + /// + /// Context information for the WebSocket endpoint, including supported subprotocols. + /// + /// + /// A instance representing the established WebSocket connection, + /// or null if the handshake fails and an appropriate HTTP response is sent. + /// + private static Task CreateWebSocket(HttpContext httpContext, ISocketContext socketContext) + { + // basic handshake pre-checks: Upgrade header, Connection header, Sec-WebSocket-Key and version + var request = httpContext.Request; + var wsFeature = httpContext.Features.Get(); + var upgradeHeader = request.Header.Upgrade; + var connectionHeader = request.Header.Connection; + var secKey = request.Header.SecWebSocketKey; + var secVersion = request.Header.SecWebSocketVersion; + var secProtocol = request.Header.SecWebSocketProtocol; + + if (string.IsNullOrWhiteSpace(upgradeHeader) || !upgradeHeader.Equals("websocket", StringComparison.OrdinalIgnoreCase) + || string.IsNullOrWhiteSpace(connectionHeader) || !connectionHeader.Split(',').Select(x => x.Trim()).Any(x => x.Equals("upgrade", StringComparison.OrdinalIgnoreCase)) + || string.IsNullOrWhiteSpace(secKey) + || string.IsNullOrWhiteSpace(secVersion) || !secVersion.Split(',').Select(x => x.Trim()).Any(x => x.Equals("13"))) + { + // missing or invalid websocket handshake headers + throw new SocketHandshakeException("Invalid WebSocket handshake headers"); + } + + // negotiate subprotocol: pick first supported subprotocol present in client's Sec-WebSocket-Protocol header + var negotiatedSubProtocol = ""; + try + { + if (!string.IsNullOrWhiteSpace(secProtocol) && socketContext is not null) + { + var requestedProtocols = secProtocol.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()); + negotiatedSubProtocol = socketContext.SupportedSubProtocols + .Intersect(requestedProtocols, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + } + } + catch + { + // ignore negotiation failures and continue without subprotocol + negotiatedSubProtocol = null; + } + + // accept the websocket; ASP.NET Core will perform the 101 Switching Protocols handshake + var webSocketAcceptContext = new Microsoft.AspNetCore.Http.WebSocketAcceptContext() + { + SubProtocol = negotiatedSubProtocol + }; + + return wsFeature.AcceptAsync(webSocketAcceptContext); + } + + /// + /// Parses a received WebSocket frame into a instance. + /// Supports both text and binary frames, applies JSON deserialization when possible, + /// and enriches the resulting message with connection and endpoint metadata. + /// + /// + /// The unique identifier assigned to the current WebSocket connection. + /// + /// + /// Context information for the WebSocket endpoint, including application and endpoint metadata. + /// + /// + /// The describing the received frame. + /// + /// + /// The memory stream containing the accumulated payload of the WebSocket message. + /// + /// + /// A representing the parsed and enriched message. + /// + private static ISocketMessage ParseMessage + ( + string connectionId, + ISocketContext socketContext, + WebSocketReceiveResult result, + MemoryStream stream + ) + { + // produce SocketMessage from buffer + stream.Seek(0, SeekOrigin.Begin); + + if (result.MessageType == WebSocketMessageType.Text) + { + // extract UTF‑8 text from the stream + var text = Encoding.UTF8.GetString(stream.ToArray()); + + ISocketMessage parsed = null; + + try + { + using var doc = JsonDocument.Parse(text); + + if (doc.RootElement.TryGetProperty("text", out _)) + { + parsed = JsonSerializer.Deserialize(text); + } + else if (doc.RootElement.TryGetProperty("data", out _)) + { + parsed = JsonSerializer.Deserialize(text); + } + else + { + // default + parsed = JsonSerializer.Deserialize(text); + } + } + catch + { + // JSON invalid → fallback to plain text message + parsed = new SocketMessageText + { + Type = null, + Text = text, + ConnectionId = connectionId, + ApplicationId = socketContext?.ApplicationContext?.ApplicationId, + SocketId = socketContext?.EndpointId?.ToString() + }; + } + + return parsed; + } + else // binary + { + var bytes = stream.ToArray(); + + return new SocketMessageBinary + { + Type = null, + Data = bytes, + ConnectionId = connectionId, + ApplicationId = socketContext?.ApplicationContext?.ApplicationId, + SocketId = socketContext?.EndpointId?.ToString() + }; + } + } + + /// + /// Dispatches the parsed to the socket handler implementation. + /// Invokes the receive callback and forwards any handler exceptions to the error callback. + /// + /// + /// The implementation responsible for processing the message. + /// + /// + /// The message to be delivered to the socket handler. + /// + /// + /// A task that represents the asynchronous dispatch operation. + /// + private static async Task DispatchMessage(ISocket instance, ISocketMessage message) + { + try + { + await instance.OnReceiveAsync(message); + } + catch (Exception ex) + { + await instance.OnErrorAsync(ex); + } + } + + /// + /// Release of unmanaged resources reserved during use. + /// + public void Dispose() + { + _componentHub.PluginManager.AddPlugin -= OnAddPlugin; + _componentHub.PluginManager.RemovePlugin -= OnRemovePlugin; + _componentHub.ApplicationManager.AddApplication -= OnAddApplication; + _componentHub.ApplicationManager.RemoveApplication -= OnRemoveApplication; + + GC.SuppressFinalize(this); + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/SocketMessageBinary.cs b/src/WebExpress.WebCore/WebSocket/SocketMessageBinary.cs new file mode 100644 index 0000000..2a55fa0 --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/SocketMessageBinary.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// Represents a WebSocket message containing binary payload. + /// Implements and provides routing metadata + /// together with binary content. + /// + public class SocketMessageBinary : ISocketMessage + { + /// + public string Type { get; init; } + + /// + /// The message identifier for deduplication or request/response correlation. + /// + public string MessageId { get; set; } + + /// + /// The application id this payload belongs to, if applicable. + /// + public string ApplicationId { get; set; } + + /// + /// The socket id (endpoint id) this payload targets or originates from. + /// + public string SocketId { get; set; } + + /// + /// The connection id assigned by the socket manager on registration. + /// + public string ConnectionId { get; set; } + + /// + public string Sender { get; init; } + + /// + public IEnumerable Targets { get; init; } + + /// + public DateTime Timestamp { get; init; } = DateTime.UtcNow; + + /// + public IDictionary Meta { get; init; } + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// The binary payload of the message. + /// Automatically Base64-encoded by System.Text.Json. + /// + public byte[] Data { get; init; } + + /// + [JsonIgnore] + public bool IsBinary => Data?.Length > 0; + + private static readonly JsonSerializerOptions SerializeOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// + public string ToJson() + { + return JsonSerializer.Serialize(this, SerializeOptions); + } + + /// + /// Creates a new binary message with the specified routing type and payload. + /// + public static SocketMessageBinary Create(string type, byte[] data) + { + return new SocketMessageBinary + { + Type = type, + Data = data, + Timestamp = DateTime.UtcNow + }; + } + } +} diff --git a/src/WebExpress.WebCore/WebSocket/SocketMessageText.cs b/src/WebExpress.WebCore/WebSocket/SocketMessageText.cs new file mode 100644 index 0000000..552ee60 --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/SocketMessageText.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// Represents a WebSocket message containing UTF-8 text payload. + /// Implements and provides routing metadata + /// together with human-readable content. + /// + public class SocketMessageText : ISocketMessage + { + /// + /// Application-defined message type used for routing. + /// + public string Type { get; init; } + + /// + /// The message identifier for deduplication or request/response correlation. + /// + public string MessageId { get; set; } + + /// + /// The application id this payload belongs to, if applicable. + /// + public string ApplicationId { get; set; } + + /// + /// The socket id (endpoint id) this payload targets or originates from. + /// + public string SocketId { get; set; } + + /// + /// The connection id assigned by the socket manager on registration. + /// + public string ConnectionId { get; set; } + + /// + public string Sender { get; init; } + + /// + public IEnumerable Targets { get; init; } + + /// + public DateTime Timestamp { get; init; } = DateTime.UtcNow; + + /// + public IDictionary Meta { get; init; } + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// The UTF-8 text payload of the message. + /// + public string Text { get; init; } + + /// + [JsonIgnore] + public bool IsBinary => false; + + private static readonly JsonSerializerOptions SerializeOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// + public string ToJson() + { + return JsonSerializer.Serialize(this, SerializeOptions); + } + + /// + /// Creates a new text message with the specified routing type and payload. + /// + public static SocketMessageText Create(string type, string text) + { + return new SocketMessageText + { + Type = type, + Text = text, + Timestamp = DateTime.UtcNow + }; + } + } +} diff --git a/src/WebExpress.WebCore/WebSocket/SocketMessageTooLargeException.cs b/src/WebExpress.WebCore/WebSocket/SocketMessageTooLargeException.cs new file mode 100644 index 0000000..010488a --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/SocketMessageTooLargeException.cs @@ -0,0 +1,64 @@ +using System; +using System.Runtime.Serialization; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// Represents an error that occurs when an incoming WebSocket message + /// exceeds the maximum allowed message size configured for the endpoint. + /// + public class SocketMessageTooLargeException : Exception + { + /// + /// Returns the total number of bytes received for the message. + /// + public ulong ActualSize { get; } + + /// + /// Returns the maximum allowed message size in bytes. + /// + public ulong MaxSize { get; } + + /// + /// Initializes a new instance of the class + /// with the specified actual and maximum message sizes. + /// + /// The number of bytes received. + /// The maximum allowed message size in bytes. + public SocketMessageTooLargeException(ulong actualSize, ulong maxSize) + : base($"WebSocket message size {actualSize} bytes exceeds the maximum allowed size of {maxSize} bytes.") + { + ActualSize = actualSize; + MaxSize = maxSize; + } + + /// + /// Initializes a new instance of the class + /// with a custom error message and the specified actual and maximum message sizes. + /// + /// The custom exception message. + /// The number of bytes received. + /// The maximum allowed message size in bytes. + public SocketMessageTooLargeException(string message, ulong actualSize, ulong maxSize) + : base(message) + { + ActualSize = actualSize; + MaxSize = maxSize; + } + + /// + /// Initializes a new instance of the class + /// with a custom error message, an inner exception, and the specified size values. + /// + /// The custom exception message. + /// The inner exception. + /// The number of bytes received. + /// The maximum allowed message size in bytes. + public SocketMessageTooLargeException(string message, Exception innerException, ulong actualSize, ulong maxSize) + : base(message, innerException) + { + ActualSize = actualSize; + MaxSize = maxSize; + } + } +} diff --git a/src/WebExpress.WebCore/WebSocket/SocketReadStream.cs b/src/WebExpress.WebCore/WebSocket/SocketReadStream.cs new file mode 100644 index 0000000..094bf4a --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/SocketReadStream.cs @@ -0,0 +1,127 @@ +using System; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// A binary-oriented implementation of , + /// exposing incoming WebSocket message data as raw byte segments. + /// + public sealed class SocketReadStream : ISocketReadStream + { + private readonly System.Net.WebSockets.WebSocket _socket; + private readonly ISocketContext _socketContext; + private readonly string _connectionId; + + /// + /// Returns the context associated with the underlying socket connection. + /// + public ISocketContext SocketContext => _socketContext; + + /// + /// Returns the unique identifier for the current connection. + /// + public string ConnectionId => _connectionId; + + /// + /// Initializes a new instance of the SocketReadStream class for reading data + /// from a WebSocket connection. + /// + /// + /// The WebSocket instance representing the underlying connection. Cannot be null. + /// + /// + /// The context object that provides additional information or services related + /// to the socket connection. + /// + /// + /// A unique identifier for the connection associated with this stream. + /// + /// Thrown if the socket parameter is null. + public SocketReadStream(System.Net.WebSockets.WebSocket socket, ISocketContext socketContext, string connectionId) + { + _socket = socket ?? throw new ArgumentNullException(nameof(socket)); + _socketContext = socketContext; + _connectionId = connectionId; + } + + /// + /// Receives data asynchronously from the underlying WebSocket and writes it into + /// the provided buffer. + /// + /// + /// The buffer that receives the incoming data. The method writes the received + /// bytes into this memory region. + /// + /// + /// A cancellation token that can be used to cancel the receive operation. + /// + /// + /// A WebSocketReceiveResult that contains information about the received data, + /// including the number of bytes read, the message type, and whether the message + /// is complete. + /// + public async Task ReadAsync + ( + ArraySegment buffer, + CancellationToken cancellationToken = default + ) + { + var result = await _socket.ReceiveAsync(buffer, cancellationToken) + .ConfigureAwait(false); + + return result; + } + + /// + /// Marks the current message as fully consumed. + /// + public Task CompleteAsync(CancellationToken cancellationToken = default) + { + // no explicit "complete" frame for reading; this is a no-op. + return Task.CompletedTask; + } + + /// + /// Asynchronously closes the underlying WebSocket connection using a normal + /// closure status. + /// + /// + /// A cancellation token that can be used to cancel the close operation. + /// + /// + /// A task that represents the asynchronous close operation. + /// + public Task CloseAsync(CancellationToken cancellationToken = default) + { + return _socket.CloseAsync + ( + WebSocketCloseStatus.NormalClosure, + "Read stream closed", + CancellationToken.None + ); + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, + /// or resetting unmanaged resources asynchronously. + /// + /// + /// A completed because this implementation does not + /// hold unmanaged resources. + /// + public ValueTask DisposeAsync() + { + _socket.CloseAsync + ( + WebSocketCloseStatus.NormalClosure, + "Binary read stream closed", + CancellationToken.None + ); + + return ValueTask.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/SocketReadStreamExtensions.cs b/src/WebExpress.WebCore/WebSocket/SocketReadStreamExtensions.cs new file mode 100644 index 0000000..aac84bd --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/SocketReadStreamExtensions.cs @@ -0,0 +1,161 @@ +using System.IO; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// Provides extension methods for reading instances + /// from an . + /// + public static class SocketReadStreamExtensions + { + /// + /// Reads a complete WebSocket message from the stream and deserializes it + /// into a instance. + /// + /// The source read stream. + /// A token to observe while waiting for the operation to complete. + /// The deserialized message. + public static async Task ReadMessageAsync + ( + this ISocketReadStream stream, + CancellationToken cancellationToken = default + ) + { + // buffer for accumulating message fragments + var totalBytes = 0ul; + using var buffer = new MemoryStream(); + var temp = new byte[4096]; + var messageType = WebSocketMessageType.Text; + var maxSize = stream.SocketContext?.MaxMessageSize ?? ulong.MinValue; + var closeStatus = WebSocketCloseStatus.NormalClosure; + var closeDescription = "closing"; + + while (true) + { + var result = await stream.ReadAsync(temp, CancellationToken.None) + .ConfigureAwait(false); + + totalBytes += (ulong)result.Count; + + if (maxSize > ulong.MinValue && totalBytes > maxSize) + { + throw new SocketMessageTooLargeException(totalBytes, maxSize); + } + + // handle close frames + if (result.MessageType == WebSocketMessageType.Close) + { + await stream.CloseAsync(CancellationToken.None); + + break; + } + + if (result.Count == 0) + { + break; // end of message + } + + buffer.Write(temp, 0, result.Count); + + messageType = result.MessageType; + closeStatus = result.CloseStatus ?? WebSocketCloseStatus.NormalClosure; + closeDescription = result.CloseStatusDescription ?? "client closed"; + } + + await stream.CompleteAsync(cancellationToken); + + return ParseMessage(stream, messageType, buffer); + } + + /// + /// Parses a WebSocket message from the provided buffer and stream, + /// returning a strongly typed socket message instance based on the + /// message type and content. + /// + /// + /// The stream representing the source of the WebSocket message data. Used + /// to provide connection and context information for the resulting message. + /// + /// + /// The type of the WebSocket message, indicating whether the message is + /// text or binary. + /// + /// A memory buffer containing the raw message data + /// to be parsed. The buffer must be positioned at the start of the message data. + /// + /// + /// An instance of a class implementing that + /// represents the parsed message. The specific type depends on the message + /// content and type. + /// + private static ISocketMessage ParseMessage + ( + ISocketReadStream stream, + WebSocketMessageType messageType, + MemoryStream buffer + ) + { + // produce SocketMessage from buffer + buffer.Seek(0, SeekOrigin.Begin); + + if (messageType == WebSocketMessageType.Text) + { + // extract UTF‑8 text from the stream + var text = Encoding.UTF8.GetString(buffer.ToArray()); + + ISocketMessage parsed = null; + + try + { + using var doc = JsonDocument.Parse(text); + + if (doc.RootElement.TryGetProperty("text", out _)) + { + parsed = JsonSerializer.Deserialize(text); + } + else if (doc.RootElement.TryGetProperty("data", out _)) + { + parsed = JsonSerializer.Deserialize(text); + } + else + { + // default + parsed = JsonSerializer.Deserialize(text); + } + } + catch + { + // JSON invalid → fallback to plain text message + parsed = new SocketMessageText + { + Type = null, + Text = text, + ConnectionId = stream.ConnectionId, + ApplicationId = stream.SocketContext?.ApplicationContext?.ApplicationId, + SocketId = stream.SocketContext?.EndpointId?.ToString() + }; + } + + return parsed; + } + else // binary + { + var bytes = buffer.ToArray(); + + return new SocketMessageBinary + { + Type = null, + Data = bytes, + ConnectionId = stream.ConnectionId, + ApplicationId = stream.SocketContext?.ApplicationContext?.ApplicationId, + SocketId = stream.SocketContext?.EndpointId?.ToString() + }; + } + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/SocketWriteStream.cs b/src/WebExpress.WebCore/WebSocket/SocketWriteStream.cs new file mode 100644 index 0000000..69d41f0 --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/SocketWriteStream.cs @@ -0,0 +1,88 @@ +using System; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// Provides a WebSocket-based implementation of . + /// Allows writing message data in one or more frames before finalizing the message. + /// + public class SocketWriteStream : ISocketWriteStream + { + private readonly System.Net.WebSockets.WebSocket _socket; + private readonly WebSocketMessageType _messageType; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying WebSocket transport. + /// The WebSocket message type to use for all frames. + public SocketWriteStream(System.Net.WebSockets.WebSocket socket, WebSocketMessageType messageType) + { + _socket = socket; + _messageType = messageType; + } + + /// + /// Writes a chunk of data to the underlying WebSocket transport. + /// This method does not finalize the message; callers may invoke it + /// multiple times to send a message in fragments. + /// + /// The data buffer to write. + /// A token to observe while waiting for the operation to complete. + public async Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (_socket.State != WebSocketState.Open) + { + return; + } + + await _socket.SendAsync + ( + buffer, + _messageType, + endOfMessage: false, + cancellationToken + ); + } + + /// + /// Completes the message by sending the final WebSocket frame with + /// endOfMessage set to true. + /// After calling this method, no further writes are allowed. + /// + /// A token to observe while waiting for the operation to complete. + public async Task CompleteAsync(CancellationToken cancellationToken = default) + { + if (_socket.State != WebSocketState.Open) + { + return; + } + + // Send an empty frame marking the end of the message. + await _socket.SendAsync + ( + ReadOnlyMemory.Empty, + _messageType, + endOfMessage: true, + cancellationToken + ); + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, + /// or resetting unmanaged resources asynchronously. + /// + /// + /// A completed because this implementation does not + /// hold unmanaged resources. + /// + public ValueTask DisposeAsync() + { + // No unmanaged resources to release. + return ValueTask.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/SocketWriteStreamExtensions.cs b/src/WebExpress.WebCore/WebSocket/SocketWriteStreamExtensions.cs new file mode 100644 index 0000000..0005dd8 --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/SocketWriteStreamExtensions.cs @@ -0,0 +1,42 @@ +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// Provides extension methods for writing instances + /// to an . + /// + public static class SocketWriteStreamExtensions + { + /// + /// Serializes and writes the specified message to the stream and finalizes it. + /// + /// The target write stream. + /// The message to send. + /// A token to observe while waiting for the operation to complete. + public static async Task WriteMessageAsync + ( + this ISocketWriteStream stream, + ISocketMessage message, + CancellationToken cancellationToken = default + ) + { + if (message.IsBinary) + { + var binary = (message as SocketMessageBinary)?.Data ?? []; + await stream.WriteAsync(binary, cancellationToken); + } + else + { + var json = message.ToJson(); + var bytes = Encoding.UTF8.GetBytes(json); + await stream.WriteAsync(bytes, cancellationToken); + } + + await stream.CompleteAsync(cancellationToken); + } + } + +} \ No newline at end of file From 3ad2f2d1a34e8caf774a945c6254c584233f9e2a Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Thu, 25 Dec 2025 23:24:08 +0100 Subject: [PATCH 10/53] add: websocket protocol --- .../Fixture/UnitTestFixture.cs | 4 +- .../TestConditionAlwaysFalse.cs | 2 +- .../TestRenderContext.cs | 4 +- src/WebExpress.WebCore.Test/TestSocketA.cs | 19 +- .../WWW/Resources/TestResourceA.cs | 2 +- .../WWW/Resources/TestResourceB.cs | 2 +- .../WWW/Resources/TestResourceC.cs | 2 +- .../WWW/Resources/TestResourceD.cs | 2 +- .../WebSocket/UnitTestWebSocketConnection.cs | 13 + .../{Uri => WebUri}/UnitTestUri.cs | 2 +- src/WebExpress.WebCore/HttpServer.cs | 229 ++++-------- .../Internationalization/I18N.cs | 4 +- .../IInternationalizationManager.cs | 4 +- .../InternationalizationManager.cs | 4 +- src/WebExpress.WebCore/WebAsset/Asset.cs | 2 +- src/WebExpress.WebCore/WebAsset/IAsset.cs | 2 +- .../WebAttribute/MessageTypeAttribute.cs | 6 +- .../WebCondition/ICondition.cs | 2 +- .../WebEndpoint/EndpointManager.cs | 2 +- .../WebEndpoint/EndpointRegistration.cs | 2 +- .../WebEndpoint/IEndpointManager.cs | 11 +- .../FragmentConditionExtentsion.cs | 2 +- .../WebFragment/Model/FragmentItem.cs | 14 +- .../WebIdentity/IIdentityManager.cs | 6 +- .../WebIdentity/IdentityManager.cs | 6 +- .../WebMessage/HttpContext.cs | 36 +- src/WebExpress.WebCore/WebMessage/IRequest.cs | 108 ++++++ .../WebMessage/IResponse.cs | 28 ++ src/WebExpress.WebCore/WebMessage/Request.cs | 4 +- .../RequestHeaderFieldsExtensions.cs | 68 ++++ .../WebMessage/RequestWebSocket.cs | 249 +++++++++++++ src/WebExpress.WebCore/WebMessage/Response.cs | 2 +- .../WebMessage/ResponseHeaderFields.cs | 11 + .../WebMessage/ResponseSender.cs | 107 ++++++ .../WebMessage/ResponseSwitchingProtocol.cs | 25 ++ .../WebPage/IRenderContext.cs | 2 +- .../WebPage/IVisualTreeContext.cs | 4 +- .../WebPage/RenderContext.cs | 4 +- .../WebPage/VisualTreeContext.cs | 4 +- .../WebResource/IResource.cs | 2 +- .../WebResource/Resource.cs | 2 +- .../WebResource/ResourceAsset.cs | 2 +- .../WebResource/ResourceBinary.cs | 2 +- .../WebResource/ResourceFile.cs | 2 +- .../WebResource/ResourceManager.cs | 3 +- .../WebRestApi/RestApiValidator.cs | 8 +- .../WebSession/ISessionManager.cs | 2 +- .../WebSession/SessionManager.cs | 22 +- .../WebSocket/ISocketContext.cs | 4 +- src/WebExpress.WebCore/WebSocket/ISockt.cs | 7 +- .../WebSocket/Model/SocketItem.cs | 7 +- .../{ => Protocol}/ISocketMessage.cs | 25 +- .../{ => Protocol}/ISocketReadStream.cs | 39 +- .../{ => Protocol}/ISocketWriteStream.cs | 10 +- .../WebSocket/Protocol/Socket.cs | 234 ++++++++++++ .../WebSocket/Protocol/SocketCloseInfo.cs | 36 ++ .../WebSocket/Protocol/SocketCloseStatus.cs | 138 ++++++++ .../WebSocket/Protocol/SocketFrame.cs | 30 ++ .../WebSocket/Protocol/SocketFrameClose.cs | 41 +++ .../WebSocket/Protocol/SocketFrameParser.cs | 110 ++++++ .../SocketHandshakeException.cs | 2 +- .../{ => Protocol}/SocketMessageBinary.cs | 42 +-- .../{ => Protocol}/SocketMessageText.cs | 42 ++- .../SocketMessageTooLargeException.cs | 2 +- .../WebSocket/Protocol/SocketMessageType.cs | 106 ++++++ .../WebSocket/Protocol/SocketReadStream.cs | 146 ++++++++ .../SocketReadStreamExtensions.cs | 67 +--- .../WebSocket/Protocol/SocketReceiveResult.cs | 45 +++ .../WebSocket/Protocol/SocketWriteStream.cs | 68 ++++ .../SocketWriteStreamExtensions.cs | 19 +- .../WebSocket/SocketContext.cs | 3 +- .../WebSocket/SocketManager.cs | 333 ++++++++---------- .../WebSocket/SocketReadStream.cs | 127 ------- .../WebSocket/SocketWriteStream.cs | 88 ----- .../WebStatusPage/IStatusPageManager.cs | 2 +- .../WebStatusPage/StatusPageManager.cs | 2 +- 76 files changed, 2006 insertions(+), 814 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/WebSocket/UnitTestWebSocketConnection.cs rename src/WebExpress.WebCore.Test/{Uri => WebUri}/UnitTestUri.cs (99%) create mode 100644 src/WebExpress.WebCore/WebMessage/IRequest.cs create mode 100644 src/WebExpress.WebCore/WebMessage/IResponse.cs create mode 100644 src/WebExpress.WebCore/WebMessage/RequestHeaderFieldsExtensions.cs create mode 100644 src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseSender.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseSwitchingProtocol.cs rename src/WebExpress.WebCore/WebSocket/{ => Protocol}/ISocketMessage.cs (66%) rename src/WebExpress.WebCore/WebSocket/{ => Protocol}/ISocketReadStream.cs (60%) rename src/WebExpress.WebCore/WebSocket/{ => Protocol}/ISocketWriteStream.cs (76%) create mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/Socket.cs create mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseInfo.cs create mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseStatus.cs create mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketFrame.cs create mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketFrameClose.cs create mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketFrameParser.cs rename src/WebExpress.WebCore/WebSocket/{ => Protocol}/SocketHandshakeException.cs (93%) rename src/WebExpress.WebCore/WebSocket/{ => Protocol}/SocketMessageBinary.cs (71%) rename src/WebExpress.WebCore/WebSocket/{ => Protocol}/SocketMessageText.cs (71%) rename src/WebExpress.WebCore/WebSocket/{ => Protocol}/SocketMessageTooLargeException.cs (98%) create mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageType.cs create mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStream.cs rename src/WebExpress.WebCore/WebSocket/{ => Protocol}/SocketReadStreamExtensions.cs (63%) create mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketReceiveResult.cs create mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStream.cs rename src/WebExpress.WebCore/WebSocket/{ => Protocol}/SocketWriteStreamExtensions.cs (71%) delete mode 100644 src/WebExpress.WebCore/WebSocket/SocketReadStream.cs delete mode 100644 src/WebExpress.WebCore/WebSocket/SocketWriteStream.cs diff --git a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs index a310319..8b52f2b 100644 --- a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs +++ b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs @@ -98,7 +98,7 @@ public static ComponentHub CreateAndRegisterComponentHubMock() /// The content of the request. /// The URI of the request. /// A fake request for testing. - public static Request CrerateRequestMock(string content = "", string uri = "") + public static IRequest CrerateRequestMock(string content = "", string uri = "") { var context = CreateHttpContextMock(content); @@ -117,7 +117,7 @@ public static Request CrerateRequestMock(string content = "", string uri = "") ///
/// The URI of the request. /// A fake request for testing. - public static Request CrerateRequestMock(IUri uri) + public static IRequest CrerateRequestMock(IUri uri) { var context = CreateHttpContextMock(); diff --git a/src/WebExpress.WebCore.Test/TestConditionAlwaysFalse.cs b/src/WebExpress.WebCore.Test/TestConditionAlwaysFalse.cs index c77a1c1..fa9ff3e 100644 --- a/src/WebExpress.WebCore.Test/TestConditionAlwaysFalse.cs +++ b/src/WebExpress.WebCore.Test/TestConditionAlwaysFalse.cs @@ -13,7 +13,7 @@ public class TestConditionAlwaysFalse : ICondition ///
/// The request. /// True if the condition is fulfilled, false otherwise. - public bool Fulfillment(Request request) + public bool Fulfillment(IRequest request) { return false; } diff --git a/src/WebExpress.WebCore.Test/TestRenderContext.cs b/src/WebExpress.WebCore.Test/TestRenderContext.cs index 5b84420..35d6d4c 100644 --- a/src/WebExpress.WebCore.Test/TestRenderContext.cs +++ b/src/WebExpress.WebCore.Test/TestRenderContext.cs @@ -22,7 +22,7 @@ public class TestRenderContext : IRenderContext /// /// Returns the request. /// - public Request Request { get; protected set; } + public IRequest Request { get; protected set; } /// /// Initializes a new instance of the class. @@ -30,7 +30,7 @@ public class TestRenderContext : IRenderContext /// The endpoint associated with the rendering context. /// >The page context. /// The request associated with the rendering context. - public TestRenderContext(IEndpoint endpoint, IPageContext pageContext, Request request) + public TestRenderContext(IEndpoint endpoint, IPageContext pageContext, IRequest request) { Endpoint = endpoint; PageContext = pageContext; diff --git a/src/WebExpress.WebCore.Test/TestSocketA.cs b/src/WebExpress.WebCore.Test/TestSocketA.cs index cf046ad..02b1b35 100644 --- a/src/WebExpress.WebCore.Test/TestSocketA.cs +++ b/src/WebExpress.WebCore.Test/TestSocketA.cs @@ -1,5 +1,5 @@ -using System.Net.WebSockets; -using WebExpress.WebCore.WebSocket; +using WebExpress.WebCore.WebSocket; +using WebExpress.WebCore.WebSocket.Protocol; namespace WebExpress.WebCore.Test { @@ -24,7 +24,7 @@ public sealed class TestSocketA : ISocket /// public TestSocketA(ISocketContext socketContext, ISocketWriteStream stream) { - _socketContext = socketContext ?? throw new ArgumentNullException(nameof(stream), "Parameter cannot be null or empty."); + _socketContext = socketContext ?? throw new ArgumentNullException(nameof(socketContext), "Parameter cannot be null or empty."); _stream = stream ?? throw new ArgumentNullException(nameof(stream), "Parameter cannot be null or empty."); } @@ -63,21 +63,16 @@ public async Task OnReceiveAsync(ISocketMessage message, CancellationToken cance } /// - /// Handles logic to be executed when the WebSocket connection is closed. + /// Handles logic to be executed when a socket connection is disconnected. /// - /// - /// The status code indicating the reason for the WebSocket closure. - /// - /// - /// A description providing additional details about the reason for closure. May be - /// null or empty. + /// + /// Information about the reason and context for the socket disconnection. /// /// /// A task that represents the asynchronous operation. /// - public async Task OnDisconnectedAsync(WebSocketCloseStatus closeStatus, string closeDescription) + public async Task OnDisconnectedAsync(SocketCloseInfo closeInfo) { - throw new NotImplementedException(); } /// diff --git a/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceA.cs b/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceA.cs index 446c874..50f6685 100644 --- a/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceA.cs +++ b/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceA.cs @@ -26,7 +26,7 @@ public TestResourceA(IResourceContext resourceContext) /// /// The request. /// The processed response. - public Response Process(Request request) + public IResponse Process(IRequest request) { // test the request if (request is null) diff --git a/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceB.cs b/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceB.cs index 399e1a3..e2d5ac8 100644 --- a/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceB.cs +++ b/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceB.cs @@ -20,7 +20,7 @@ public TestResourceB() /// /// The request. /// The processed response. - public Response Process(Request request) + public IResponse Process(IRequest request) { // test the request if (request is null) diff --git a/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceC.cs b/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceC.cs index 41d3f68..71990fd 100644 --- a/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceC.cs +++ b/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceC.cs @@ -33,7 +33,7 @@ public TestResourceC(IResourceManager resourceManager, IResourceContext resource ///
/// The request. /// The processed response. - public Response Process(Request request) + public IResponse Process(IRequest request) { // test the request if (request is null) diff --git a/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceD.cs b/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceD.cs index 7af5d43..001a328 100644 --- a/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceD.cs +++ b/src/WebExpress.WebCore.Test/WWW/Resources/TestResourceD.cs @@ -33,7 +33,7 @@ public TestResourceD(IResourceContext resourceContext, IResourceManager resource ///
/// The request. /// The processed response. - public Response Process(Request request) + public IResponse Process(IRequest request) { // test the request if (request is null) diff --git a/src/WebExpress.WebCore.Test/WebSocket/UnitTestWebSocketConnection.cs b/src/WebExpress.WebCore.Test/WebSocket/UnitTestWebSocketConnection.cs new file mode 100644 index 0000000..99cf509 --- /dev/null +++ b/src/WebExpress.WebCore.Test/WebSocket/UnitTestWebSocketConnection.cs @@ -0,0 +1,13 @@ +namespace WebExpress.WebCore.Test.WebSocket +{ + public class UnitTestWebSocketConnection + { + [Fact] + public async Task ClientConnection() + { + //using var client = new ClientWebSocket(); + + //await client.ConnectAsync(new Uri("ws://localhost:5000/socket"), CancellationToken.None); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Uri/UnitTestUri.cs b/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs similarity index 99% rename from src/WebExpress.WebCore.Test/Uri/UnitTestUri.cs rename to src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs index 60d0583..820bb21 100644 --- a/src/WebExpress.WebCore.Test/Uri/UnitTestUri.cs +++ b/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs @@ -3,7 +3,7 @@ using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebUri; -namespace WebExpress.WebCore.Test.Uri +namespace WebExpress.WebCore.Test.WebUri { /// /// Tests an uri. diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index a919f9b..6a90669 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -7,29 +7,23 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; -using System.Buffers; -using System.Collections.Generic; using System.Diagnostics; using System.Globalization; -using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; -using System.Net.WebSockets; using System.Security.Cryptography.X509Certificates; -using System.Text; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using WebExpress.WebCore.Config; using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebEndpoint; -using WebExpress.WebCore.WebHtml; using WebExpress.WebCore.WebLog; using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebSitemap; using WebExpress.WebCore.WebSocket; +using WebExpress.WebCore.WebSocket.Protocol; using WebExpress.WebCore.WebStatusPage; using WebExpress.WebCore.WebUri; @@ -118,7 +112,10 @@ public void Start() } var logger = new LogFactory(); - var transportOptions = new OptionsWrapper(new SocketTransportOptions()); + var transportOptions = new OptionsWrapper + ( + new SocketTransportOptions() + ); var transport = new SocketTransportFactory(transportOptions, logger); var serviceCollection = new ServiceCollection(); @@ -128,10 +125,13 @@ public void Start() x.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace); x.AddProvider(logger); }); - serviceCollection.AddHttpLogging(x => - { - x.LoggingFields = Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.All; - }); + serviceCollection.AddHttpLogging + ( + x => + { + x.LoggingFields = Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.All; + } + ); var serverOptions = new OptionsWrapper(new KestrelServerOptions() { @@ -141,8 +141,12 @@ public void Start() ApplicationServices = serviceCollection.BuildServiceProvider() }); - serverOptions.Value.Limits.MaxConcurrentConnections = Config?.Limit?.ConnectionLimit > 0 ? Config?.Limit?.ConnectionLimit : serverOptions.Value.Limits.MaxConcurrentConnections; - serverOptions.Value.Limits.MaxRequestBodySize = Config?.Limit?.UploadLimit > 0 ? Config?.Limit?.UploadLimit : serverOptions.Value.Limits.MaxRequestBodySize; + serverOptions.Value.Limits.MaxConcurrentConnections = Config?.Limit?.ConnectionLimit > 0 + ? Config?.Limit?.ConnectionLimit + : serverOptions.Value.Limits.MaxConcurrentConnections; + serverOptions.Value.Limits.MaxRequestBodySize = Config?.Limit?.UploadLimit > 0 + ? Config?.Limit?.UploadLimit + : serverOptions.Value.Limits.MaxRequestBodySize; foreach (var endpoint in Config.Endpoints) { @@ -152,7 +156,11 @@ public void Start() Kestrel = new KestrelServer(serverOptions, transport, logger); Kestrel.StartAsync(this, ServerTokenSource.Token); - HttpServerContext.Log.Info(message: I18N.Translate("webexpress.webcore:httpserver.start"), args: new object[] { ExecutionTime.ToShortDateString(), ExecutionTime.ToLongTimeString() }); + HttpServerContext.Log.Info(message: I18N.Translate + ( + "webexpress.webcore:httpserver.start"), + args: [ExecutionTime.ToShortDateString(), ExecutionTime.ToLongTimeString()] + ); Started?.Invoke(this, new EventArgs()); } @@ -172,7 +180,7 @@ private void AddEndpoint(OptionsWrapper serverOptions, End var port = uri.Port; var host = asterisk ? Dns.GetHostEntry(Dns.GetHostName()) : Dns.GetHostEntry(uri.Host); var addressList = host.AddressList - .Union(asterisk ? Dns.GetHostEntry("localhost").AddressList : Array.Empty()) + .Union(asterisk ? Dns.GetHostEntry("localhost").AddressList : []) .Where(x => x.AddressFamily == AddressFamily.InterNetwork || x.AddressFamily == AddressFamily.InterNetworkV6); HttpServerContext.Log.Info(message: I18N.Translate("webexpress.webcore:httpserver.endpoint"), args: endPoint.Uri); @@ -248,11 +256,11 @@ public void Stop() /// The context of the web request. /// The previously resolved search result for the request. /// The response to be sent back to the caller. - private Response HandleClient(HttpContext context, SearchResult searchResult) + private IResponse HandleClient(HttpContext context, SearchResult searchResult) { var stopwatch = Stopwatch.StartNew(); var request = context.Request; - var response = default(Response); + var response = default(IResponse); HttpServerContext.Log.Debug(message: I18N.Translate("webexpress.webcore:httpserver.connected"), args: context.RemoteEndPoint); HttpServerContext.Log.Info(I18N.Translate @@ -362,79 +370,6 @@ private Response HandleClient(HttpContext context, SearchResult searchResult) return response; } - /// - /// Sends the response message. - /// - /// The context of the request. - /// The reply message. - /// Sending the message as a task, which is executed concurrently. - private async Task SendResponseAsync(HttpContext context, Response response) - { - try - { - var responseFeature = context.Features.Get(); - var responseBodyFeature = context.Features.Get(); - - responseFeature.StatusCode = response.Status; - responseFeature.ReasonPhrase = response.Reason; - responseFeature.Headers.KeepAlive = "true"; - - if (response.Header.Location != null) - { - responseFeature.Headers.Location = response.Header.Location; - } - - if (!string.IsNullOrWhiteSpace(response.Header.CacheControl)) - { - responseFeature.Headers.CacheControl = response.Header.CacheControl; - } - - if (!string.IsNullOrWhiteSpace(response.Header.ContentType)) - { - responseFeature.Headers.ContentType = response.Header.ContentType; - } - - if (response.Header.WWWAuthenticate) - { - responseFeature.Headers.WWWAuthenticate = "Basic realm=\"Bereich\""; - } - - if (response.Header.Cookies.Count != 0) - { - responseFeature.Headers.SetCookie = string.Join(" ", response.Header.Cookies); - } - - if (response?.Content is byte[] byteContent) - { - responseFeature.Headers.ContentLength = byteContent.Length; - await responseBodyFeature.Stream.WriteAsync(byteContent); - await responseBodyFeature.Stream.FlushAsync(); - } - else if (response?.Content is string strContent) - { - var content = context.Encoding.GetBytes(strContent); - - responseFeature.Headers.ContentLength = content.Length; - await responseBodyFeature.Stream.WriteAsync(content); - await responseBodyFeature.Stream.FlushAsync(); - } - else if (response?.Content is IHtmlNode htmlContent) - { - var content = context.Encoding.GetBytes(htmlContent?.ToString()); - - responseFeature.Headers.ContentLength = content.Length; - await responseBodyFeature.Stream.WriteAsync(content); - await responseBodyFeature.Stream.FlushAsync(); - } - - responseBodyFeature.Stream.Close(); - } - catch (Exception ex) - { - HttpServerContext.Log.Error(context.RemoteEndPoint + ": " + ex.Message); - } - } - /// /// Creates a status page. /// @@ -442,7 +377,7 @@ private async Task SendResponseAsync(HttpContext context, Response response) /// The request. /// The plugin by searching the status page or null. /// The response. - private static Response CreateStatusPage(string message, Request request, SearchResult searchResult = null) where T : Response, new() + private static Response CreateStatusPage(string message, IRequest request, SearchResult searchResult = null) where T : Response, new() { var response = new T() as Response; var statusPageManager = WebEx.ComponentHub.StatusPageManager; @@ -488,7 +423,9 @@ private async Task SendResponseAsync(HttpContext context, Response response) /// /// Create an HttpContext with a collection of HTTP features. /// - /// A collection of HTTP features to use to create the HttpContext. + /// + /// A collection of HTTP features to use to create the HttpContext. + /// /// The HttpContext created. public HttpContext CreateContext(IFeatureCollection contextFeatures) { @@ -502,83 +439,36 @@ public HttpContext CreateContext(IFeatureCollection contextFeatures) } } - /// - /// Determines whether the incoming context represents a websocket upgrade request. - /// - /// The incoming http context. - /// True when the request is a websocket upgrade request, false otherwise. - private static bool IsWebSocketRequest(HttpContext httpContext) - { - var wsFeature = httpContext.Features.Get(); - if (wsFeature != null) - { - return true; - } - - try - { - var upgrade = httpContext.Request?.Header?.Upgrade; - if (!string.IsNullOrWhiteSpace(upgrade) && upgrade.Equals("websocket", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - catch - { - // ignore - } - - return false; - } - - /// - /// Determines whether the provided searchResult represents a websocket endpoint by checking for IWebSocketContext. - /// - /// The sitemap search result. - /// True when the search result indicates a websocket endpoint. - private static bool IsWebSocketSearchResult(SearchResult searchResult) - { - if (searchResult == null) - { - return false; - } - - // endpoint context is websocket context - if (searchResult.EndpointContext is ISocketContext) - { - return true; - } - - return false; - } - /// /// Processes an http context asynchronously. - /// If the request is a websocket upgrade to a configured endpoint, handle websocket lifecycle instead of request/response. + /// If the request is a websocket upgrade to a configured endpoint, handle + /// websocket lifecycle instead of request/response. /// Handles missing sitemap endpoints directly here. /// /// The http context that the operation processes. /// Provides an asynchronous operation that handles the http context. public async Task ProcessRequestAsync(HttpContext httpContext) { + var responseSender = new ResponseSender(); + if (httpContext is HttpExceptionContext exceptionContext) { var message = "404" + - $"

Message

{exceptionContext.Exception.Message}

" + - $"
Source
{exceptionContext.Exception.Source}

" + - $"
StackTrace
{exceptionContext.Exception.StackTrace.Replace("\n", "
\n")}

" + - $"
InnerException
{exceptionContext.Exception.InnerException?.ToString().Replace("\n", "
\n")}" + - ""; + $"

Message

{exceptionContext.Exception.Message}

" + + $"
Source
{exceptionContext.Exception.Source}

" + + $"
StackTrace
{exceptionContext.Exception.StackTrace.Replace("\n", "
\n")}

" + + $"
InnerException
{exceptionContext.Exception.InnerException?.ToString().Replace("\n", "
\n")}" + + ""; var response500 = CreateStatusPage(message, httpContext?.Request); - await SendResponseAsync(exceptionContext, response500); + await responseSender.SendAsync(exceptionContext, response500); return; } - var culture = httpContext.Request.Culture; - var searchResult = WebEx.ComponentHub.SitemapManager.SearchResource(httpContext.Uri, new SearchContext() + var culture = httpContext?.Request.Culture; + var searchResult = WebEx.ComponentHub.SitemapManager.SearchResource(httpContext?.Uri, new SearchContext() { Culture = culture, HttpContext = httpContext, @@ -587,23 +477,30 @@ public async Task ProcessRequestAsync(HttpContext httpContext) if (searchResult == null) { - var notFoundResponse = CreateStatusPage("Resource not found", httpContext.Request); - await SendResponseAsync(httpContext, notFoundResponse); + var notFoundResponse = CreateStatusPage + ( + "Resource not found", + httpContext.Request + ); + + await responseSender.SendAsync(httpContext, notFoundResponse); + return; } - if (IsWebSocketSearchResult(searchResult) && IsWebSocketRequest(httpContext)) + if (httpContext.Request is RequestWebSocket) { // try to obtain websocket context and optional handler var socketContext = searchResult.EndpointContext as ISocketContext; await HandleWebSocketAsync(httpContext, socketContext); + return; } var response = HandleClient(httpContext, searchResult); - await SendResponseAsync(httpContext, response); + await responseSender.SendAsync(httpContext, response); } /// @@ -620,13 +517,15 @@ public async Task ProcessRequestAsync(HttpContext httpContext) /// public async Task HandleWebSocketAsync(HttpContext httpContext, ISocketContext socketContext) { + var responseSender = new ResponseSender(); var socketManager = WebEx.ComponentHub.SocketManager; // validate that the request is a websocket upgrade - if (!IsWebSocketRequest(httpContext)) + if (httpContext.Request is not RequestWebSocket) { // websocket not requested by client; return 400 Bad Request - await SendResponseAsync(httpContext, new ResponseBadRequest(new StatusMessage("WebSocket upgrade required"))); + await responseSender.SendAsync(httpContext, new ResponseBadRequest(new StatusMessage("WebSocket upgrade required"))); + return; } @@ -640,13 +539,13 @@ public async Task HandleWebSocketAsync(HttpContext httpContext, ISocketContext s var response = new ResponseUpgradeRequired(new StatusMessage("Invalid WebSocket handshake headers")); response.Header.Upgrade = "websocket"; - await SendResponseAsync(httpContext, response); + await responseSender.SendAsync(httpContext, response); } - catch (SocketMessageTooLargeException ex) - { + catch (SocketMessageTooLargeException ex) + { // the client sent a WebSocket message exceeding the configured maximum size -> respond with 413 - var response = new ResponsePayloadTooLarge(new StatusMessage($"WebSocket message exceeds the maximum allowed size of {ex.MaxSize} bytes.") ); - await SendResponseAsync(httpContext, response); + var response = new ResponsePayloadTooLarge(new StatusMessage($"WebSocket message exceeds the maximum allowed size of {ex.MaxSize} bytes.")); + await responseSender.SendAsync(httpContext, response); } catch (SocketException ex) { @@ -654,7 +553,7 @@ public async Task HandleWebSocketAsync(HttpContext httpContext, ISocketContext s // return 500 when socket error var response = new ResponseInternalServerError(new StatusMessage("A transport-level socket error occurred during WebSocket communication.")); - await SendResponseAsync(httpContext, response); + await responseSender.SendAsync(httpContext, response); } catch (Exception ex) { @@ -663,7 +562,7 @@ public async Task HandleWebSocketAsync(HttpContext httpContext, ISocketContext s // return 500 when handshake did not succeed and no websocket established var response = new ResponseInternalServerError(new StatusMessage("An unexpected server error occurred during WebSocket processing.")); - await SendResponseAsync(httpContext, response); + await responseSender.SendAsync(httpContext, response); } } diff --git a/src/WebExpress.WebCore/Internationalization/I18N.cs b/src/WebExpress.WebCore/Internationalization/I18N.cs index ec8a0f7..7a37de9 100644 --- a/src/WebExpress.WebCore/Internationalization/I18N.cs +++ b/src/WebExpress.WebCore/Internationalization/I18N.cs @@ -36,7 +36,7 @@ public static string Translate(string key, params object[] args) /// The request with the language to use. /// The internationalization key. /// The value of the key in the current language. - public static string Translate(Request request, string key) + public static string Translate(IRequest request, string key) { return WebEx.ComponentHub?.InternationalizationManager.Translate(request, key) ?? key; } @@ -48,7 +48,7 @@ public static string Translate(Request request, string key) /// The internationalization key. /// The formatting arguments. /// The value of the key in the current language. - public static string Translate(Request request, string key, params object[] args) + public static string Translate(IRequest request, string key, params object[] args) { return WebEx.ComponentHub?.InternationalizationManager?.Translate(request, key, args) ?? key; } diff --git a/src/WebExpress.WebCore/Internationalization/IInternationalizationManager.cs b/src/WebExpress.WebCore/Internationalization/IInternationalizationManager.cs index cfed473..21a1c3d 100644 --- a/src/WebExpress.WebCore/Internationalization/IInternationalizationManager.cs +++ b/src/WebExpress.WebCore/Internationalization/IInternationalizationManager.cs @@ -30,7 +30,7 @@ public interface IInternationalizationManager : IComponentManager /// The request with the language to use. /// The internationalization key. /// The value of the key in the current language. - string Translate(Request request, string key); + string Translate(IRequest request, string key); /// /// Translates a given key to the specified language. @@ -39,7 +39,7 @@ public interface IInternationalizationManager : IComponentManager /// The internationalization key. /// The formatting arguments. /// The value of the key in the current language. - string Translate(Request request, string key, params object[] args); + string Translate(IRequest request, string key, params object[] args); /// /// Translates a given key to the specified language. diff --git a/src/WebExpress.WebCore/Internationalization/InternationalizationManager.cs b/src/WebExpress.WebCore/Internationalization/InternationalizationManager.cs index 110672f..e29fb10 100644 --- a/src/WebExpress.WebCore/Internationalization/InternationalizationManager.cs +++ b/src/WebExpress.WebCore/Internationalization/InternationalizationManager.cs @@ -182,7 +182,7 @@ public string Translate(string key, params object[] args) /// The request with the language to use. /// The internationalization key. /// The value of the key in the current language. - public string Translate(Request request, string key) + public string Translate(IRequest request, string key) { return Translate(request.Culture, null, key); } @@ -194,7 +194,7 @@ public string Translate(Request request, string key) /// The internationalization key. /// The formatting arguments. /// The value of the key in the current language. - public string Translate(Request request, string key, params object[] args) + public string Translate(IRequest request, string key, params object[] args) { return string.Format(Translate(request, key), args); } diff --git a/src/WebExpress.WebCore/WebAsset/Asset.cs b/src/WebExpress.WebCore/WebAsset/Asset.cs index 7486ba6..e2b6970 100644 --- a/src/WebExpress.WebCore/WebAsset/Asset.cs +++ b/src/WebExpress.WebCore/WebAsset/Asset.cs @@ -46,7 +46,7 @@ public Asset(IComponentHub componentHub, IAssetContext assetContext, IHttpServer /// /// The request. /// The response. - public Response Process(Request request) + public Response Process(IRequest request) { if (_data is null) { diff --git a/src/WebExpress.WebCore/WebAsset/IAsset.cs b/src/WebExpress.WebCore/WebAsset/IAsset.cs index 78d8e5c..12855c2 100644 --- a/src/WebExpress.WebCore/WebAsset/IAsset.cs +++ b/src/WebExpress.WebCore/WebAsset/IAsset.cs @@ -13,6 +13,6 @@ public interface IAsset : IEndpoint /// /// The request. /// The response. - Response Process(Request request); + Response Process(IRequest request); } } diff --git a/src/WebExpress.WebCore/WebAttribute/MessageTypeAttribute.cs b/src/WebExpress.WebCore/WebAttribute/MessageTypeAttribute.cs index 02d624e..8dfb6d9 100644 --- a/src/WebExpress.WebCore/WebAttribute/MessageTypeAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/MessageTypeAttribute.cs @@ -1,5 +1,5 @@ using System; -using System.Net.WebSockets; +using WebExpress.WebCore.WebSocket.Protocol; namespace WebExpress.WebCore.WebAttribute { @@ -12,13 +12,13 @@ public class MessageTypeAttribute : Attribute, ISocketAttribute /// /// Returns the message type code. /// - public WebSocketMessageType MessageType { get; } + public SocketMessageType MessageType { get; } /// /// Initializes a new instance of the class with the specified status code. /// /// The message type. - public MessageTypeAttribute(WebSocketMessageType messageType) + public MessageTypeAttribute(SocketMessageType messageType) { MessageType = messageType; } diff --git a/src/WebExpress.WebCore/WebCondition/ICondition.cs b/src/WebExpress.WebCore/WebCondition/ICondition.cs index d0e75d4..4e1ad44 100644 --- a/src/WebExpress.WebCore/WebCondition/ICondition.cs +++ b/src/WebExpress.WebCore/WebCondition/ICondition.cs @@ -12,6 +12,6 @@ public interface ICondition /// /// The request. /// True if the condition is met, false otherwise. - bool Fulfillment(Request request); + bool Fulfillment(IRequest request); } } diff --git a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs index 6dba8da..cdb3d03 100644 --- a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs +++ b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs @@ -106,7 +106,7 @@ public IEnumerable GetEndpoints(Type endpointType, IApplicatio /// The request to handle. /// The context of the endpoint handling the request. /// The response generated by the endpoint. - public Response HandleRequest(Request request, IEndpointContext endpointContext) + public IResponse HandleRequest(IRequest request, IEndpointContext endpointContext) { var registration = _registrations .Where(x => x.Key == endpointContext?.GetType()) diff --git a/src/WebExpress.WebCore/WebEndpoint/EndpointRegistration.cs b/src/WebExpress.WebCore/WebEndpoint/EndpointRegistration.cs index 72b82b6..9d01d65 100644 --- a/src/WebExpress.WebCore/WebEndpoint/EndpointRegistration.cs +++ b/src/WebExpress.WebCore/WebEndpoint/EndpointRegistration.cs @@ -33,6 +33,6 @@ public class EndpointRegistration /// /// Returns or sets the function to handle requests. /// - public Func HandleRequest { get; set; } + public Func HandleRequest { get; set; } } } diff --git a/src/WebExpress.WebCore/WebEndpoint/IEndpointManager.cs b/src/WebExpress.WebCore/WebEndpoint/IEndpointManager.cs index bf11c67..780b046 100644 --- a/src/WebExpress.WebCore/WebEndpoint/IEndpointManager.cs +++ b/src/WebExpress.WebCore/WebEndpoint/IEndpointManager.cs @@ -37,21 +37,12 @@ public interface IEndpointManager : IComponentManager /// An enumeration of endpoint contexts. IEnumerable GetEndpoints(Type endpointType, IApplicationContext applicationContext = null); - ///// - ///// Creates a new instance or if caching is active, a possibly existing instance is returned. - ///// - ///// The endpoint context. - ///// The uri. - ///// The search context. - ///// The created endpoint. - //IEndpoint CreateEndpoint(IEndpointContext endpointContext, UriResource uri, SearchContext searchContext); - /// /// Handles a request and returns a response. /// /// The request to handle. /// The context of the endpoint handling the request. /// The response generated by the endpoint. - Response HandleRequest(Request request, IEndpointContext endpointContext); + IResponse HandleRequest(IRequest request, IEndpointContext endpointContext); } } diff --git a/src/WebExpress.WebCore/WebFragment/FragmentConditionExtentsion.cs b/src/WebExpress.WebCore/WebFragment/FragmentConditionExtentsion.cs index e8085db..8cc85c1 100644 --- a/src/WebExpress.WebCore/WebFragment/FragmentConditionExtentsion.cs +++ b/src/WebExpress.WebCore/WebFragment/FragmentConditionExtentsion.cs @@ -15,7 +15,7 @@ public static class FragmentConditionExtentsion /// The collection of conditions to check. /// The request to evaluate the conditions against. /// True if all conditions are fulfilled; otherwise, false. - public static bool Check(this IEnumerable conditions, Request request) + public static bool Check(this IEnumerable conditions, IRequest request) { foreach (var condition in conditions) { diff --git a/src/WebExpress.WebCore/WebFragment/Model/FragmentItem.cs b/src/WebExpress.WebCore/WebFragment/Model/FragmentItem.cs index b2a5463..e5be93e 100644 --- a/src/WebExpress.WebCore/WebFragment/Model/FragmentItem.cs +++ b/src/WebExpress.WebCore/WebFragment/Model/FragmentItem.cs @@ -82,18 +82,18 @@ public FragmentItem(IComponentHub componentHub, IHttpServerContext httpServerCon /// Create the instance of the component. ///
/// The page context. - public TFragment CreateInstance(IPageContext pageContext = null) + public TFragment CreateInstance(IPageContext pageContext = null) where TFragment : IFragmentBase { var instance = _instance; instance ??= ComponentActivator.CreateInstance ( - FragmentClass, - FragmentContext, - _httpServerContext, - _componentHub, - FragmentContext, + FragmentClass, + FragmentContext, + _httpServerContext, + _componentHub, + FragmentContext, pageContext ); @@ -157,7 +157,7 @@ public IHtmlNode Render(TRenderContext renderContex ///
/// The request. /// True if the fragment is active, false otherwise. - public bool CheckConditions(Request request) + public bool CheckConditions(IRequest request) { return !FragmentContext.Conditions.Any() || FragmentContext.Conditions.All(x => x.Fulfillment(request)); } diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs index 9b4af9f..7e924ed 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs @@ -39,20 +39,20 @@ public interface IIdentityManager : IComponentManager /// The identity. /// The password. /// True if successful, false otherwise. - bool Login(Request request, IIdentity identity, SecureString password); + bool Login(IRequest request, IIdentity identity, SecureString password); /// /// Logout an identity. /// /// The request. - void Logout(Request request); + void Logout(IRequest request); /// /// Returns the current signed-in identity based on the provided request. /// /// The request to get the current identity for. /// The current signed-in identity. - IIdentity GetCurrentIdentity(Request request); + IIdentity GetCurrentIdentity(IRequest request); /// /// Checks if the specified identity has the given permission. diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs index 5457981..c368881 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs @@ -383,7 +383,7 @@ private void OnAddApplication(object sender, IApplicationContext e) /// The identity. /// The password. /// True if successful, false otherwise. - public bool Login(Request request, IIdentity identity, SecureString password) + public bool Login(IRequest request, IIdentity identity, SecureString password) { if (identity?.PasswordHash == ComputeHash(password)) { @@ -405,7 +405,7 @@ public bool Login(Request request, IIdentity identity, SecureString password) /// Logout an identity. /// /// The request. - public void Logout(Request request) + public void Logout(IRequest request) { var session = _componentHub.SessionManager.GetSession(request); session.RemoveProperty(); @@ -416,7 +416,7 @@ public void Logout(Request request) ///
/// The request to get the current identity for. /// The current signed-in identity. - public IIdentity GetCurrentIdentity(Request request) + public IIdentity GetCurrentIdentity(IRequest request) { var session = _componentHub.SessionManager.GetSession(request); var authentification = session.GetProperty(); diff --git a/src/WebExpress.WebCore/WebMessage/HttpContext.cs b/src/WebExpress.WebCore/WebMessage/HttpContext.cs index b76dbf9..3efea25 100644 --- a/src/WebExpress.WebCore/WebMessage/HttpContext.cs +++ b/src/WebExpress.WebCore/WebMessage/HttpContext.cs @@ -2,6 +2,7 @@ using System; using System.Net; using System.Text; +using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebMessage { @@ -23,7 +24,7 @@ public class HttpContext /// /// Returns the request. /// - public Request Request { get; protected set; } + public IRequest Request { get; protected set; } /// /// Gets the ip address and port number of the server to which the request is made. @@ -74,9 +75,40 @@ public HttpContext(IFeatureCollection contextFeatures, IHttpServerContext httpSe LocalEndPoint = new IPEndPoint(connectionFeature.LocalIpAddress, connectionFeature.LocalPort); RemoteEndPoint = new IPEndPoint(connectionFeature.RemoteIpAddress, connectionFeature.RemotePort); - Encoding = requestFeature.Headers.ContentEncoding.Count != 0 ? Encoding.GetEncoding(requestFeature.Headers.ContentEncoding) : Encoding.Default; + Encoding = requestFeature.Headers.ContentEncoding.Count != 0 + ? Encoding.GetEncoding(requestFeature.Headers.ContentEncoding) + : Encoding.Default; Uri = new Uri(baseUri, requestFeature.RawTarget); + // determine WebSocket upgrade + string upgradeHeader = requestFeature.Headers["Upgrade"]; + + if + ( + !string.IsNullOrEmpty(upgradeHeader) && + upgradeHeader.Equals("websocket", StringComparison.OrdinalIgnoreCase) + ) + { + Request = new RequestWebSocket + ( + httpServerContext, + null, + null, + header, + RequestMethod.GET, + requestFeature.Protocol, + requestFeature.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) + ? UriScheme.Https + : UriScheme.Http, + LocalEndPoint, + RemoteEndPoint, + requestFeature.Headers["Sec-WebSocket-Key"], + requestFeature.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) + ); + + return; + } + Request = new Request(contextFeatures, header, httpServerContext); } } diff --git a/src/WebExpress.WebCore/WebMessage/IRequest.cs b/src/WebExpress.WebCore/WebMessage/IRequest.cs new file mode 100644 index 0000000..4365900 --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/IRequest.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Net; +using WebExpress.WebCore.WebParameter; +using WebExpress.WebCore.WebSession.Model; +using WebExpress.WebCore.WebUri; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Defines the contract for accessing HTTP request information (see RFC 2616). + /// + public interface IRequest + { + /// + /// The context of the web server. + /// + IHttpServerContext HttpServerContext { get; } + + /// + /// Returns the request method (e.g. POST). + /// + RequestMethod Method { get; } + + /// + /// Returns the uri. + /// + UriEndpoint Uri { get; internal set; } + + /// + /// Returns the session. + /// + Session Session { get; } + + /// + /// Returns the http version. + /// + string Protocoll { get; } + + /// + /// Returns the options from the header. + /// + RequestHeaderFields Header { get; } + + /// + /// Returns the ip address and port number of the server to which the request is made. + /// + EndPoint LocalEndPoint { get; } + + /// + /// Returns the ip address and port number of the client from which the request originated. + /// + EndPoint RemoteEndPoint { get; } + + /// + /// Returns a boolean value that indicates whether the tcp connection used to send the request uses the secure sockets layer (ssl) protocol. + /// + bool IsSecureConnection { get; } + + /// + /// Returns the schema. This can be http or https. + /// + UriScheme Scheme { get; } + + /// + /// Returns the request identifier of the incoming http request. + /// + string RequestTraceIdentifier { get; } + + /// + /// Returns the culture. + /// + CultureInfo Culture { get; } + + /// + /// Adds several parameters. + /// + /// The parameters. + void AddParameter(IEnumerable param); + + /// + /// Adds one parameter. + /// + /// The parameter. + void AddParameter(Parameter param); + + /// + /// Returns a parameter by name. + /// + /// The name of the parameter. + /// The value. + IParameter GetParameter(string name); + + /// + /// Returns a parameter by type. + /// + /// The parameter. + /// The value. + IParameter GetParameter() where TParameter : IParameter; + + /// + /// Checks whether a parameter exists. + /// + /// The name of the parameter. + /// True if the parameter is present, false otherwise. + bool HasParameter(string name); + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebMessage/IResponse.cs b/src/WebExpress.WebCore/WebMessage/IResponse.cs new file mode 100644 index 0000000..ef63b15 --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/IResponse.cs @@ -0,0 +1,28 @@ +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Defines the contract for a response according to RFC 2616 Section 6. + /// + public interface IResponse + { + /// + /// Returns the response header fields. + /// + ResponseHeaderFields Header { get; } + + /// + /// Returns or sets the response content. + /// + object Content { get; set; } + + /// + /// Returns the status code of the response. + /// + int Status { get; } + + /// + /// Returns or sets the reason phrase of the response. + /// + string Reason { get; } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebMessage/Request.cs b/src/WebExpress.WebCore/WebMessage/Request.cs index 742201c..e560f2f 100644 --- a/src/WebExpress.WebCore/WebMessage/Request.cs +++ b/src/WebExpress.WebCore/WebMessage/Request.cs @@ -19,7 +19,7 @@ namespace WebExpress.WebCore.WebMessage /// See RFC 2616, The Request class encapsulates and extends the /// original request of the HttpListener call. /// - public class Request + public class Request : IRequest { private readonly ParameterDictionary _param = []; @@ -36,7 +36,7 @@ public class Request /// /// Returns the uri. /// - public UriEndpoint Uri { get; internal set; } + public UriEndpoint Uri { get; set; } /// /// Returns the session. diff --git a/src/WebExpress.WebCore/WebMessage/RequestHeaderFieldsExtensions.cs b/src/WebExpress.WebCore/WebMessage/RequestHeaderFieldsExtensions.cs new file mode 100644 index 0000000..a258ee4 --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/RequestHeaderFieldsExtensions.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Provides extension methods for converting strongly typed request header + /// fields to formats suitable for WebSocket handshake processing. + /// + public static class RequestHeaderFieldsExtensions + { + /// + /// Converts the strongly typed RequestHeaderFields into a dictionary + /// suitable for WebSocket handshake processing. + /// + public static Dictionary ToDictionary(this RequestHeaderFields header) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + + void Add(string name, string value) + { + if (!string.IsNullOrEmpty(value)) + { + dict[name] = value; + } + } + + // standard http headers + Add("Host", header.Host); + Add("Connection", header.Connection); + Add("Content-Type", header.ContentType); + Add("Content-Length", header.ContentLength > 0 ? header.ContentLength.ToString() : null); + Add("Content-Language", header.ContentLanguage); + Add("Content-Encoding", header.ContentEncoding?.WebName); + Add("User-Agent", header.UserAgent); + Add("Referer", header.Referer); + + // accept headers + if (header.Accept != null) + Add("Accept", string.Join(", ", header.Accept)); + + Add("Accept-Encoding", header.AcceptEncoding); + + if (header.AcceptLanguage != null) + Add("Accept-Language", string.Join(", ", header.AcceptLanguage)); + + // cookies + if (header.Cookies != null) + { + var cookieString = string.Join("; ", header.Cookies.Select(c => $"{c.Name}={c.Value}")); + Add("Cookie", cookieString); + } + + // authorization + if (header.Authorization != null) + Add("Authorization", header.Authorization.ToString()); + + // websocket-specific headers + Add("Upgrade", header.Upgrade); + Add("Sec-WebSocket-Key", header.SecWebSocketKey); + Add("Sec-WebSocket-Protocol", header.SecWebSocketProtocol); + Add("Sec-WebSocket-Version", header.SecWebSocketVersion); + + return dict; + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs b/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs new file mode 100644 index 0000000..767d37a --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using WebExpress.WebCore.WebParameter; +using WebExpress.WebCore.WebSession.Model; +using WebExpress.WebCore.WebUri; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Represents a request for a WebSocket connection. + /// + public class RequestWebSocket : IRequest + { + private readonly ParameterDictionary _param = []; + + /// + /// The context of the web server. + /// + public IHttpServerContext HttpServerContext { get; protected set; } + + /// + /// Returns the request method (typically GET for WebSocket handshake). + /// + public RequestMethod Method { get; private set; } + + /// + /// Returns the URI. + /// + public UriEndpoint Uri { get; set; } + + /// + /// Returns the session. + /// + public Session Session { get; private set; } + + /// + /// Returns the HTTP version. + /// + public string Protocoll { get; private set; } + + /// + /// Returns the header fields. + /// + public RequestHeaderFields Header { get; private set; } + + /// + /// Returns the server's local endpoint. + /// + public EndPoint LocalEndPoint { get; private set; } + + /// + /// Returns the client's remote endpoint. + /// + public EndPoint RemoteEndPoint { get; private set; } + + /// + /// Indicates whether the connection is secured (wss). + /// + public bool IsSecureConnection { get; private set; } + + /// + /// Returns the scheme (ws or wss). + /// + public UriScheme Scheme { get; private set; } + + /// + /// Returns the request identifier. + /// + public string RequestTraceIdentifier { get; private set; } + + /// + /// Returns the culture. + /// + public CultureInfo Culture + { + get + { + try + { + var languages = Header?.AcceptLanguage?.FirstOrDefault(); + var language = languages?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); + return new CultureInfo(language); + } + catch + { + return HttpServerContext.Culture ?? CultureInfo.CurrentCulture; + } + } + } + + /// + /// Returns the current WebSocket message type. + /// + public string WebSocketMessageType { get; internal set; } + + /// + /// Returns true, if the WebSocket is open. + /// + public bool IsWebSocketOpen { get; internal set; } + + /// + /// Initializes a new instance for a WebSocket request. + /// Use this after WebSocket handshake is established. + /// + /// The server context. + /// The endpoint URI. + /// The session. + /// Header fields. + /// Method (typically GET at handshake). + /// HTTP version. + /// The URI scheme (ws, wss). + /// The local endpoint. + /// The remote endpoint. + /// Trace identifier. + /// Whether the connection is secure. + internal RequestWebSocket + ( + IHttpServerContext httpServerContext, + UriEndpoint uri, + Session session, + RequestHeaderFields header, + RequestMethod method, + string protocoll, + UriScheme scheme, + EndPoint localEndPoint, + EndPoint remoteEndPoint, + string traceId, + bool isSecureConnection + ) + { + HttpServerContext = httpServerContext; + Uri = uri; + Session = session; + Header = header; + Method = method; + Protocoll = protocoll; + Scheme = scheme; + LocalEndPoint = localEndPoint; + RemoteEndPoint = remoteEndPoint; + RequestTraceIdentifier = traceId; + IsSecureConnection = isSecureConnection; + + // WebSocket specific defaults + WebSocketMessageType = null; + IsWebSocketOpen = true; + + ParseSessionParams(); + } + + /// + /// Adds several parameters. + /// + /// The parameters. + public void AddParameter(IEnumerable param) + { + foreach (var p in param) + { + AddParameter(p); + } + } + + /// + /// Adds one parameter. + /// + /// The parameter. + public void AddParameter(Parameter param) + { + var key = param.Key.ToLower(); + + if (!_param.TryAdd(key, param)) + { + _param[key] = param; + } + } + + /// + /// Returns a parameter by name. + /// + /// The name of the parameter. + /// The value. + public IParameter GetParameter(string name) + { + if (!string.IsNullOrWhiteSpace(name) && HasParameter(name)) + { + return _param[name.ToLower()]; + } + + return null; + } + + /// + /// Returns a parameter by type. + /// + /// The parameter type. + /// The value. + public IParameter GetParameter() + where TParameter : IParameter + { + var parameter = Parameter.GetParameter(); + if (parameter is not null + && !string.IsNullOrWhiteSpace(parameter.Key) + && HasParameter(parameter.Key)) + { + var p = _param[parameter.Key.ToLower()]; + parameter.Value = p.Value; + parameter.Scope = p.Scope; + + return parameter; + } + + return null; + } + + /// + /// Checks whether a parameter exists. + /// + /// The name of the parameter. + /// True if the parameter is present, false otherwise. + public bool HasParameter(string name) + { + if (name is null) + { + return false; + } + + return _param.ContainsKey(name.ToLower()); + } + + /// + /// Parse the session parameters. + /// + private void ParseSessionParams() + { + Session = WebEx.ComponentHub?.SessionManager?.GetSession(this); + + var property = Session?.GetProperty(); + if (property is not null && property.Params is not null) + { + foreach (var param in property.Params) + { + AddParameter(new Parameter(param.Key?.ToLower(), param.Value.Value, ParameterScope.Session)); + } + } + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebMessage/Response.cs b/src/WebExpress.WebCore/WebMessage/Response.cs index f4eefb6..8774377 100644 --- a/src/WebExpress.WebCore/WebMessage/Response.cs +++ b/src/WebExpress.WebCore/WebMessage/Response.cs @@ -6,7 +6,7 @@ namespace WebExpress.WebCore.WebMessage /// /// Represents a response according to RFC 2616 Section 6. /// - public abstract class Response + public abstract class Response : IResponse { /// /// Returns the response header fields. diff --git a/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs b/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs index 84553c0..642d06d 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs @@ -59,6 +59,17 @@ public class ResponseHeaderFields /// public string Upgrade { get; set; } + /// + /// Returns the connection. Keep-Alive or close. + /// + public string Connection { get; set; } + + /// + /// Returns or sets the value of the Sec-WebSocket-Accept header used in + /// the WebSocket handshake response. + /// + public string SecWebSocketAccept { get; set; } + /// /// Initializes a new instance of the class. /// diff --git a/src/WebExpress.WebCore/WebMessage/ResponseSender.cs b/src/WebExpress.WebCore/WebMessage/ResponseSender.cs new file mode 100644 index 0000000..77ed2b3 --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/ResponseSender.cs @@ -0,0 +1,107 @@ +using Microsoft.AspNetCore.Http.Features; +using System; +using System.Threading.Tasks; +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Provides functionality for sending HTTP responses. + /// + public class ResponseSender + { + /// + /// Sends the specified response message asynchronously to the provided context. + /// + /// The context of the request. + /// The reply message. + /// Indicates whether the connection should be kept alive after sending the response. + /// A task representing the asynchronous operation. + public async Task SendAsync(HttpContext context, IResponse response, bool keepAlive = false) + { + try + { + var responseFeature = context.Features.Get(); + var responseBodyFeature = context.Features.Get(); + + responseFeature.StatusCode = response.Status; + responseFeature.ReasonPhrase = response.Reason; + responseFeature.Headers.KeepAlive = "true"; + + if (response.Header.Location != null) + { + responseFeature.Headers.Location = response.Header.Location; + } + + if (!string.IsNullOrWhiteSpace(response.Header.CacheControl)) + { + responseFeature.Headers.CacheControl = response.Header.CacheControl; + } + + if (!string.IsNullOrWhiteSpace(response.Header.ContentType)) + { + responseFeature.Headers.ContentType = response.Header.ContentType; + } + + if (response.Header.WWWAuthenticate) + { + responseFeature.Headers.WWWAuthenticate = "Basic realm=\"Bereich\""; + } + + if (response.Header.Cookies.Count != 0) + { + responseFeature.Headers.SetCookie = string.Join(" ", response.Header.Cookies); + } + + if (!string.IsNullOrWhiteSpace(response.Header.Upgrade)) + { + responseFeature.Headers.Upgrade = response.Header.Upgrade; + } + + if (!string.IsNullOrWhiteSpace(response.Header.Connection)) + { + responseFeature.Headers.Connection = response.Header.Connection; + } + + if (!string.IsNullOrWhiteSpace(response.Header.SecWebSocketAccept)) + { + responseFeature.Headers.SecWebSocketAccept = response.Header.SecWebSocketAccept; + } + + if (response?.Content is byte[] byteContent) + { + responseFeature.Headers.ContentLength = byteContent.Length; + await responseBodyFeature.Stream.WriteAsync(byteContent); + await responseBodyFeature.Stream.FlushAsync(); + } + else if (response?.Content is string strContent) + { + var content = context.Encoding.GetBytes(strContent); + + responseFeature.Headers.ContentLength = content.Length; + await responseBodyFeature.Stream.WriteAsync(content); + await responseBodyFeature.Stream.FlushAsync(); + } + else if (response?.Content is IHtmlNode htmlContent) + { + var content = context.Encoding.GetBytes(htmlContent?.ToString()); + + responseFeature.Headers.ContentLength = content.Length; + await responseBodyFeature.Stream.WriteAsync(content); + await responseBodyFeature.Stream.FlushAsync(); + } + + if (!keepAlive) + { + responseBodyFeature.Stream.Close(); + } + } + catch (Exception ex) + { + // write error to server log + var log = WebEx.ComponentHub.LogManager.DefaultLog; + log.Error(context.RemoteEndPoint + ": " + ex.Message); + } + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebMessage/ResponseSwitchingProtocol.cs b/src/WebExpress.WebCore/WebMessage/ResponseSwitchingProtocol.cs new file mode 100644 index 0000000..e457131 --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/ResponseSwitchingProtocol.cs @@ -0,0 +1,25 @@ +using WebExpress.WebCore.WebAttribute; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Represents a response for a protocol switch (101) according to RFC 2616 Section 6. + /// + [StatusCode(101)] + public class ResponseSwitchingProtocols : Response + { + /// + /// Initializes a new instance of the class. + /// + /// The Connection header value. + /// The Upgrade header value. + /// The Sec-WebSocket-Accept header value. + public ResponseSwitchingProtocols(string connection, string upgrade, string _secWebSocketAccept) + { + Reason = "Switching Protocols"; + Header.Upgrade = upgrade; + Header.Connection = connection; + Header.SecWebSocketAccept = _secWebSocketAccept; + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebPage/IRenderContext.cs b/src/WebExpress.WebCore/WebPage/IRenderContext.cs index a9b3be0..36437f9 100644 --- a/src/WebExpress.WebCore/WebPage/IRenderContext.cs +++ b/src/WebExpress.WebCore/WebPage/IRenderContext.cs @@ -27,6 +27,6 @@ public interface IRenderContext /// /// Returns the request. /// - Request Request { get; } + IRequest Request { get; } } } diff --git a/src/WebExpress.WebCore/WebPage/IVisualTreeContext.cs b/src/WebExpress.WebCore/WebPage/IVisualTreeContext.cs index 87f0abc..4ef9186 100644 --- a/src/WebExpress.WebCore/WebPage/IVisualTreeContext.cs +++ b/src/WebExpress.WebCore/WebPage/IVisualTreeContext.cs @@ -11,12 +11,12 @@ public interface IVisualTreeContext /// /// Returns the request. /// - Request Request { get; } + IRequest Request { get; } /// /// The uri of the request. /// - UriEndpoint Uri { get; } + IUri Uri { get; } /// /// Return or sets the render context. diff --git a/src/WebExpress.WebCore/WebPage/RenderContext.cs b/src/WebExpress.WebCore/WebPage/RenderContext.cs index 13cd87f..441d8a6 100644 --- a/src/WebExpress.WebCore/WebPage/RenderContext.cs +++ b/src/WebExpress.WebCore/WebPage/RenderContext.cs @@ -18,7 +18,7 @@ public class RenderContext : IRenderContext /// /// Returns the request. /// - public Request Request { get; protected set; } + public IRequest Request { get; protected set; } /// /// The uri of the request. @@ -48,7 +48,7 @@ public RenderContext() /// The endpoint associated with the rendering context. /// The page context. /// The request associated with the rendering context. - public RenderContext(IEndpoint endpoint, IPageContext pageContext, Request request) + public RenderContext(IEndpoint endpoint, IPageContext pageContext, IRequest request) { Endpoint = endpoint; PageContext = pageContext; diff --git a/src/WebExpress.WebCore/WebPage/VisualTreeContext.cs b/src/WebExpress.WebCore/WebPage/VisualTreeContext.cs index 59beca1..724f68f 100644 --- a/src/WebExpress.WebCore/WebPage/VisualTreeContext.cs +++ b/src/WebExpress.WebCore/WebPage/VisualTreeContext.cs @@ -11,12 +11,12 @@ public class VisualTreeContext : IVisualTreeContext /// /// Returns the request. /// - public Request Request => RenderContext?.Request; + public IRequest Request => RenderContext?.Request; /// /// The uri of the request. /// - public UriEndpoint Uri => RenderContext?.Request?.Uri; + public IUri Uri => RenderContext?.Request?.Uri; /// /// Return or sets the render context. diff --git a/src/WebExpress.WebCore/WebResource/IResource.cs b/src/WebExpress.WebCore/WebResource/IResource.cs index 583e12d..6f3ec92 100644 --- a/src/WebExpress.WebCore/WebResource/IResource.cs +++ b/src/WebExpress.WebCore/WebResource/IResource.cs @@ -13,6 +13,6 @@ public interface IResource : IEndpoint /// /// The request. /// The response. - Response Process(Request request); + IResponse Process(IRequest request); } } diff --git a/src/WebExpress.WebCore/WebResource/Resource.cs b/src/WebExpress.WebCore/WebResource/Resource.cs index 9977312..6c56fbf 100644 --- a/src/WebExpress.WebCore/WebResource/Resource.cs +++ b/src/WebExpress.WebCore/WebResource/Resource.cs @@ -26,7 +26,7 @@ public Resource(IResourceContext resourceContext) /// /// The request. /// The response. - public abstract Response Process(Request request); + public abstract IResponse Process(IRequest request); /// /// Performs application-specific tasks related to sharing, returning, or resetting unmanaged resources. diff --git a/src/WebExpress.WebCore/WebResource/ResourceAsset.cs b/src/WebExpress.WebCore/WebResource/ResourceAsset.cs index 53a96cc..ee4c4fe 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceAsset.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceAsset.cs @@ -38,7 +38,7 @@ public ResourceAsset(IResourceContext resourceContext) /// /// The request. /// The response. - public override Response Process(Request request) + public override IResponse Process(IRequest request) { lock (Gard) { diff --git a/src/WebExpress.WebCore/WebResource/ResourceBinary.cs b/src/WebExpress.WebCore/WebResource/ResourceBinary.cs index 6af4b6d..6bc5386 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceBinary.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceBinary.cs @@ -26,7 +26,7 @@ public ResourceBinary(IResourceContext resourceContext) /// /// The request. /// The response. - public override Response Process(Request request) + public override IResponse Process(IRequest request) { var response = new ResponseOK(); response.Header.ContentLength = Data is not null ? Data.Length : 0; diff --git a/src/WebExpress.WebCore/WebResource/ResourceFile.cs b/src/WebExpress.WebCore/WebResource/ResourceFile.cs index c43d0cc..b405aa4 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceFile.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceFile.cs @@ -33,7 +33,7 @@ public ResourceFile(IResourceContext resourceContext) /// /// The request. /// The response. - public override Response Process(Request request) + public override IResponse Process(IRequest request) { lock (Gard) { diff --git a/src/WebExpress.WebCore/WebResource/ResourceManager.cs b/src/WebExpress.WebCore/WebResource/ResourceManager.cs index 1751a08..035ebd0 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceManager.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceManager.cs @@ -17,7 +17,8 @@ namespace WebExpress.WebCore.WebResource { /// - /// The resource manager manages WebExpress elements, which can be called with a URI (Uniform Resource Identifier). + /// The resource manager manages WebExpress elements, which can be called with a + /// URI (Uniform Resource Identifier). /// public sealed class ResourceManager : IResourceManager, ISystemComponent { diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs b/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs index a354ebe..aa7d904 100644 --- a/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs +++ b/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebCore.WebRestApi ///
public class RestApiValidator { - private readonly Request _request; + private readonly IRequest _request; private readonly RestApiValidationResult _result = new(); private bool _currentCondition = true; @@ -28,7 +28,7 @@ public class RestApiValidator /// Initializes a new instance of the class with the specified request. ///
/// The request to be validated. - public RestApiValidator(Request request) + public RestApiValidator(IRequest request) { _request = request; } @@ -42,7 +42,7 @@ public RestApiValidator(Request request) /// met; otherwise, false. /// /// The current instance, allowing for method chaining. - public RestApiValidator When(Func condition) + public RestApiValidator When(Func condition) { _currentCondition = condition(_request); return this; @@ -617,7 +617,7 @@ public RestApiValidator IsDate(string parameter, string message = null) /// Defaults to "CUSTOM" if not specified. /// /// The current instance, allowing for method chaining. - public RestApiValidator Custom(Func condition, string message, string parameter = null, string code = "CUSTOM") + public RestApiValidator Custom(Func condition, string message, string parameter = null, string code = "CUSTOM") { if (!_currentCondition) { diff --git a/src/WebExpress.WebCore/WebSession/ISessionManager.cs b/src/WebExpress.WebCore/WebSession/ISessionManager.cs index 29befb8..4e947d6 100644 --- a/src/WebExpress.WebCore/WebSession/ISessionManager.cs +++ b/src/WebExpress.WebCore/WebSession/ISessionManager.cs @@ -15,7 +15,7 @@ public interface ISessionManager : IComponentManager ///
/// The request. /// The session. - Session GetSession(Request request); + Session GetSession(IRequest request); /// /// Cleans up expired sessions from the session manager based on the specified session timeout. diff --git a/src/WebExpress.WebCore/WebSession/SessionManager.cs b/src/WebExpress.WebCore/WebSession/SessionManager.cs index 17d8b2c..0ee368d 100644 --- a/src/WebExpress.WebCore/WebSession/SessionManager.cs +++ b/src/WebExpress.WebCore/WebSession/SessionManager.cs @@ -38,11 +38,11 @@ private SessionManager(IHttpServerContext context) /// /// The request. /// The session. - public Session GetSession(Request request) + public Session GetSession(IRequest request) { var session = default(Session); - // Session ermitteln + // determine session var sessionCookie = request?.Header .Cookies?.Where(x => x.Name.Equals("session", StringComparison.OrdinalIgnoreCase)) .FirstOrDefault(); @@ -78,20 +78,20 @@ public Session GetSession(Request request) } /// - /// Cleans up expired sessions from the session manager based on the specified session timeout. + /// Cleans up expired sessions from the session manager based on the specified + /// session timeout. /// - /// - /// This method iterates through the sessions and removes those that have been inactive - /// for longer than the configured session timeout. It logs the removal of each expired session. - /// /// - /// The application context containing configuration settings, including the session timeout duration. + /// The application context containing configuration settings, including the session + /// timeout duration. /// /// - /// The explicit session timeout in minutes; if non-positive, the configured timeout is used. If - /// the effective timeout is non-positive, cleanup is skipped. + /// The explicit session timeout in minutes; if non-positive, the configured + /// timeout is used. If the effective timeout is non-positive, cleanup is skipped. /// - /// The current instance of the session manager, allowing for method chaining. + /// + /// The current instance of the session manager, allowing for method chaining. + /// public ISessionManager CleanUp(IApplicationContext applicationContext, int timeoutMinutes = 60 * 24 * 365) { // validate input diff --git a/src/WebExpress.WebCore/WebSocket/ISocketContext.cs b/src/WebExpress.WebCore/WebSocket/ISocketContext.cs index 69c8af1..8734f06 100644 --- a/src/WebExpress.WebCore/WebSocket/ISocketContext.cs +++ b/src/WebExpress.WebCore/WebSocket/ISocketContext.cs @@ -1,7 +1,7 @@ -using System; using System.Collections.Generic; using System.Net.WebSockets; using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.WebSocket.Protocol; namespace WebExpress.WebCore.WebSocket { @@ -23,7 +23,7 @@ public interface ISocketContext : IEndpointContext /// for JSON or human-readable content, or /// for binary payloads. ///
- WebSocketMessageType MessageType { get; } + SocketMessageType MessageType { get; } /// /// Returns the maximum allowed message size in bytes, or null when the endpoint imposes no limit. diff --git a/src/WebExpress.WebCore/WebSocket/ISockt.cs b/src/WebExpress.WebCore/WebSocket/ISockt.cs index 77a9a2b..4744451 100644 --- a/src/WebExpress.WebCore/WebSocket/ISockt.cs +++ b/src/WebExpress.WebCore/WebSocket/ISockt.cs @@ -1,8 +1,8 @@ using System; -using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.WebSocket.Protocol; namespace WebExpress.WebCore.WebSocket { @@ -34,10 +34,9 @@ public interface ISocket : IEndpoint, IDisposable /// Invoked when the websocket connection is closed or is about to be closed. /// Implementers should perform cleanup and release resources. /// - /// Optional close status. - /// Optional close description. + /// Information about the socket closure. /// An asynchronous task. - Task OnDisconnectedAsync(WebSocketCloseStatus closeStatus, string closeDescription); + Task OnDisconnectedAsync(SocketCloseInfo closeInfo); /// /// Invoked when an unhandled exception occurs during websocket processing. diff --git a/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs b/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs index 5873a72..41283c7 100644 --- a/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs +++ b/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs @@ -6,6 +6,7 @@ using WebExpress.WebCore.WebCondition; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebPlugin; +using WebExpress.WebCore.WebSocket.Protocol; namespace WebExpress.WebCore.WebSocket.Model { @@ -34,7 +35,7 @@ internal class SocketItem : IDisposable /// public Type SocketClass { get; set; } - /// + /// /// Returns the collection of supported websocket subprotocols. /// implementations should return the subprotocol identifiers the endpoint can speak. /// @@ -46,7 +47,7 @@ internal class SocketItem : IDisposable /// for JSON or human-readable content, or /// for binary payloads. /// - public WebSocketMessageType MessageType { get; set; } + public SocketMessageType MessageType { get; set; } /// /// Returns the maximum allowed message size in bytes, or null when the endpoint imposes no limit. @@ -79,7 +80,7 @@ internal class SocketItem : IDisposable /// /// Returns or sets the instance of the socket endpoint, if the endpoint is cached, otherwise null. /// - public IEndpoint Instance { get; set; } + public ISocket Instance { get; set; } /// /// Initializes a new instance of the class. diff --git a/src/WebExpress.WebCore/WebSocket/ISocketMessage.cs b/src/WebExpress.WebCore/WebSocket/Protocol/ISocketMessage.cs similarity index 66% rename from src/WebExpress.WebCore/WebSocket/ISocketMessage.cs rename to src/WebExpress.WebCore/WebSocket/Protocol/ISocketMessage.cs index 842cbc6..7ba9ee4 100644 --- a/src/WebExpress.WebCore/WebSocket/ISocketMessage.cs +++ b/src/WebExpress.WebCore/WebSocket/Protocol/ISocketMessage.cs @@ -1,13 +1,11 @@ using System; using System.Collections.Generic; -using System.Text.Json; -using System.Text.Json.Serialization; -namespace WebExpress.WebCore.WebSocket +namespace WebExpress.WebCore.WebSocket.Protocol { /// - /// Base class for structured WebSocket messages exchanged between client and server. - /// Contains routing metadata common to all message types. + /// Base interface for structured WebSocket messages exchanged between client and + /// server. Contains routing metadata common to all message types. /// public interface ISocketMessage { @@ -55,20 +53,5 @@ public interface ISocketMessage /// Arbitrary metadata as key/value pairs. /// IDictionary Meta { get; } - - /// - /// Indicates whether this message contains binary payload. - /// - [JsonIgnore] - abstract bool IsBinary { get; } - - /// - /// Serializes the message to JSON. - /// - /// - /// A JSON string containing the serialized form of the message, including - /// routing metadata and payload fields. - /// - string ToJson(); } -} +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/ISocketReadStream.cs b/src/WebExpress.WebCore/WebSocket/Protocol/ISocketReadStream.cs similarity index 60% rename from src/WebExpress.WebCore/WebSocket/ISocketReadStream.cs rename to src/WebExpress.WebCore/WebSocket/Protocol/ISocketReadStream.cs index 7e2937e..8d29851 100644 --- a/src/WebExpress.WebCore/WebSocket/ISocketReadStream.cs +++ b/src/WebExpress.WebCore/WebSocket/Protocol/ISocketReadStream.cs @@ -1,13 +1,13 @@ using System; -using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; -namespace WebExpress.WebCore.WebSocket +namespace WebExpress.WebCore.WebSocket.Protocol { /// /// Represents an asynchronous read-only stream abstraction for receiving - /// WebSocket message data in one or more frames. + /// WebSocket message data in one or more frames using the native + /// WebExpress WebSocket protocol. /// public interface ISocketReadStream : IAsyncDisposable { @@ -33,31 +33,34 @@ public interface ISocketReadStream : IAsyncDisposable /// A token to observe while waiting for the operation to complete. /// /// - /// The number of bytes read. Returns 0 if the end of the message - /// has been reached. + /// A describing the number of bytes read, + /// whether the message has ended, and the message type. /// - Task ReadAsync(ArraySegment buffer, CancellationToken cancellationToken = default); + Task ReadAsync + ( + ArraySegment buffer, + CancellationToken cancellationToken = default + ); /// /// Signals that the current message has been fully consumed. /// After calling this method, no further reads for the current /// message are allowed. /// - /// - /// A token to observe while waiting for the operation to complete. - /// Task CompleteAsync(CancellationToken cancellationToken = default); /// - /// Asynchronously closes the underlying WebSocket connection using a normal - /// closure status. + /// Asynchronously closes the underlying WebSocket connection. /// - /// - /// A cancellation token that can be used to cancel the close operation. - /// - /// - /// A task that represents the asynchronous close operation. - /// - Task CloseAsync(CancellationToken cancellationToken = default); + /// The close status code. + /// An optional description for the closure. + /// A token to monitor for cancellation requests. + /// A task that represents the asynchronous close operation. + Task CloseAsync + ( + SocketCloseStatus status = SocketCloseStatus.NormalClosure, + string description = null, + CancellationToken cancellationToken = default + ); } } \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/ISocketWriteStream.cs b/src/WebExpress.WebCore/WebSocket/Protocol/ISocketWriteStream.cs similarity index 76% rename from src/WebExpress.WebCore/WebSocket/ISocketWriteStream.cs rename to src/WebExpress.WebCore/WebSocket/Protocol/ISocketWriteStream.cs index 7b4b51c..f74f4d1 100644 --- a/src/WebExpress.WebCore/WebSocket/ISocketWriteStream.cs +++ b/src/WebExpress.WebCore/WebSocket/Protocol/ISocketWriteStream.cs @@ -2,7 +2,7 @@ using System.Threading; using System.Threading.Tasks; -namespace WebExpress.WebCore.WebSocket +namespace WebExpress.WebCore.WebSocket.Protocol { /// /// Represents an asynchronous write-only stream abstraction for sending @@ -16,7 +16,9 @@ public interface ISocketWriteStream : IAsyncDisposable /// multiple times to send a message in fragments. /// /// The data buffer to write. - /// A token to observe while waiting for the operation to complete. + /// + /// A token to observe while waiting for the operation to complete. + /// Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default); /// @@ -24,7 +26,9 @@ public interface ISocketWriteStream : IAsyncDisposable /// endOfMessage set to true. /// After calling this method, no further writes are allowed. /// - /// A token to observe while waiting for the operation to complete. + /// + /// A token to observe while waiting for the operation to complete. + /// Task CompleteAsync(CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/Socket.cs b/src/WebExpress.WebCore/WebSocket/Protocol/Socket.cs new file mode 100644 index 0000000..ab628ef --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/Protocol/Socket.cs @@ -0,0 +1,234 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace WebExpress.WebCore.WebSocket.Protocol +{ + /// + /// Represents a WebSocket connection with send/receive logic + /// using the native WebExpress WebSocket protocol. + /// + public class Socket + { + private readonly Stream _stream; + private readonly CancellationToken _token; + + /// + /// Occurs when a text message is received, allowing subscribers to handle the message asynchronously. + /// + public event Func OnTextMessage; + + /// + /// Occurs when a binary message is received, providing the message data as a byte array. + /// + public event Func OnBinaryMessage; + + /// + /// Occurs when the component is being closed, allowing subscribers to perform asynchronous cleanup or + /// finalization tasks. + /// + public event Func OnClose; + + /// + /// Initializes a new instance of the Socket class using the specified data + /// stream and cancellation token. + /// + /// + /// The stream to use for network communication. Must be readable and writable. + /// + /// + /// A cancellation token that can be used to cancel operations associated + /// with this socket. + /// + public Socket(Stream stream, CancellationToken token) + { + _stream = stream; + _token = token; + } + + /// + /// Starts reading frames from the underlying stream and dispatches + /// them to the appropriate event handlers. + /// + /// A task that represents the asynchronous operation. + public async Task StartAsync() + { + while (!_token.IsCancellationRequested) + { + var frame = SocketFrameParser.ReadFrame(_stream); + + switch (frame.MessageType) + { + case SocketMessageType.Text: + var text = Encoding.UTF8.GetString(frame.Payload); + if (OnTextMessage != null) + await OnTextMessage(text); + break; + + case SocketMessageType.Binary: + if (OnBinaryMessage != null) + await OnBinaryMessage(frame.Payload); + break; + + case SocketMessageType.Close: + if (frame is SocketFrameClose close) + { + await SendCloseAsync(close.Status, close.Description); + } + else + { + await SendCloseAsync(SocketCloseStatus.NormalClosure, "closing"); + } + + if (OnClose != null) + await OnClose(); + + return; + + case SocketMessageType.Ping: + await SendPongAsync(frame.Payload); + break; + + case SocketMessageType.Pong: + break; + + case SocketMessageType.Continuation: + // optional: handle fragmented messages + break; + } + } + } + + /// + /// Asynchronously sends a text message over the WebSocket connection. + /// + /// The text message to send. Cannot be null. + /// A task that represents the asynchronous send operation. + public Task SendTextAsync(string message) + { + var payload = Encoding.UTF8.GetBytes(message); + return SendFrameAsync(SocketMessageType.Text, payload); + } + + /// + /// Asynchronously sends a binary message to the connected endpoint. + /// + /// The binary data to send. Cannot be null. + /// A task that represents the asynchronous send operation. + public Task SendBinaryAsync(byte[] data) + { + return SendFrameAsync(SocketMessageType.Binary, data); + } + + /// + /// Initiates an asynchronous close handshake by sending a WebSocket close frame + /// to the remote endpoint. + /// + /// + /// The status code indicating the reason for closure. + /// + /// + /// An optional description providing additional context for the closure. + /// + /// + /// A task that represents the asynchronous close operation. + /// + public Task SendCloseAsync(SocketCloseStatus status, string description = null) + { + byte[] reasonBytes = description != null + ? Encoding.UTF8.GetBytes(description) + : []; + + byte[] payload = new byte[2 + reasonBytes.Length]; + + // statuscode (2 byte, big endian) + payload[0] = (byte)((ushort)status >> 8); + payload[1] = (byte)((ushort)status & 0xFF); + + if (reasonBytes.Length > 0) + { + Array.Copy(reasonBytes, 0, payload, 2, reasonBytes.Length); + } + + return SendFrameAsync(SocketMessageType.Close, payload); + } + + /// + /// Sends a WebSocket Pong frame asynchronously with the specified payload. + /// + /// + /// The optional application data to include in the Pong frame. May be null + /// or empty if no payload is required. + /// + /// + /// A task that represents the asynchronous send operation. + /// + public Task SendPongAsync(byte[] payload) + { + return SendFrameAsync(SocketMessageType.Pong, payload); + } + + /// + /// Asynchronously sends a WebSocket frame with the specified message type + /// and payload over the underlying stream. + /// + /// + /// The type of the WebSocket message to send. Determines the opcode set in + /// the frame header. + /// + /// + /// The payload data to include in the frame. Must not be null. + /// + /// + /// A task that represents the asynchronous send operation. + /// + private async Task SendFrameAsync(SocketMessageType type, byte[] payload) + { + using var ms = new MemoryStream(); + + // FIN + opcode + ms.WriteByte((byte)(0b1000_0000 | type.ToOpcode())); + + // payload length + if (payload.Length < 126) + { + ms.WriteByte((byte)payload.Length); + } + else if (payload.Length <= ushort.MaxValue) + { + ms.WriteByte(126); + var len = BitConverter.GetBytes((ushort)payload.Length); + if (BitConverter.IsLittleEndian) Array.Reverse(len); + ms.Write(len); + } + else + { + ms.WriteByte(127); + var len = BitConverter.GetBytes((ulong)payload.Length); + if (BitConverter.IsLittleEndian) Array.Reverse(len); + ms.Write(len); + } + + // payload + ms.Write(payload); + + var buffer = ms.ToArray(); + await _stream.WriteAsync(buffer, 0, buffer.Length, _token); + await _stream.FlushAsync(_token); + } + + /// + /// Asynchronously reads the next frame from the underlying network stream. + /// + /// + /// A task that represents the asynchronous read operation. The task result + /// contains the next read from the stream. + /// + public Task ReadFrameAsync() + { + return Task.Run(() => SocketFrameParser.ReadFrame(_stream), _token); + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseInfo.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseInfo.cs new file mode 100644 index 0000000..ba4df4c --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseInfo.cs @@ -0,0 +1,36 @@ +namespace WebExpress.WebCore.WebSocket.Protocol +{ + /// + /// Represents information about the reason a socket connection was closed, + /// including the close status and an optional description. + /// + public class SocketCloseInfo + { + /// + /// Returns the status that indicates the reason the socket was closed. + /// + public SocketCloseStatus Status { get; } + + /// + /// Returns the description associated with this instance. + /// + public string Description { get; } + + /// + /// Initializes a new instance of the SocketCloseInfo class with the specified + /// close status and optional description. + /// + /// + /// The status code indicating the reason the socket was closed. + /// + /// + /// An optional textual description providing additional details about the + /// socket closure. May be null. + /// + public SocketCloseInfo(SocketCloseStatus status, string description) + { + Status = status; + Description = description; + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseStatus.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseStatus.cs new file mode 100644 index 0000000..3b03940 --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseStatus.cs @@ -0,0 +1,138 @@ +using System; + +namespace WebExpress.WebCore.WebSocket.Protocol +{ + /// + /// Defines WebSocket close status codes according to RFC 6455. + /// + public enum SocketCloseStatus : ushort + { + /// + /// Indicates that the connection was closed normally, as defined by the + /// WebSocket protocol. + /// + NormalClosure = 1000, + + /// + /// Indicates that the connection is closing because the endpoint is going + /// away, such as a server shutdown or a browser navigating away from a page. + /// + GoingAway = 1001, + + /// + /// Indicates that a protocol error has occurred during communication. + /// + ProtocolError = 1002, + + /// + /// Indicates that the received data is not supported by the protocol or + /// application. + /// + UnsupportedData = 1003, + + /// + /// Indicates that no status code was received from the remote endpoint. + /// + NoStatusReceived = 1005, + + /// + /// Indicates that the connection was closed abnormally, without a close + /// frame being sent or received. + /// + AbnormalClosure = 1006, + + /// + /// Indicates that the received data does not conform to the expected payload + /// format or contains invalid data. + /// + InvalidPayloadData = 1007, + + /// + /// Indicates that a message was closed because it violated a policy defined + /// by the endpoint or server. + /// + PolicyViolation = 1008, + + /// + /// Indicates that a message was rejected because its size exceeds the + /// maximum allowed limit. + /// + MessageTooBig = 1009, + + /// + /// Indicates that the extension is required for the operation to proceed. + /// + MandatoryExtension = 1010, + + /// + /// Indicates that an internal server error has occurred. + /// + InternalServerError = 1011 + } + + /// + /// Provides helper and conversion methods for . + /// + public static class SocketCloseStatusExtensions + { + /// + /// Returns a human-readable description for the given close status. + /// + public static string GetDescription(this SocketCloseStatus status) + { + return status switch + { + SocketCloseStatus.NormalClosure => "Normal closure", + SocketCloseStatus.GoingAway => "Going away", + SocketCloseStatus.ProtocolError => "Protocol error", + SocketCloseStatus.UnsupportedData => "Unsupported data", + SocketCloseStatus.NoStatusReceived => "No status received", + SocketCloseStatus.AbnormalClosure => "Abnormal closure", + SocketCloseStatus.InvalidPayloadData => "Invalid payload data", + SocketCloseStatus.PolicyViolation => "Policy violation", + SocketCloseStatus.MessageTooBig => "Message too big", + SocketCloseStatus.MandatoryExtension => "Mandatory extension missing", + SocketCloseStatus.InternalServerError => "Internal server error", + _ => "Unknown close status" + }; + } + + /// + /// Returns true if the close status indicates an error condition. + /// + public static bool IsError(this SocketCloseStatus status) + { + return status switch + { + SocketCloseStatus.NormalClosure => false, + SocketCloseStatus.GoingAway => false, + SocketCloseStatus.NoStatusReceived => false, + _ => true + }; + } + + /// + /// Returns true if the status code is reserved for internal use. + /// + public static bool IsReserved(this SocketCloseStatus status) + { + return status switch + { + SocketCloseStatus.NoStatusReceived => true, + SocketCloseStatus.AbnormalClosure => true, + _ => false + }; + } + + /// + /// Attempts to convert a raw ushort value into a . + /// Returns null if the value is not a valid RFC 6455 close code. + /// + public static SocketCloseStatus? TryParse(ushort code) + { + return Enum.IsDefined(typeof(SocketCloseStatus), code) + ? (SocketCloseStatus)code + : null; + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrame.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrame.cs new file mode 100644 index 0000000..1b7a2af --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrame.cs @@ -0,0 +1,30 @@ +namespace WebExpress.WebCore.WebSocket.Protocol +{ + /// + /// Represents a parsed WebSocket frame in the native + /// WebExpress WebSocket protocol implementation. + /// + public class SocketFrame + { + /// + /// Indicates whether this frame is the final frame of the message. + /// + public bool Fin { get; set; } + + /// + /// The message type of the frame (text, binary, close, ping, pong, continuation). + /// + public SocketMessageType MessageType { get; set; } + + /// + /// Indicates whether the payload is masked. + /// Client-to-server frames must be masked; server-to-client frames are not. + /// + public bool Masked { get; set; } + + /// + /// The raw payload data of the frame. + /// + public byte[] Payload { get; set; } = []; + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrameClose.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrameClose.cs new file mode 100644 index 0000000..1a5b4b6 --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrameClose.cs @@ -0,0 +1,41 @@ +using WebExpress.WebCore.WebSocket.Protocol; + +/// +/// Represents a WebSocket close frame containing the close status and +/// an optional description. +/// +public class SocketFrameClose : SocketFrame +{ + /// + /// Returns the status that indicates the reason the socket was closed. + /// + public SocketCloseStatus Status { get; } + + /// + /// Returns the description associated with the current instance. + /// + public string Description { get; } + + /// + /// Initializes a new instance of the SocketFrameClose class with the specified + /// close status, description, and raw payload. + /// + /// + /// The status code indicating the reason for closing the socket connection. + /// + /// + /// A human-readable description providing additional information about the close + /// reason. Can be null or empty if no description is needed. + /// + /// + /// The raw payload data associated with the close frame. Can be null if no + /// payload is included. + /// + public SocketFrameClose(SocketCloseStatus status, string description, byte[] rawPayload) + { + MessageType = SocketMessageType.Close; + Status = status; + Description = description; + Payload = rawPayload; + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrameParser.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrameParser.cs new file mode 100644 index 0000000..a3b6e6d --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrameParser.cs @@ -0,0 +1,110 @@ +using System; +using System.IO; +using System.Text; + +namespace WebExpress.WebCore.WebSocket.Protocol +{ + /// + /// Parses WebSocket frames from a raw network stream. + /// + public static class SocketFrameParser + { + /// + /// Reads and parses a single WebSocket frame from the given stream. + /// + /// The input stream to read from. + public static SocketFrame ReadFrame(Stream stream) + { + var header = new byte[2]; + stream.ReadExactly(header); + + var fin = (header[0] & 0b1000_0000) != 0; + var opcode = header[0] & 0b0000_1111; + var masked = (header[1] & 0b1000_0000) != 0; + var payloadLen = header[1] & 0b0111_1111; + + long actualLength = payloadLen switch + { + 126 => ReadExtendedLength(stream, 2), + 127 => ReadExtendedLength(stream, 8), + _ => payloadLen + }; + + byte[] maskKey = []; + if (masked) + { + maskKey = new byte[4]; + stream.ReadExactly(maskKey); + } + + var payload = new byte[actualLength]; + stream.ReadExactly(payload); + + if (masked) + { + for (var i = 0; i < payload.Length; i++) + { + payload[i] ^= maskKey[i % 4]; + } + } + + var messageType = SocketMessageTypeExtensions.FromOpcode(opcode); + + // special case: close frame + if (messageType == SocketMessageType.Close) + { + SocketCloseStatus status = SocketCloseStatus.NormalClosure; + string reason = null; + + if (payload.Length >= 2) + { + status = (SocketCloseStatus)((payload[0] << 8) | payload[1]); + + if (payload.Length > 2) + { + reason = Encoding.UTF8.GetString(payload, 2, payload.Length - 2); + } + } + + return new SocketFrameClose(status, reason, payload) + { + Fin = fin, + Masked = masked + }; + } + + // default: normal frame + return new SocketFrame + { + Fin = fin, + MessageType = messageType, + Masked = masked, + Payload = payload + }; + } + + /// + /// Reads an extended payload length field (16-bit or 64-bit). + /// + /// The input stream to read from. + /// The number of bytes to read (2 or 8). + /// The parsed length as a long. + private static long ReadExtendedLength(Stream stream, int bytes) + { + var buffer = new byte[bytes]; + stream.ReadExactly(buffer); + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(buffer); + } + + return bytes switch + { + 2 => BitConverter.ToUInt16(buffer), + 8 => BitConverter.ToInt64(buffer), + _ => throw new InvalidOperationException("Invalid extended length field size.") + }; + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/SocketHandshakeException.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketHandshakeException.cs similarity index 93% rename from src/WebExpress.WebCore/WebSocket/SocketHandshakeException.cs rename to src/WebExpress.WebCore/WebSocket/Protocol/SocketHandshakeException.cs index bc95495..d37f320 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketHandshakeException.cs +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketHandshakeException.cs @@ -1,6 +1,6 @@ using System; -namespace WebExpress.WebCore.WebSocket +namespace WebExpress.WebCore.WebSocket.Protocol { /// /// Represents an error that occurs when a WebSocket handshake request diff --git a/src/WebExpress.WebCore/WebSocket/SocketMessageBinary.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageBinary.cs similarity index 71% rename from src/WebExpress.WebCore/WebSocket/SocketMessageBinary.cs rename to src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageBinary.cs index 2a55fa0..8740412 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketMessageBinary.cs +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageBinary.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; -using System.Text.Json; -using System.Text.Json.Serialization; -namespace WebExpress.WebCore.WebSocket +namespace WebExpress.WebCore.WebSocket.Protocol { /// /// Represents a WebSocket message containing binary payload. @@ -12,7 +10,9 @@ namespace WebExpress.WebCore.WebSocket /// public class SocketMessageBinary : ISocketMessage { - /// + /// + /// Returns the type identifier associated with the current instance. + /// public string Type { get; init; } /// @@ -35,16 +35,26 @@ public class SocketMessageBinary : ISocketMessage /// public string ConnectionId { get; set; } - /// + /// + /// Returns the identifier of the sender associated with this message. + /// public string Sender { get; init; } - /// + /// + /// Returns the collection of target identifiers associated with this instance. + /// public IEnumerable Targets { get; init; } - /// + /// + /// Returns the date and time when the object was created or last updated, + /// in Coordinated Universal Time (UTC). + /// public DateTime Timestamp { get; init; } = DateTime.UtcNow; - /// + /// + /// Returns a collection of key-value pairs that provide additional + /// metadata associated with the object. + /// public IDictionary Meta { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -54,22 +64,6 @@ public class SocketMessageBinary : ISocketMessage /// public byte[] Data { get; init; } - /// - [JsonIgnore] - public bool IsBinary => Data?.Length > 0; - - private static readonly JsonSerializerOptions SerializeOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - /// - public string ToJson() - { - return JsonSerializer.Serialize(this, SerializeOptions); - } - /// /// Creates a new binary message with the specified routing type and payload. /// diff --git a/src/WebExpress.WebCore/WebSocket/SocketMessageText.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageText.cs similarity index 71% rename from src/WebExpress.WebCore/WebSocket/SocketMessageText.cs rename to src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageText.cs index 552ee60..fbe88ed 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketMessageText.cs +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageText.cs @@ -3,7 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace WebExpress.WebCore.WebSocket +namespace WebExpress.WebCore.WebSocket.Protocol { /// /// Represents a WebSocket message containing UTF-8 text payload. @@ -12,6 +12,12 @@ namespace WebExpress.WebCore.WebSocket /// public class SocketMessageText : ISocketMessage { + private static readonly JsonSerializerOptions _serializeOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + /// /// Application-defined message type used for routing. /// @@ -37,16 +43,26 @@ public class SocketMessageText : ISocketMessage /// public string ConnectionId { get; set; } - /// + /// + /// Returns the identifier of the sender associated with this message. + /// public string Sender { get; init; } - /// + /// + /// Returns the collection of target identifiers associated with this instance. + /// public IEnumerable Targets { get; init; } - /// + /// + /// Returns the date and time, in Coordinated Universal Time (UTC), + /// when the object was created or last updated. + /// public DateTime Timestamp { get; init; } = DateTime.UtcNow; - /// + /// + /// Returns a collection of key-value pairs that provide additional metadata + /// associated with the object. + /// public IDictionary Meta { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -55,20 +71,12 @@ public class SocketMessageText : ISocketMessage ///
public string Text { get; init; } - /// - [JsonIgnore] - public bool IsBinary => false; - - private static readonly JsonSerializerOptions SerializeOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - /// + /// + /// Converts the current object to its JSON string representation. + /// public string ToJson() { - return JsonSerializer.Serialize(this, SerializeOptions); + return JsonSerializer.Serialize(this, _serializeOptions); } /// diff --git a/src/WebExpress.WebCore/WebSocket/SocketMessageTooLargeException.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageTooLargeException.cs similarity index 98% rename from src/WebExpress.WebCore/WebSocket/SocketMessageTooLargeException.cs rename to src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageTooLargeException.cs index 010488a..6ac1e3c 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketMessageTooLargeException.cs +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageTooLargeException.cs @@ -1,7 +1,7 @@ using System; using System.Runtime.Serialization; -namespace WebExpress.WebCore.WebSocket +namespace WebExpress.WebCore.WebSocket.Protocol { /// /// Represents an error that occurs when an incoming WebSocket message diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageType.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageType.cs new file mode 100644 index 0000000..6b8722f --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageType.cs @@ -0,0 +1,106 @@ +namespace WebExpress.WebCore.WebSocket.Protocol +{ + /// + /// Defines the type of WebSocket message represented or sent + /// in the native WebExpress WebSocket protocol. + /// + public enum SocketMessageType + { + /// + /// Specifies that the data is UTF‑8 encoded text. + /// + Text, + + /// + /// Specifies that the data is binary. + /// + Binary, + + /// + /// Indicates a close control frame. + /// + Close, + + /// + /// Indicates a ping control frame. + /// + Ping, + + /// + /// Indicates a pong control frame. + /// + Pong, + + /// + /// Indicates a continuation frame for fragmented messages. + /// + Continuation + } + + /// + /// Provides helper and conversion methods for . + /// + public static class SocketMessageTypeExtensions + { + /// + /// Converts a WebSocket opcode into a . + /// + public static SocketMessageType FromOpcode(int opcode) + { + return opcode switch + { + 0x0 => SocketMessageType.Continuation, + 0x1 => SocketMessageType.Text, + 0x2 => SocketMessageType.Binary, + 0x8 => SocketMessageType.Close, + 0x9 => SocketMessageType.Ping, + 0xA => SocketMessageType.Pong, + _ => SocketMessageType.Binary // fallback + }; + } + + /// + /// Converts a into the corresponding WebSocket opcode. + /// + public static int ToOpcode(this SocketMessageType type) + { + return type switch + { + SocketMessageType.Continuation => 0x0, + SocketMessageType.Text => 0x1, + SocketMessageType.Binary => 0x2, + SocketMessageType.Close => 0x8, + SocketMessageType.Ping => 0x9, + SocketMessageType.Pong => 0xA, + _ => 0x2 + }; + } + + /// + /// Returns true if the message type represents a control frame. + /// + public static bool IsControl(this SocketMessageType type) + { + return type == SocketMessageType.Close + || type == SocketMessageType.Ping + || type == SocketMessageType.Pong; + } + + /// + /// Returns true if the message type represents a data frame (text or binary). + /// + public static bool IsData(this SocketMessageType type) + { + return type == SocketMessageType.Text + || type == SocketMessageType.Binary; + } + + /// + /// Returns true if the message type represents a continuation frame. + /// + public static bool IsContinuation(this SocketMessageType type) + { + return type == SocketMessageType.Continuation; + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStream.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStream.cs new file mode 100644 index 0000000..1a81d5e --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStream.cs @@ -0,0 +1,146 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace WebExpress.WebCore.WebSocket.Protocol +{ + /// + /// A binary-oriented implementation of , + /// exposing incoming WebSocket message data as raw byte segments using + /// the native WebExpress WebSocket protocol. + /// + public sealed class SocketReadStream : ISocketReadStream + { + private readonly Socket _socket; + private readonly ISocketContext _socketContext; + private readonly string _connectionId; + + private SocketFrame _currentFrame; + private int _frameOffset = 0; + + /// + /// Returns the context associated with the underlying socket connection. + /// + public ISocketContext SocketContext => _socketContext; + + /// + /// Returns the unique identifier for the current connection. + /// + public string ConnectionId => _connectionId; + + /// + /// Initializes a new instance of the SocketReadStream class for reading data + /// from a native WebExpress WebSocket connection. + /// + public SocketReadStream(Socket socket, ISocketContext socketContext, string connectionId) + { + _socket = socket ?? throw new ArgumentNullException(nameof(socket)); + _socketContext = socketContext; + _connectionId = connectionId; + } + + /// + /// Reads a chunk of data from the underlying WebSocket transport. + /// Supports fragmented messages by returning partial payload segments. + /// + public async Task ReadAsync( + ArraySegment buffer, + CancellationToken cancellationToken = default) + { + // Load a new frame if needed + if (_currentFrame == null) + { + _currentFrame = await _socket.ReadFrameAsync(); + _frameOffset = 0; + } + + var payload = _currentFrame.Payload; + + // Remaining bytes in this frame + int remaining = payload.Length - _frameOffset; + + if (remaining <= 0) + { + // End of message + var messageType = _currentFrame.MessageType; + _currentFrame = null; + + return new SocketReceiveResult( + count: 0, + endOfMessage: true, + messageType: messageType + ); + } + + // Copy as much as fits into the buffer + int toCopy = Math.Min(buffer.Count, remaining); + + Array.Copy( + payload, + _frameOffset, + buffer.Array!, + buffer.Offset, + toCopy + ); + + _frameOffset += toCopy; + + bool endOfMessage = _frameOffset >= payload.Length; + var type = _currentFrame.MessageType; + + if (endOfMessage) + { + _currentFrame = null; + } + + return new SocketReceiveResult( + count: toCopy, + endOfMessage: endOfMessage, + messageType: type + ); + } + + /// + /// Marks the current message as fully consumed. + /// For the native protocol, this is a no-op. + /// + public Task CompleteAsync(CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + /// + /// Asynchronously closes the underlying WebSocket connection. + /// + /// The close status code. + /// An optional description for the closure. + /// A token to monitor for cancellation requests. + /// A task that represents the asynchronous close operation. + public Task CloseAsync + ( + SocketCloseStatus status = SocketCloseStatus.NormalClosure, + string description = null, + CancellationToken cancellationToken = default + ) + { + return _socket.SendCloseAsync(status, description); + } + + /// + /// Performs cleanup operations for the read stream. + /// + public ValueTask DisposeAsync() + { + try + { + _socket.SendCloseAsync(SocketCloseStatus.NormalClosure, "disposing"); + } + catch + { + // Socket already closed or broken – ignore + } + + return ValueTask.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/SocketReadStreamExtensions.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStreamExtensions.cs similarity index 63% rename from src/WebExpress.WebCore/WebSocket/SocketReadStreamExtensions.cs rename to src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStreamExtensions.cs index aac84bd..ef6ceb4 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketReadStreamExtensions.cs +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStreamExtensions.cs @@ -1,15 +1,14 @@ using System.IO; -using System.Net.WebSockets; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -namespace WebExpress.WebCore.WebSocket +namespace WebExpress.WebCore.WebSocket.Protocol { /// /// Provides extension methods for reading instances - /// from an . + /// from an using the native WebExpress WebSocket protocol. /// public static class SocketReadStreamExtensions { @@ -20,24 +19,20 @@ public static class SocketReadStreamExtensions /// The source read stream. /// A token to observe while waiting for the operation to complete. /// The deserialized message. - public static async Task ReadMessageAsync - ( + public static async Task ReadMessageAsync( this ISocketReadStream stream, - CancellationToken cancellationToken = default - ) + CancellationToken cancellationToken = default) { - // buffer for accumulating message fragments - var totalBytes = 0ul; + ulong totalBytes = 0; using var buffer = new MemoryStream(); var temp = new byte[4096]; - var messageType = WebSocketMessageType.Text; + + var messageType = SocketMessageType.Text; var maxSize = stream.SocketContext?.MaxMessageSize ?? ulong.MinValue; - var closeStatus = WebSocketCloseStatus.NormalClosure; - var closeDescription = "closing"; while (true) { - var result = await stream.ReadAsync(temp, CancellationToken.None) + var result = await stream.ReadAsync(temp, cancellationToken) .ConfigureAwait(false); totalBytes += (ulong)result.Count; @@ -47,24 +42,13 @@ public static async Task ReadMessageAsync throw new SocketMessageTooLargeException(totalBytes, maxSize); } - // handle close frames - if (result.MessageType == WebSocketMessageType.Close) - { - await stream.CloseAsync(CancellationToken.None); - - break; - } - if (result.Count == 0) { break; // end of message } buffer.Write(temp, 0, result.Count); - messageType = result.MessageType; - closeStatus = result.CloseStatus ?? WebSocketCloseStatus.NormalClosure; - closeDescription = result.CloseStatusDescription ?? "client closed"; } await stream.CompleteAsync(cancellationToken); @@ -73,41 +57,20 @@ public static async Task ReadMessageAsync } /// - /// Parses a WebSocket message from the provided buffer and stream, - /// returning a strongly typed socket message instance based on the + /// Parses a WebSocket message from the provided buffer and stream, + /// returning a strongly typed socket message instance based on the /// message type and content. /// - /// - /// The stream representing the source of the WebSocket message data. Used - /// to provide connection and context information for the resulting message. - /// - /// - /// The type of the WebSocket message, indicating whether the message is - /// text or binary. - /// - /// A memory buffer containing the raw message data - /// to be parsed. The buffer must be positioned at the start of the message data. - /// - /// - /// An instance of a class implementing that - /// represents the parsed message. The specific type depends on the message - /// content and type. - /// - private static ISocketMessage ParseMessage - ( + private static ISocketMessage ParseMessage( ISocketReadStream stream, - WebSocketMessageType messageType, - MemoryStream buffer - ) + SocketMessageType messageType, + MemoryStream buffer) { - // produce SocketMessage from buffer buffer.Seek(0, SeekOrigin.Begin); - if (messageType == WebSocketMessageType.Text) + if (messageType == SocketMessageType.Text) { - // extract UTF‑8 text from the stream var text = Encoding.UTF8.GetString(buffer.ToArray()); - ISocketMessage parsed = null; try @@ -124,13 +87,11 @@ MemoryStream buffer } else { - // default parsed = JsonSerializer.Deserialize(text); } } catch { - // JSON invalid → fallback to plain text message parsed = new SocketMessageText { Type = null, diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketReceiveResult.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketReceiveResult.cs new file mode 100644 index 0000000..2e217f7 --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketReceiveResult.cs @@ -0,0 +1,45 @@ +namespace WebExpress.WebCore.WebSocket.Protocol +{ + /// + /// Represents the result of a read operation on a WebSocket stream, + /// containing the number of bytes read, the message type, and whether + /// the end of the message has been reached. + /// + public class SocketReceiveResult + { + /// + /// The number of bytes read into the provided buffer. + /// + public int Count { get; } + + /// + /// Indicates whether the end of the current WebSocket message has been reached. + /// + public bool EndOfMessage { get; } + + /// + /// The type of the WebSocket message (text, binary, close, ping, pong, continuation). + /// + public SocketMessageType MessageType { get; } + + /// + /// Initializes a new instance of the class with the specified number of + /// bytes received, end-of-message indicator, and message type. + /// + /// + /// The number of bytes received in the operation. + /// + /// + /// True if the received data marks the end of the message; otherwise, false. + /// + /// + /// The type of message received, indicating how the data should be interpreted. + /// + public SocketReceiveResult(int count, bool endOfMessage, SocketMessageType messageType) + { + Count = count; + EndOfMessage = endOfMessage; + MessageType = messageType; + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStream.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStream.cs new file mode 100644 index 0000000..5d22268 --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStream.cs @@ -0,0 +1,68 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace WebExpress.WebCore.WebSocket.Protocol +{ + /// + /// Provides a write stream abstraction for sending WebSocket messages + /// using the native WebExpress WebSocket protocol implementation. + /// + public class SocketWriteStream : ISocketWriteStream + { + private readonly Socket _socket; + private readonly SocketMessageType _messageType; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying native web socket connection. + /// The message type (text or binary). + public SocketWriteStream(Socket socket, SocketMessageType messageType) + { + _socket = socket; + _messageType = messageType; + } + + /// + /// Writes a chunk of data to the underlying web socket transport. + /// This method does not finalize the message; callers may invoke it + /// multiple times to send a message in fragments. + /// + /// The data buffer to write. + /// A token to observe while waiting for the operation to complete. + public async Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (_messageType == SocketMessageType.Text) + { + // Convert bytes to UTF8 text + var text = System.Text.Encoding.UTF8.GetString(buffer.Span); + await _socket.SendTextAsync(text); + } + else + { + await _socket.SendBinaryAsync(buffer.ToArray()); + } + } + + /// + /// Completes the message. For the native protocol implementation, + /// messages are finalized automatically, so this method performs no action. + /// + public Task CompleteAsync(CancellationToken cancellationToken = default) + { + // No explicit final frame required in the native protocol. + return Task.CompletedTask; + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, + /// or resetting unmanaged resources asynchronously. + /// + public ValueTask DisposeAsync() + { + // No unmanaged resources to release. + return ValueTask.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/SocketWriteStreamExtensions.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStreamExtensions.cs similarity index 71% rename from src/WebExpress.WebCore/WebSocket/SocketWriteStreamExtensions.cs rename to src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStreamExtensions.cs index 0005dd8..7e726f2 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketWriteStreamExtensions.cs +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStreamExtensions.cs @@ -2,7 +2,7 @@ using System.Threading; using System.Threading.Tasks; -namespace WebExpress.WebCore.WebSocket +namespace WebExpress.WebCore.WebSocket.Protocol { /// /// Provides extension methods for writing instances @@ -23,20 +23,21 @@ public static async Task WriteMessageAsync CancellationToken cancellationToken = default ) { - if (message.IsBinary) + if (message is SocketMessageBinary) { var binary = (message as SocketMessageBinary)?.Data ?? []; await stream.WriteAsync(binary, cancellationToken); } - else + else if (message is SocketMessageText textMessage) { - var json = message.ToJson(); - var bytes = Encoding.UTF8.GetBytes(json); - await stream.WriteAsync(bytes, cancellationToken); - } + { + var json = textMessage.ToJson(); + var bytes = Encoding.UTF8.GetBytes(json); + await stream.WriteAsync(bytes, cancellationToken); + } - await stream.CompleteAsync(cancellationToken); + await stream.CompleteAsync(cancellationToken); + } } } - } \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/SocketContext.cs b/src/WebExpress.WebCore/WebSocket/SocketContext.cs index 8585c13..01e9537 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketContext.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketContext.cs @@ -6,6 +6,7 @@ using WebExpress.WebCore.WebCondition; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebPlugin; +using WebExpress.WebCore.WebSocket.Protocol; namespace WebExpress.WebCore.WebSocket { @@ -46,7 +47,7 @@ public class SocketContext : ISocketContext /// for JSON or human-readable content, or /// for binary payloads. /// - public WebSocketMessageType MessageType { get; set; } + public SocketMessageType MessageType { get; set; } /// /// Maximum allowed message size in bytes, or null when no limit is imposed. diff --git a/src/WebExpress.WebCore/WebSocket/SocketManager.cs b/src/WebExpress.WebCore/WebSocket/SocketManager.cs index b6a21cf..3b2957b 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketManager.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketManager.cs @@ -2,11 +2,9 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Linq; -using System.Net.WebSockets; +using System.Security.Cryptography; using System.Text; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using WebExpress.WebCore.Internationalization; @@ -18,6 +16,7 @@ using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPlugin; using WebExpress.WebCore.WebSocket.Model; +using WebExpress.WebCore.WebSocket.Protocol; namespace WebExpress.WebCore.WebSocket { @@ -26,6 +25,7 @@ namespace WebExpress.WebCore.WebSocket /// public class SocketManager : ISocketManager { + private const string _webSocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; private readonly IComponentHub _componentHub; private readonly IHttpServerContext _httpServerContext; private readonly SocketDictionary _dictionary = new(); @@ -100,49 +100,138 @@ private SocketManager(IComponentHub componentHub, IHttpServerContext httpServerC public async Task HandleConnectionAsync(HttpContext httpContext, ISocketContext socketContext) { var connectionId = Guid.NewGuid().ToString(); - var closeStatus = WebSocketCloseStatus.NormalClosure; var closeDescription = "closing"; - var webSocket = await CreateWebSocket(httpContext, socketContext); - var instance = CreateSocketInstance(socketContext, webSocket) as ISocket; + var cancellationToken = CancellationToken.None; - // notify connected event on server side if available + var responseFeature = httpContext.Features.Get(); + var responseBodyFeature = httpContext.Features.Get(); + var requestFeature = httpContext.Features.Get(); + + var headers = httpContext.Request.Header + .ToDictionary(); + + var connection = httpContext.Request.Header.Connection; + var upgrade = httpContext.Request.Header.Upgrade; + var key = httpContext.Request.Header.SecWebSocketKey; + + // 1. perform handshake + var responseSender = new ResponseSender(); + var response101 = new ResponseSwitchingProtocols + ( + connection, + upgrade, + ComputeWebSocketAcceptKey(key) + ); + + await responseSender.SendAsync(httpContext, response101, true); + + // 2. create native web socket connection using the raw body stream + var webSocket = new Socket(requestFeature.Body, cancellationToken); + + // 3. create ISocket instance + var instance = await CreateSocketInstance(socketContext, webSocket); + + // 4. notify user code try { - // if an ISocket implementation is registered for this endpoint, - // try to call OnConnectedAsync await instance.OnConnectedAsync(); } catch { - // ignore errors from optional OnConnected handling + // optional } try { - // receive loop: handle fragmented frames and large payloads - while (webSocket.State == WebSocketState.Open) + // 5. receive loop + while (!cancellationToken.IsCancellationRequested) { - var stream = new SocketReadStream(webSocket, socketContext, connectionId); - var message = await stream.ReadMessageAsync(CancellationToken.None); + var frame = await webSocket.ReadFrameAsync(); - // dispatch - await DispatchMessage(instance, message); + switch (frame.MessageType) + { + case SocketMessageType.Text: + { + var text = Encoding.UTF8.GetString(frame.Payload); + var msg = new SocketMessageText + { + Text = text, + SocketId = socketContext.EndpointId?.ToString(), + ConnectionId = connectionId + }; + + await DispatchMessage(instance, msg); + break; + } + + case SocketMessageType.Binary: + { + var msg = new SocketMessageBinary + { + Data = frame.Payload, + SocketId = socketContext.EndpointId?.ToString(), + ConnectionId = connectionId + }; + + await DispatchMessage(instance, msg); + break; + } + + case SocketMessageType.Close: + { + if (frame is SocketFrameClose close) + { + await webSocket.SendCloseAsync + ( + close.Status, + close.Description + ); + closeDescription = $"{close.Status}: {close.Description}"; + } + else + { + await webSocket.SendCloseAsync + ( + SocketCloseStatus.NormalClosure, + "closing" + ); + closeDescription = "normal closure"; + } + return; + } + + case SocketMessageType.Ping: + await webSocket.SendPongAsync(frame.Payload); + break; + + case SocketMessageType.Pong: + break; + + case SocketMessageType.Continuation: + // optional: handle fragmented messages + break; + } } + } - catch (WebSocketException ex) + catch (Exception ex) { - closeStatus = WebSocketCloseStatus.InternalServerError; closeDescription = "transport error"; await instance.OnErrorAsync(ex); } - await instance.OnDisconnectedAsync(closeStatus, closeDescription); + var closeInfo = new SocketCloseInfo(SocketCloseStatus.NormalClosure, closeDescription); + + // 6. disconnect + await instance.OnDisconnectedAsync(closeInfo); } /// /// Returns an enumeration of all socket contexts provided by a plugin. /// - /// A context of a plugin whose sockets are to be returned. + /// + /// A context of a plugin whose sockets are to be returned. + /// /// An enumeration of socket contexts. public IEnumerable GetSockets(IPluginContext pluginContext) { @@ -170,7 +259,8 @@ public IEnumerable GetSockets(Type socketType) } /// - /// Returns an enumeration of socket contexts filtered by endpoint type and application context. + /// Returns an enumeration of socket contexts filtered by endpoint type + /// and application context. /// /// The socket endpoint type. /// The context of the application. @@ -181,7 +271,8 @@ public IEnumerable GetSockets(Type socketType, IApplicationConte } /// - /// Returns an enumeration of socket contexts filtered by endpoint type and application context. + /// Returns an enumeration of socket contexts filtered by endpoint type and + /// application context. /// /// The socket endpoint type. /// The context of the application. @@ -214,12 +305,17 @@ public ISocketContext GetSocket(string applicationId, string socketId) } /// - /// Creates a new socket endpoint instance and returns it. If an instance is cached it is returned. + /// Creates a new socket endpoint instance and returns it. + /// If an instance is cached, the cached instance is returned. /// /// The context used for socket creation. - /// The accepted websocket instance. - /// The created or cached endpoint. - private async Task CreateSocketInstance(ISocketContext socketContext, System.Net.WebSockets.WebSocket webSocket) + /// The accepted native WebSocket connection. + /// The created or cached endpoint instance. + private async Task CreateSocketInstance + ( + ISocketContext socketContext, + Socket webSocket + ) { var resourceItem = _dictionary.GetSocketItem(socketContext); @@ -227,14 +323,14 @@ private async Task CreateSocketInstance(ISocketContext socketContext, { await using var stream = new SocketWriteStream(webSocket, socketContext.MessageType); - var instance = ComponentActivator.CreateInstance + var instance = ComponentActivator.CreateInstance ( resourceItem.SocketClass, socketContext, _httpServerContext, _componentHub, socketContext.ApplicationContext, - stream + stream as ISocketWriteStream ); if (resourceItem.Cache) @@ -275,7 +371,7 @@ private void Register(IApplicationContext applicationContext) continue; } - Register(pluginContext, new[] { applicationContext }); + Register(pluginContext, [applicationContext]); } } @@ -297,7 +393,7 @@ private void Register(IPluginContext pluginContext, IEnumerable(); var cache = false; var subProtocols = new List(); - var messageType = WebSocketMessageType.Text; + var messageType = SocketMessageType.Text; var maxMessageSize = ulong.MinValue; var attributes = socketType.CustomAttributes .Where(x => !x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute))); @@ -325,7 +421,7 @@ private void Register(IPluginContext pluginContext, IEnumerable(customAttribute.ConstructorArguments.FirstOrDefault().Value.ToString()); + messageType = Enum.Parse(customAttribute.ConstructorArguments.FirstOrDefault().Value.ToString()); } else if (customAttribute.AttributeType == typeof(SubProtocolAttribute)) { @@ -426,7 +522,9 @@ public void Remove(IPluginContext pluginContext) /// /// Removes all sockets associated with the specified application context. /// - /// The context of the application that contains the sockets to remove. + /// + /// The context of the application that contains the sockets to remove. + /// internal void Remove(IApplicationContext applicationContext) { if (applicationContext is null) @@ -509,155 +607,6 @@ private void OnRemoveApplication(object sender, IApplicationContext applicationC Remove(applicationContext); } - /// - /// Performs the server-side WebSocket upgrade handshake and creates a WebSocket instance. - /// Validates the required handshake headers, negotiates an optional subprotocol, - /// and delegates the protocol switch to the ASP.NET Core WebSocket feature. - /// - /// - /// The current HTTP context containing the incoming WebSocket upgrade request. - /// - /// - /// Context information for the WebSocket endpoint, including supported subprotocols. - /// - /// - /// A instance representing the established WebSocket connection, - /// or null if the handshake fails and an appropriate HTTP response is sent. - /// - private static Task CreateWebSocket(HttpContext httpContext, ISocketContext socketContext) - { - // basic handshake pre-checks: Upgrade header, Connection header, Sec-WebSocket-Key and version - var request = httpContext.Request; - var wsFeature = httpContext.Features.Get(); - var upgradeHeader = request.Header.Upgrade; - var connectionHeader = request.Header.Connection; - var secKey = request.Header.SecWebSocketKey; - var secVersion = request.Header.SecWebSocketVersion; - var secProtocol = request.Header.SecWebSocketProtocol; - - if (string.IsNullOrWhiteSpace(upgradeHeader) || !upgradeHeader.Equals("websocket", StringComparison.OrdinalIgnoreCase) - || string.IsNullOrWhiteSpace(connectionHeader) || !connectionHeader.Split(',').Select(x => x.Trim()).Any(x => x.Equals("upgrade", StringComparison.OrdinalIgnoreCase)) - || string.IsNullOrWhiteSpace(secKey) - || string.IsNullOrWhiteSpace(secVersion) || !secVersion.Split(',').Select(x => x.Trim()).Any(x => x.Equals("13"))) - { - // missing or invalid websocket handshake headers - throw new SocketHandshakeException("Invalid WebSocket handshake headers"); - } - - // negotiate subprotocol: pick first supported subprotocol present in client's Sec-WebSocket-Protocol header - var negotiatedSubProtocol = ""; - try - { - if (!string.IsNullOrWhiteSpace(secProtocol) && socketContext is not null) - { - var requestedProtocols = secProtocol.Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Trim()); - negotiatedSubProtocol = socketContext.SupportedSubProtocols - .Intersect(requestedProtocols, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(); - } - } - catch - { - // ignore negotiation failures and continue without subprotocol - negotiatedSubProtocol = null; - } - - // accept the websocket; ASP.NET Core will perform the 101 Switching Protocols handshake - var webSocketAcceptContext = new Microsoft.AspNetCore.Http.WebSocketAcceptContext() - { - SubProtocol = negotiatedSubProtocol - }; - - return wsFeature.AcceptAsync(webSocketAcceptContext); - } - - /// - /// Parses a received WebSocket frame into a instance. - /// Supports both text and binary frames, applies JSON deserialization when possible, - /// and enriches the resulting message with connection and endpoint metadata. - /// - /// - /// The unique identifier assigned to the current WebSocket connection. - /// - /// - /// Context information for the WebSocket endpoint, including application and endpoint metadata. - /// - /// - /// The describing the received frame. - /// - /// - /// The memory stream containing the accumulated payload of the WebSocket message. - /// - /// - /// A representing the parsed and enriched message. - /// - private static ISocketMessage ParseMessage - ( - string connectionId, - ISocketContext socketContext, - WebSocketReceiveResult result, - MemoryStream stream - ) - { - // produce SocketMessage from buffer - stream.Seek(0, SeekOrigin.Begin); - - if (result.MessageType == WebSocketMessageType.Text) - { - // extract UTF‑8 text from the stream - var text = Encoding.UTF8.GetString(stream.ToArray()); - - ISocketMessage parsed = null; - - try - { - using var doc = JsonDocument.Parse(text); - - if (doc.RootElement.TryGetProperty("text", out _)) - { - parsed = JsonSerializer.Deserialize(text); - } - else if (doc.RootElement.TryGetProperty("data", out _)) - { - parsed = JsonSerializer.Deserialize(text); - } - else - { - // default - parsed = JsonSerializer.Deserialize(text); - } - } - catch - { - // JSON invalid → fallback to plain text message - parsed = new SocketMessageText - { - Type = null, - Text = text, - ConnectionId = connectionId, - ApplicationId = socketContext?.ApplicationContext?.ApplicationId, - SocketId = socketContext?.EndpointId?.ToString() - }; - } - - return parsed; - } - else // binary - { - var bytes = stream.ToArray(); - - return new SocketMessageBinary - { - Type = null, - Data = bytes, - ConnectionId = connectionId, - ApplicationId = socketContext?.ApplicationContext?.ApplicationId, - SocketId = socketContext?.EndpointId?.ToString() - }; - } - } - /// /// Dispatches the parsed to the socket handler implementation. /// Invokes the receive callback and forwards any handler exceptions to the error callback. @@ -671,8 +620,17 @@ MemoryStream stream /// /// A task that represents the asynchronous dispatch operation. /// + /// + /// Dispatches a parsed to the socket handler. + /// Invokes the receive callback and forwards any handler exceptions to the error callback. + /// private static async Task DispatchMessage(ISocket instance, ISocketMessage message) { + if (instance == null || message == null) + { + return; + } + try { await instance.OnReceiveAsync(message); @@ -683,6 +641,19 @@ private static async Task DispatchMessage(ISocket instance, ISocketMessage messa } } + /// + /// Computes the Sec-WebSocket-Accept header value from the client key. + /// + /// The Sec-WebSocket-Key from the client. + /// The computed Sec-WebSocket-Accept value. + private static string ComputeWebSocketAcceptKey(string key) + { + var combined = key + _webSocketGuid; + var hash = SHA1.HashData(Encoding.UTF8.GetBytes(combined)); + + return Convert.ToBase64String(hash); + } + /// /// Release of unmanaged resources reserved during use. /// diff --git a/src/WebExpress.WebCore/WebSocket/SocketReadStream.cs b/src/WebExpress.WebCore/WebSocket/SocketReadStream.cs deleted file mode 100644 index 094bf4a..0000000 --- a/src/WebExpress.WebCore/WebSocket/SocketReadStream.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using System.Net.WebSockets; -using System.Threading; -using System.Threading.Tasks; - -namespace WebExpress.WebCore.WebSocket -{ - /// - /// A binary-oriented implementation of , - /// exposing incoming WebSocket message data as raw byte segments. - /// - public sealed class SocketReadStream : ISocketReadStream - { - private readonly System.Net.WebSockets.WebSocket _socket; - private readonly ISocketContext _socketContext; - private readonly string _connectionId; - - /// - /// Returns the context associated with the underlying socket connection. - /// - public ISocketContext SocketContext => _socketContext; - - /// - /// Returns the unique identifier for the current connection. - /// - public string ConnectionId => _connectionId; - - /// - /// Initializes a new instance of the SocketReadStream class for reading data - /// from a WebSocket connection. - /// - /// - /// The WebSocket instance representing the underlying connection. Cannot be null. - /// - /// - /// The context object that provides additional information or services related - /// to the socket connection. - /// - /// - /// A unique identifier for the connection associated with this stream. - /// - /// Thrown if the socket parameter is null. - public SocketReadStream(System.Net.WebSockets.WebSocket socket, ISocketContext socketContext, string connectionId) - { - _socket = socket ?? throw new ArgumentNullException(nameof(socket)); - _socketContext = socketContext; - _connectionId = connectionId; - } - - /// - /// Receives data asynchronously from the underlying WebSocket and writes it into - /// the provided buffer. - /// - /// - /// The buffer that receives the incoming data. The method writes the received - /// bytes into this memory region. - /// - /// - /// A cancellation token that can be used to cancel the receive operation. - /// - /// - /// A WebSocketReceiveResult that contains information about the received data, - /// including the number of bytes read, the message type, and whether the message - /// is complete. - /// - public async Task ReadAsync - ( - ArraySegment buffer, - CancellationToken cancellationToken = default - ) - { - var result = await _socket.ReceiveAsync(buffer, cancellationToken) - .ConfigureAwait(false); - - return result; - } - - /// - /// Marks the current message as fully consumed. - /// - public Task CompleteAsync(CancellationToken cancellationToken = default) - { - // no explicit "complete" frame for reading; this is a no-op. - return Task.CompletedTask; - } - - /// - /// Asynchronously closes the underlying WebSocket connection using a normal - /// closure status. - /// - /// - /// A cancellation token that can be used to cancel the close operation. - /// - /// - /// A task that represents the asynchronous close operation. - /// - public Task CloseAsync(CancellationToken cancellationToken = default) - { - return _socket.CloseAsync - ( - WebSocketCloseStatus.NormalClosure, - "Read stream closed", - CancellationToken.None - ); - } - - /// - /// Performs application-defined tasks associated with freeing, releasing, - /// or resetting unmanaged resources asynchronously. - /// - /// - /// A completed because this implementation does not - /// hold unmanaged resources. - /// - public ValueTask DisposeAsync() - { - _socket.CloseAsync - ( - WebSocketCloseStatus.NormalClosure, - "Binary read stream closed", - CancellationToken.None - ); - - return ValueTask.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/SocketWriteStream.cs b/src/WebExpress.WebCore/WebSocket/SocketWriteStream.cs deleted file mode 100644 index 69d41f0..0000000 --- a/src/WebExpress.WebCore/WebSocket/SocketWriteStream.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System; -using System.Net.WebSockets; -using System.Threading; -using System.Threading.Tasks; - -namespace WebExpress.WebCore.WebSocket -{ - /// - /// Provides a WebSocket-based implementation of . - /// Allows writing message data in one or more frames before finalizing the message. - /// - public class SocketWriteStream : ISocketWriteStream - { - private readonly System.Net.WebSockets.WebSocket _socket; - private readonly WebSocketMessageType _messageType; - - /// - /// Initializes a new instance of the class. - /// - /// The underlying WebSocket transport. - /// The WebSocket message type to use for all frames. - public SocketWriteStream(System.Net.WebSockets.WebSocket socket, WebSocketMessageType messageType) - { - _socket = socket; - _messageType = messageType; - } - - /// - /// Writes a chunk of data to the underlying WebSocket transport. - /// This method does not finalize the message; callers may invoke it - /// multiple times to send a message in fragments. - /// - /// The data buffer to write. - /// A token to observe while waiting for the operation to complete. - public async Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - if (_socket.State != WebSocketState.Open) - { - return; - } - - await _socket.SendAsync - ( - buffer, - _messageType, - endOfMessage: false, - cancellationToken - ); - } - - /// - /// Completes the message by sending the final WebSocket frame with - /// endOfMessage set to true. - /// After calling this method, no further writes are allowed. - /// - /// A token to observe while waiting for the operation to complete. - public async Task CompleteAsync(CancellationToken cancellationToken = default) - { - if (_socket.State != WebSocketState.Open) - { - return; - } - - // Send an empty frame marking the end of the message. - await _socket.SendAsync - ( - ReadOnlyMemory.Empty, - _messageType, - endOfMessage: true, - cancellationToken - ); - } - - /// - /// Performs application-defined tasks associated with freeing, releasing, - /// or resetting unmanaged resources asynchronously. - /// - /// - /// A completed because this implementation does not - /// hold unmanaged resources. - /// - public ValueTask DisposeAsync() - { - // No unmanaged resources to release. - return ValueTask.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebStatusPage/IStatusPageManager.cs b/src/WebExpress.WebCore/WebStatusPage/IStatusPageManager.cs index d1c9b05..58436e3 100644 --- a/src/WebExpress.WebCore/WebStatusPage/IStatusPageManager.cs +++ b/src/WebExpress.WebCore/WebStatusPage/IStatusPageManager.cs @@ -42,6 +42,6 @@ public interface IStatusPageManager : IComponentManager /// The application context where the status pages are located or null for an undefined page (may be from another application) that matches the status code. /// The request. /// The response or null. - Response CreateStatusResponse(string message, int status, IApplicationContext applicationContext, Request request); + Response CreateStatusResponse(string message, int status, IApplicationContext applicationContext, IRequest request); } } diff --git a/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs b/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs index dd296e6..244f894 100644 --- a/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs +++ b/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs @@ -333,7 +333,7 @@ public IStatusPageContext GetStatusPage(IApplicationContext applicationContext, /// The application context where the status pages are located or null for an undefined page (may be from another application) that matches the status code. /// The request. /// The response or null. - public Response CreateStatusResponse(string message, int status, IApplicationContext applicationContext, Request request) + public Response CreateStatusResponse(string message, int status, IApplicationContext applicationContext, IRequest request) { StatusPageItem statusPageItem = null; From 3034345a56b7e608063451418339ea13b1dfe099 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Fri, 26 Dec 2025 13:51:00 +0100 Subject: [PATCH 11/53] refactor: websocket protocol --- src/WebExpress.WebCore/HttpServer.cs | 55 ++++++-- .../WebMessage/HttpContext.cs | 32 +---- .../WebMessage/HttpWebSocketContext.cs | 93 +++++++++++++ .../WebMessage/IHttpContext.cs | 53 ++++++++ .../WebMessage/RequestWebSocket.cs | 124 +++++++++++------- .../WebMessage/ResponseSender.cs | 2 +- .../WebSitemap/SearchContext.cs | 2 +- .../WebSocket/ISocketManager.cs | 2 +- .../WebSocket/Protocol/Socket.cs | 27 ++-- .../WebSocket/Protocol/SocketReadStream.cs | 27 ++-- .../WebSocket/SocketManager.cs | 92 ++----------- src/WebExpress.WebCore/WebUri/UriScheme.cs | 15 ++- 12 files changed, 322 insertions(+), 202 deletions(-) create mode 100644 src/WebExpress.WebCore/WebMessage/HttpWebSocketContext.cs create mode 100644 src/WebExpress.WebCore/WebMessage/IHttpContext.cs diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index 6a90669..2944191 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -32,7 +32,7 @@ namespace WebExpress.WebCore /// /// The web server for processing http requests (see RFC 2616). The web server uses Kestrel internally. /// - public class HttpServer : IHost, IHttpApplication + public class HttpServer : IHost, IHttpApplication { /// /// Event is triggered after the web server is started. @@ -256,7 +256,7 @@ public void Stop() /// The context of the web request. /// The previously resolved search result for the request. /// The response to be sent back to the caller. - private IResponse HandleClient(HttpContext context, SearchResult searchResult) + private IResponse HandleClient(IHttpContext context, SearchResult searchResult) { var stopwatch = Stopwatch.StartNew(); var request = context.Request; @@ -421,20 +421,30 @@ private IResponse HandleClient(HttpContext context, SearchResult searchResult) } /// - /// Create an HttpContext with a collection of HTTP features. + /// Creates an appropriate IHttpContext instance (HttpContext or WebSocketContext) + /// based on feature detection. /// - /// - /// A collection of HTTP features to use to create the HttpContext. - /// - /// The HttpContext created. - public HttpContext CreateContext(IFeatureCollection contextFeatures) + /// The feature collection of the request. + /// An IHttpContext instance for the request. + public IHttpContext CreateContext(IFeatureCollection contextFeatures) { try { + var requestFeature = contextFeatures.Get(); + + // check if schema or upgrade header indicates websocket + if (IsWebSocketRequest(requestFeature)) + { + // use WebSocketContext for websocket connections + return new HttpWebSocketContext(contextFeatures, HttpServerContext); + } + + // use regular HttpContext for normal HTTP requests return new HttpContext(contextFeatures, HttpServerContext); } catch (Exception ex) { + // fall back to HttpExceptionContext on error return new HttpExceptionContext(ex, contextFeatures); } } @@ -447,7 +457,7 @@ public HttpContext CreateContext(IFeatureCollection contextFeatures) /// /// The http context that the operation processes. /// Provides an asynchronous operation that handles the http context. - public async Task ProcessRequestAsync(HttpContext httpContext) + public async Task ProcessRequestAsync(IHttpContext httpContext) { var responseSender = new ResponseSender(); @@ -515,7 +525,7 @@ public async Task ProcessRequestAsync(HttpContext httpContext) /// Optional WebSocket endpoint context resolved from the sitemap. May be null /// if the endpoint does not define additional metadata. /// - public async Task HandleWebSocketAsync(HttpContext httpContext, ISocketContext socketContext) + public async Task HandleWebSocketAsync(IHttpContext httpContext, ISocketContext socketContext) { var responseSender = new ResponseSender(); var socketManager = WebEx.ComponentHub.SocketManager; @@ -571,8 +581,31 @@ public async Task HandleWebSocketAsync(HttpContext httpContext, ISocketContext s /// /// The http context to discard. /// The exception that is thrown if processing did not complete successfully; otherwise null. - public void DisposeContext(HttpContext context, Exception exception) + public void DisposeContext(IHttpContext context, Exception exception) + { + } + + /// + /// Checks whether the current request is a WebSocket connection. + /// + /// The HTTP request feature instance. + /// True if it is a WebSocket connection; otherwise, false. + private bool IsWebSocketRequest(IHttpRequestFeature requestFeature) { + // check scheme and "Upgrade" header for websocket protocol + if (requestFeature == null) + { + return false; + } + + var upgradeHeader = requestFeature.Headers.Upgrade; + var scheme = requestFeature.Scheme; + var isWebSocket = + upgradeHeader.Contains("websocket", StringComparer.OrdinalIgnoreCase) || + string.Equals(scheme, "ws", StringComparison.OrdinalIgnoreCase) || + string.Equals(scheme, "wss", StringComparison.OrdinalIgnoreCase); + + return isWebSocket; } } } \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebMessage/HttpContext.cs b/src/WebExpress.WebCore/WebMessage/HttpContext.cs index 3efea25..4239993 100644 --- a/src/WebExpress.WebCore/WebMessage/HttpContext.cs +++ b/src/WebExpress.WebCore/WebMessage/HttpContext.cs @@ -2,14 +2,13 @@ using System; using System.Net; using System.Text; -using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebMessage { /// /// Represents the context of an HTTP request and response. /// - public class HttpContext + public class HttpContext : IHttpContext { /// /// The context of the web server. @@ -80,35 +79,6 @@ public HttpContext(IFeatureCollection contextFeatures, IHttpServerContext httpSe : Encoding.Default; Uri = new Uri(baseUri, requestFeature.RawTarget); - // determine WebSocket upgrade - string upgradeHeader = requestFeature.Headers["Upgrade"]; - - if - ( - !string.IsNullOrEmpty(upgradeHeader) && - upgradeHeader.Equals("websocket", StringComparison.OrdinalIgnoreCase) - ) - { - Request = new RequestWebSocket - ( - httpServerContext, - null, - null, - header, - RequestMethod.GET, - requestFeature.Protocol, - requestFeature.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) - ? UriScheme.Https - : UriScheme.Http, - LocalEndPoint, - RemoteEndPoint, - requestFeature.Headers["Sec-WebSocket-Key"], - requestFeature.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) - ); - - return; - } - Request = new Request(contextFeatures, header, httpServerContext); } } diff --git a/src/WebExpress.WebCore/WebMessage/HttpWebSocketContext.cs b/src/WebExpress.WebCore/WebMessage/HttpWebSocketContext.cs new file mode 100644 index 0000000..13b26b0 --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/HttpWebSocketContext.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Http.Features; +using System; +using System.Net; +using System.Text; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Represents the context for a WebSocket connection. + /// + public class HttpWebSocketContext : IHttpContext + { + /// + /// Returns the context of the web server. + /// + public IHttpServerContext HttpServerContext { get; protected set; } + + /// + /// Returns the context id. + /// + public string Id { get; protected set; } + + /// + /// Returns the request associated with this context. + /// + public IRequest Request { get; protected set; } + + /// + /// Returns the ip address and port number of the server receiving the request. + /// + public EndPoint LocalEndPoint { get; protected set; } + + /// + /// Returns the ip address and port number of the client making the request. + /// + public EndPoint RemoteEndPoint { get; protected set; } + + /// + /// Returns the set of features for this context. + /// + public IFeatureCollection Features { get; protected set; } + + /// + /// Returns the encoding used by this context. + /// + public Encoding Encoding { get; protected set; } = Encoding.Default; + + /// + /// Returns the URI associated with this context. + /// + public Uri Uri { get; internal set; } + + /// + /// Returns the WebSocket key for this context. + /// + public string WebSocketKey { get; protected set; } + + /// + /// Returns whether this WebSocket is secure (wss). + /// + public bool IsSecureWebSocket { get; protected set; } + + /// + /// Initializes a new instance of the WebSocketContext class. + /// + /// The initial set of features. + /// The context of the web server. + public HttpWebSocketContext(IFeatureCollection contextFeatures, IHttpServerContext httpServerContext) + { + var connectionFeature = contextFeatures.Get(); + var requestFeature = contextFeatures.Get(); + var header = new RequestHeaderFields(contextFeatures); + var baseUri = new UriBuilder(requestFeature.Scheme, header.Host, connectionFeature.LocalPort).Uri; + + Features = contextFeatures; + HttpServerContext = httpServerContext; + Id = connectionFeature.ConnectionId; + LocalEndPoint = new IPEndPoint(connectionFeature.LocalIpAddress, connectionFeature.LocalPort); + RemoteEndPoint = new IPEndPoint(connectionFeature.RemoteIpAddress, connectionFeature.RemotePort); + + Encoding = requestFeature.Headers.ContentEncoding.Count != 0 + ? Encoding.GetEncoding(requestFeature.Headers.ContentEncoding) + : Encoding.Default; + Uri = new Uri(baseUri, requestFeature.RawTarget); + + // always initialize as websocket-request for this context + Request = new RequestWebSocket(contextFeatures, header, httpServerContext); + + WebSocketKey = requestFeature.Headers["Sec-WebSocket-Key"]; + IsSecureWebSocket = requestFeature.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase); + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebMessage/IHttpContext.cs b/src/WebExpress.WebCore/WebMessage/IHttpContext.cs new file mode 100644 index 0000000..b5fd04d --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/IHttpContext.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Http.Features; +using System; +using System.Net; +using System.Text; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Represents the context of an HTTP request and response. + /// + public interface IHttpContext + { + /// + /// Returns the context of the web server. + /// + IHttpServerContext HttpServerContext { get; } + + /// + /// Returns the context id. + /// + string Id { get; } + + /// + /// Returns the request data. + /// + IRequest Request { get; } + + /// + /// Returns the ip address and port number of the server that receives the request. + /// + EndPoint LocalEndPoint { get; } + + /// + /// Returns the ip address and port number of the client where the request originated. + /// + EndPoint RemoteEndPoint { get; } + + /// + /// Returns the set of features. + /// + IFeatureCollection Features { get; } + + /// + /// Returns the encoding used. + /// + Encoding Encoding { get; } + + /// + /// Returns the URI. + /// + Uri Uri { get; } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs b/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs index 767d37a..ca68018 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs @@ -1,4 +1,5 @@ -using System; +using Microsoft.AspNetCore.Http.Features; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -105,43 +106,39 @@ public CultureInfo Culture /// Initializes a new instance for a WebSocket request. /// Use this after WebSocket handshake is established. /// - /// The server context. - /// The endpoint URI. - /// The session. - /// Header fields. - /// Method (typically GET at handshake). - /// HTTP version. - /// The URI scheme (ws, wss). - /// The local endpoint. - /// The remote endpoint. - /// Trace identifier. - /// Whether the connection is secure. - internal RequestWebSocket - ( - IHttpServerContext httpServerContext, - UriEndpoint uri, - Session session, - RequestHeaderFields header, - RequestMethod method, - string protocoll, - UriScheme scheme, - EndPoint localEndPoint, - EndPoint remoteEndPoint, - string traceId, - bool isSecureConnection - ) + /// The feature collection from ASP.NET Core. + /// The parsed header fields of the request. + /// The context of the web server. + internal RequestWebSocket(IFeatureCollection contextFeatures, RequestHeaderFields header, IHttpServerContext httpServerContext) { HttpServerContext = httpServerContext; - Uri = uri; - Session = session; Header = header; - Method = method; - Protocoll = protocoll; - Scheme = scheme; - LocalEndPoint = localEndPoint; - RemoteEndPoint = remoteEndPoint; - RequestTraceIdentifier = traceId; - IsSecureConnection = isSecureConnection; + + var connectionFeature = contextFeatures.Get(); + var requestFeature = contextFeatures.Get(); + + Method = RequestMethod.GET; // WebSocket handshake always uses GET + Protocoll = requestFeature.Protocol; + + Scheme = requestFeature.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) ? UriScheme.Wss : + requestFeature.Scheme.Equals("ws", StringComparison.OrdinalIgnoreCase) ? UriScheme.Ws : UriScheme.Http; + + LocalEndPoint = new IPEndPoint(connectionFeature.LocalIpAddress, connectionFeature.LocalPort); + RemoteEndPoint = new IPEndPoint(connectionFeature.RemoteIpAddress, connectionFeature.RemotePort); + RequestTraceIdentifier = connectionFeature.ConnectionId; + IsSecureConnection = Scheme == UriScheme.Wss; + + // build the uri-endpoint for WebSocket (assume raw target is path + query) + Uri = new UriEndpoint + ( + Scheme, + new UriAuthority() + { + Host = Header.Host, + Port = connectionFeature.LocalPort + }, + requestFeature.RawTarget + ); // WebSocket specific defaults WebSocketMessageType = null; @@ -151,9 +148,11 @@ bool isSecureConnection } /// - /// Adds several parameters. + /// Adds a collection of parameters to the current instance. /// - /// The parameters. + /// + /// An enumerable collection of objects to add. Cannot be null. + /// public void AddParameter(IEnumerable param) { foreach (var p in param) @@ -163,9 +162,13 @@ public void AddParameter(IEnumerable param) } /// - /// Adds one parameter. + /// Adds a parameter to the collection, replacing any existing parameter with the + /// same key (case-insensitive). /// - /// The parameter. + /// + /// The parameter to add to the collection. Cannot be null. The parameter's key + /// is used as the unique identifier. + /// public void AddParameter(Parameter param) { var key = param.Key.ToLower(); @@ -177,10 +180,15 @@ public void AddParameter(Parameter param) } /// - /// Returns a parameter by name. + /// Retrieves the parameter with the specified name, if it exists. /// - /// The name of the parameter. - /// The value. + /// + /// The name of the parameter to retrieve. Cannot be null, empty, or consist + /// only of white-space characters. The comparison is case-insensitive. + /// + /// + /// The parameter associated with the specified name, or null if no such parameter exists. + /// public IParameter GetParameter(string name) { if (!string.IsNullOrWhiteSpace(name) && HasParameter(name)) @@ -192,17 +200,26 @@ public IParameter GetParameter(string name) } /// - /// Returns a parameter by type. + /// Retrieves the parameter of the specified type from the current parameter + /// collection, if it exists. /// - /// The parameter type. - /// The value. + /// + /// The type of parameter to retrieve. Must implement the IParameter interface. + /// + /// + /// An instance of the specified parameter type with its value and scope set + /// if the parameter exists; otherwise, null. + /// public IParameter GetParameter() where TParameter : IParameter { var parameter = Parameter.GetParameter(); - if (parameter is not null + if + ( + parameter is not null && !string.IsNullOrWhiteSpace(parameter.Key) - && HasParameter(parameter.Key)) + && HasParameter(parameter.Key) + ) { var p = _param[parameter.Key.ToLower()]; parameter.Value = p.Value; @@ -215,10 +232,14 @@ public IParameter GetParameter() } /// - /// Checks whether a parameter exists. + /// Determines whether a parameter with the specified name exists. /// - /// The name of the parameter. - /// True if the parameter is present, false otherwise. + /// + /// The name of the parameter to locate. The comparison is case-insensitive. Can be null. + /// + /// + /// True if a parameter with the specified name exists; otherwise, false. + /// public bool HasParameter(string name) { if (name is null) @@ -230,7 +251,8 @@ public bool HasParameter(string name) } /// - /// Parse the session parameters. + /// Parses session parameters from the current session and adds them to + /// the parameter collection. /// private void ParseSessionParams() { diff --git a/src/WebExpress.WebCore/WebMessage/ResponseSender.cs b/src/WebExpress.WebCore/WebMessage/ResponseSender.cs index 77ed2b3..a77b52a 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseSender.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseSender.cs @@ -17,7 +17,7 @@ public class ResponseSender /// The reply message. /// Indicates whether the connection should be kept alive after sending the response. /// A task representing the asynchronous operation. - public async Task SendAsync(HttpContext context, IResponse response, bool keepAlive = false) + public async Task SendAsync(IHttpContext context, IResponse response, bool keepAlive = false) { try { diff --git a/src/WebExpress.WebCore/WebSitemap/SearchContext.cs b/src/WebExpress.WebCore/WebSitemap/SearchContext.cs index 7ba3c4b..bff4d1e 100644 --- a/src/WebExpress.WebCore/WebSitemap/SearchContext.cs +++ b/src/WebExpress.WebCore/WebSitemap/SearchContext.cs @@ -16,7 +16,7 @@ public class SearchContext /// /// Returns the http context. /// - public HttpContext HttpContext { get; internal set; } + public IHttpContext HttpContext { get; internal set; } /// /// Returns the server context. diff --git a/src/WebExpress.WebCore/WebSocket/ISocketManager.cs b/src/WebExpress.WebCore/WebSocket/ISocketManager.cs index 1199c37..847f076 100644 --- a/src/WebExpress.WebCore/WebSocket/ISocketManager.cs +++ b/src/WebExpress.WebCore/WebSocket/ISocketManager.cs @@ -98,6 +98,6 @@ IEnumerable GetSockets() /// /// A task that represents the asynchronous handling of the WebSocket connection. /// - Task HandleConnectionAsync(HttpContext httpContext, ISocketContext socketContext); + Task HandleConnectionAsync(IHttpContext httpContext, ISocketContext socketContext); } } \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/Socket.cs b/src/WebExpress.WebCore/WebSocket/Protocol/Socket.cs index ab628ef..32c1f1f 100644 --- a/src/WebExpress.WebCore/WebSocket/Protocol/Socket.cs +++ b/src/WebExpress.WebCore/WebSocket/Protocol/Socket.cs @@ -15,6 +15,11 @@ public class Socket private readonly Stream _stream; private readonly CancellationToken _token; + /// + /// Provides access to the underlying data stream. + /// + internal Stream Stream => _stream; + /// /// Occurs when a text message is received, allowing subscribers to handle the message asynchronously. /// @@ -64,12 +69,16 @@ public async Task StartAsync() case SocketMessageType.Text: var text = Encoding.UTF8.GetString(frame.Payload); if (OnTextMessage != null) + { await OnTextMessage(text); + } break; case SocketMessageType.Binary: if (OnBinaryMessage != null) + { await OnBinaryMessage(frame.Payload); + } break; case SocketMessageType.Close: @@ -83,7 +92,9 @@ public async Task StartAsync() } if (OnClose != null) + { await OnClose(); + } return; @@ -200,14 +211,14 @@ private async Task SendFrameAsync(SocketMessageType type, byte[] payload) { ms.WriteByte(126); var len = BitConverter.GetBytes((ushort)payload.Length); - if (BitConverter.IsLittleEndian) Array.Reverse(len); + if (BitConverter.IsLittleEndian) { Array.Reverse(len); } ms.Write(len); } else { ms.WriteByte(127); var len = BitConverter.GetBytes((ulong)payload.Length); - if (BitConverter.IsLittleEndian) Array.Reverse(len); + if (BitConverter.IsLittleEndian) { Array.Reverse(len); } ms.Write(len); } @@ -218,17 +229,5 @@ private async Task SendFrameAsync(SocketMessageType type, byte[] payload) await _stream.WriteAsync(buffer, 0, buffer.Length, _token); await _stream.FlushAsync(_token); } - - /// - /// Asynchronously reads the next frame from the underlying network stream. - /// - /// - /// A task that represents the asynchronous read operation. The task result - /// contains the next read from the stream. - /// - public Task ReadFrameAsync() - { - return Task.Run(() => SocketFrameParser.ReadFrame(_stream), _token); - } } } \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStream.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStream.cs index 1a81d5e..a0f641c 100644 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStream.cs +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStream.cs @@ -32,6 +32,9 @@ public sealed class SocketReadStream : ISocketReadStream /// Initializes a new instance of the SocketReadStream class for reading data /// from a native WebExpress WebSocket connection. /// + /// The WebExpress WebSocket wrapper. + /// The logical socket context. + /// The connection identifier. public SocketReadStream(Socket socket, ISocketContext socketContext, string connectionId) { _socket = socket ?? throw new ArgumentNullException(nameof(socket)); @@ -43,25 +46,31 @@ public SocketReadStream(Socket socket, ISocketContext socketContext, string conn /// Reads a chunk of data from the underlying WebSocket transport. /// Supports fragmented messages by returning partial payload segments. /// - public async Task ReadAsync( + /// The buffer receiving the data. + /// The cancellation token for the async read operation. + /// A result indicating the bytes read and message boundaries. + public async Task ReadAsync + ( ArraySegment buffer, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { - // Load a new frame if needed + // load a new frame if needed if (_currentFrame == null) { - _currentFrame = await _socket.ReadFrameAsync(); + // uses the internal stream from the Socket class + _currentFrame = await Task.Run(() => SocketFrameParser.ReadFrame(_socket.Stream), cancellationToken); _frameOffset = 0; } var payload = _currentFrame.Payload; - // Remaining bytes in this frame + // remaining bytes in this frame int remaining = payload.Length - _frameOffset; if (remaining <= 0) { - // End of message + // end of message var messageType = _currentFrame.MessageType; _currentFrame = null; @@ -72,7 +81,7 @@ public async Task ReadAsync( ); } - // Copy as much as fits into the buffer + // copy as much as fits into the buffer int toCopy = Math.Min(buffer.Count, remaining); Array.Copy( @@ -104,6 +113,7 @@ public async Task ReadAsync( /// Marks the current message as fully consumed. /// For the native protocol, this is a no-op. /// + /// The cancellation token (unused). public Task CompleteAsync(CancellationToken cancellationToken = default) { return Task.CompletedTask; @@ -129,6 +139,7 @@ public Task CloseAsync /// /// Performs cleanup operations for the read stream. /// + /// A value task indicating the stream was disposed. public ValueTask DisposeAsync() { try @@ -137,7 +148,7 @@ public ValueTask DisposeAsync() } catch { - // Socket already closed or broken – ignore + // socket already closed or broken – ignore } return ValueTask.CompletedTask; diff --git a/src/WebExpress.WebCore/WebSocket/SocketManager.cs b/src/WebExpress.WebCore/WebSocket/SocketManager.cs index 3b2957b..608bbff 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketManager.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketManager.cs @@ -97,19 +97,12 @@ private SocketManager(IComponentHub componentHub, IHttpServerContext httpServerC /// /// A task that represents the asynchronous handling of the WebSocket connection. /// - public async Task HandleConnectionAsync(HttpContext httpContext, ISocketContext socketContext) + public async Task HandleConnectionAsync(IHttpContext httpContext, ISocketContext socketContext) { var connectionId = Guid.NewGuid().ToString(); var closeDescription = "closing"; var cancellationToken = CancellationToken.None; - - var responseFeature = httpContext.Features.Get(); - var responseBodyFeature = httpContext.Features.Get(); var requestFeature = httpContext.Features.Get(); - - var headers = httpContext.Request.Header - .ToDictionary(); - var connection = httpContext.Request.Header.Connection; var upgrade = httpContext.Request.Header.Upgrade; var key = httpContext.Request.Header.SecWebSocketKey; @@ -144,75 +137,7 @@ public async Task HandleConnectionAsync(HttpContext httpContext, ISocketContext try { // 5. receive loop - while (!cancellationToken.IsCancellationRequested) - { - var frame = await webSocket.ReadFrameAsync(); - - switch (frame.MessageType) - { - case SocketMessageType.Text: - { - var text = Encoding.UTF8.GetString(frame.Payload); - var msg = new SocketMessageText - { - Text = text, - SocketId = socketContext.EndpointId?.ToString(), - ConnectionId = connectionId - }; - - await DispatchMessage(instance, msg); - break; - } - - case SocketMessageType.Binary: - { - var msg = new SocketMessageBinary - { - Data = frame.Payload, - SocketId = socketContext.EndpointId?.ToString(), - ConnectionId = connectionId - }; - - await DispatchMessage(instance, msg); - break; - } - - case SocketMessageType.Close: - { - if (frame is SocketFrameClose close) - { - await webSocket.SendCloseAsync - ( - close.Status, - close.Description - ); - closeDescription = $"{close.Status}: {close.Description}"; - } - else - { - await webSocket.SendCloseAsync - ( - SocketCloseStatus.NormalClosure, - "closing" - ); - closeDescription = "normal closure"; - } - return; - } - - case SocketMessageType.Ping: - await webSocket.SendPongAsync(frame.Payload); - break; - - case SocketMessageType.Pong: - break; - - case SocketMessageType.Continuation: - // optional: handle fragmented messages - break; - } - } - + await webSocket.StartAsync(); } catch (Exception ex) { @@ -241,11 +166,12 @@ public IEnumerable GetSockets(IPluginContext pluginContext) /// /// Returns an enumeration of socket contexts filtered by endpoint type. /// - /// The socket endpoint type. + /// The socket endpoint type. /// An enumeration of socket contexts. - public IEnumerable GetSockets() where T : ISocket + public IEnumerable GetSockets() + where TSocket : ISocket { - return GetSockets(typeof(T)); + return GetSockets(typeof(TSocket)); } /// @@ -274,12 +200,12 @@ public IEnumerable GetSockets(Type socketType, IApplicationConte /// Returns an enumeration of socket contexts filtered by endpoint type and /// application context. /// - /// The socket endpoint type. + /// The socket endpoint type. /// The context of the application. /// An enumeration of socket contexts. - public IEnumerable GetSockets(IApplicationContext applicationContext) where T : ISocket + public IEnumerable GetSockets(IApplicationContext applicationContext) where TSocket : ISocket { - return _dictionary.GetSockets(applicationContext); + return _dictionary.GetSockets(applicationContext); } /// diff --git a/src/WebExpress.WebCore/WebUri/UriScheme.cs b/src/WebExpress.WebCore/WebUri/UriScheme.cs index 2b11f04..4c616a3 100644 --- a/src/WebExpress.WebCore/WebUri/UriScheme.cs +++ b/src/WebExpress.WebCore/WebUri/UriScheme.cs @@ -38,7 +38,18 @@ public enum UriScheme /// /// The Mailto URI scheme. /// - Mailto + Mailto, + + /// + /// Represents a web service or related functionality. + /// + Ws, + + /// + /// Specifies the WebSocket Secure (WSS) protocol, which provides encrypted + /// communication over WebSockets using TLS. + /// + Wss } /// @@ -62,6 +73,8 @@ public static string ToString(this UriScheme scheme) UriScheme.Ldap => "ldap", UriScheme.Ldaps => "ldaps", UriScheme.Mailto => "mailto", + UriScheme.Ws => "ws", + UriScheme.Wss => "wss", _ => "http" }; } From 0a50b23062c6791fd8e03af4af8330798ee70157 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sat, 27 Dec 2025 14:02:53 +0100 Subject: [PATCH 12/53] refactor: websocket protocol --- src/WebExpress.WebCore/HttpServer.cs | 14 +- .../WebMessage/HttpWebSocketContext.cs | 2 +- .../WebSitemap/SitemapManager.cs | 2 +- .../WebSocket/Protocol/ISocketReadStream.cs | 3 +- .../WebSocket/Protocol/Socket.cs | 233 ------------------ .../WebSocket/Protocol/SocketCloseInfo.cs | 8 +- .../WebSocket/Protocol/SocketCloseStatus.cs | 138 ----------- .../WebSocket/Protocol/SocketFrame.cs | 30 --- .../WebSocket/Protocol/SocketFrameClose.cs | 41 --- .../WebSocket/Protocol/SocketFrameParser.cs | 110 --------- .../Protocol/SocketHandshakeException.cs | 16 ++ .../WebSocket/Protocol/SocketReadStream.cs | 101 +++----- .../WebSocket/Protocol/SocketWriteStream.cs | 24 +- .../WebSocket/SocketManager.cs | 84 ++++--- 14 files changed, 135 insertions(+), 671 deletions(-) delete mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/Socket.cs delete mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseStatus.cs delete mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketFrame.cs delete mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketFrameClose.cs delete mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketFrameParser.cs diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index 2944191..ccdd7df 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets; +using Microsoft.AspNetCore.WebSockets; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -132,6 +133,13 @@ public void Start() x.LoggingFields = Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.All; } ); + serviceCollection.AddWebSockets + ( + x => + { + x.KeepAliveInterval = TimeSpan.FromSeconds(120); + } + ); var serverOptions = new OptionsWrapper(new KestrelServerOptions() { @@ -477,7 +485,7 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) return; } - var culture = httpContext?.Request.Culture; + var culture = httpContext?.Request?.Culture; var searchResult = WebEx.ComponentHub.SitemapManager.SearchResource(httpContext?.Uri, new SearchContext() { Culture = culture, @@ -498,7 +506,7 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) return; } - if (httpContext.Request is RequestWebSocket) + if (httpContext is HttpWebSocketContext) { // try to obtain websocket context and optional handler var socketContext = searchResult.EndpointContext as ISocketContext; @@ -531,7 +539,7 @@ public async Task HandleWebSocketAsync(IHttpContext httpContext, ISocketContext var socketManager = WebEx.ComponentHub.SocketManager; // validate that the request is a websocket upgrade - if (httpContext.Request is not RequestWebSocket) + if (httpContext is not HttpWebSocketContext) { // websocket not requested by client; return 400 Bad Request await responseSender.SendAsync(httpContext, new ResponseBadRequest(new StatusMessage("WebSocket upgrade required"))); diff --git a/src/WebExpress.WebCore/WebMessage/HttpWebSocketContext.cs b/src/WebExpress.WebCore/WebMessage/HttpWebSocketContext.cs index 13b26b0..cda62c8 100644 --- a/src/WebExpress.WebCore/WebMessage/HttpWebSocketContext.cs +++ b/src/WebExpress.WebCore/WebMessage/HttpWebSocketContext.cs @@ -86,7 +86,7 @@ public HttpWebSocketContext(IFeatureCollection contextFeatures, IHttpServerConte // always initialize as websocket-request for this context Request = new RequestWebSocket(contextFeatures, header, httpServerContext); - WebSocketKey = requestFeature.Headers["Sec-WebSocket-Key"]; + WebSocketKey = header.SecWebSocketKey; IsSecureWebSocket = requestFeature.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase); } } diff --git a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs index d5f6990..078767a 100644 --- a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs +++ b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs @@ -115,7 +115,7 @@ public SearchResult SearchResource(Uri requestUri, SearchContext searchContext) var result = SearchNode ( _root, - new Queue(requestUri.Segments.Select(x => x == "/" ? x : (x.EndsWith('/') ? x[..^1] : x))), + new Queue(requestUri?.Segments.Select(x => x == "/" ? x : (x.EndsWith('/') ? x[..^1] : x))), new Queue(), searchContext ); diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/ISocketReadStream.cs b/src/WebExpress.WebCore/WebSocket/Protocol/ISocketReadStream.cs index 8d29851..bf4fb3e 100644 --- a/src/WebExpress.WebCore/WebSocket/Protocol/ISocketReadStream.cs +++ b/src/WebExpress.WebCore/WebSocket/Protocol/ISocketReadStream.cs @@ -1,4 +1,5 @@ using System; +using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; @@ -58,7 +59,7 @@ Task ReadAsync /// A task that represents the asynchronous close operation. Task CloseAsync ( - SocketCloseStatus status = SocketCloseStatus.NormalClosure, + WebSocketCloseStatus status = WebSocketCloseStatus.NormalClosure, string description = null, CancellationToken cancellationToken = default ); diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/Socket.cs b/src/WebExpress.WebCore/WebSocket/Protocol/Socket.cs deleted file mode 100644 index 32c1f1f..0000000 --- a/src/WebExpress.WebCore/WebSocket/Protocol/Socket.cs +++ /dev/null @@ -1,233 +0,0 @@ -using System; -using System.IO; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace WebExpress.WebCore.WebSocket.Protocol -{ - /// - /// Represents a WebSocket connection with send/receive logic - /// using the native WebExpress WebSocket protocol. - /// - public class Socket - { - private readonly Stream _stream; - private readonly CancellationToken _token; - - /// - /// Provides access to the underlying data stream. - /// - internal Stream Stream => _stream; - - /// - /// Occurs when a text message is received, allowing subscribers to handle the message asynchronously. - /// - public event Func OnTextMessage; - - /// - /// Occurs when a binary message is received, providing the message data as a byte array. - /// - public event Func OnBinaryMessage; - - /// - /// Occurs when the component is being closed, allowing subscribers to perform asynchronous cleanup or - /// finalization tasks. - /// - public event Func OnClose; - - /// - /// Initializes a new instance of the Socket class using the specified data - /// stream and cancellation token. - /// - /// - /// The stream to use for network communication. Must be readable and writable. - /// - /// - /// A cancellation token that can be used to cancel operations associated - /// with this socket. - /// - public Socket(Stream stream, CancellationToken token) - { - _stream = stream; - _token = token; - } - - /// - /// Starts reading frames from the underlying stream and dispatches - /// them to the appropriate event handlers. - /// - /// A task that represents the asynchronous operation. - public async Task StartAsync() - { - while (!_token.IsCancellationRequested) - { - var frame = SocketFrameParser.ReadFrame(_stream); - - switch (frame.MessageType) - { - case SocketMessageType.Text: - var text = Encoding.UTF8.GetString(frame.Payload); - if (OnTextMessage != null) - { - await OnTextMessage(text); - } - break; - - case SocketMessageType.Binary: - if (OnBinaryMessage != null) - { - await OnBinaryMessage(frame.Payload); - } - break; - - case SocketMessageType.Close: - if (frame is SocketFrameClose close) - { - await SendCloseAsync(close.Status, close.Description); - } - else - { - await SendCloseAsync(SocketCloseStatus.NormalClosure, "closing"); - } - - if (OnClose != null) - { - await OnClose(); - } - - return; - - case SocketMessageType.Ping: - await SendPongAsync(frame.Payload); - break; - - case SocketMessageType.Pong: - break; - - case SocketMessageType.Continuation: - // optional: handle fragmented messages - break; - } - } - } - - /// - /// Asynchronously sends a text message over the WebSocket connection. - /// - /// The text message to send. Cannot be null. - /// A task that represents the asynchronous send operation. - public Task SendTextAsync(string message) - { - var payload = Encoding.UTF8.GetBytes(message); - return SendFrameAsync(SocketMessageType.Text, payload); - } - - /// - /// Asynchronously sends a binary message to the connected endpoint. - /// - /// The binary data to send. Cannot be null. - /// A task that represents the asynchronous send operation. - public Task SendBinaryAsync(byte[] data) - { - return SendFrameAsync(SocketMessageType.Binary, data); - } - - /// - /// Initiates an asynchronous close handshake by sending a WebSocket close frame - /// to the remote endpoint. - /// - /// - /// The status code indicating the reason for closure. - /// - /// - /// An optional description providing additional context for the closure. - /// - /// - /// A task that represents the asynchronous close operation. - /// - public Task SendCloseAsync(SocketCloseStatus status, string description = null) - { - byte[] reasonBytes = description != null - ? Encoding.UTF8.GetBytes(description) - : []; - - byte[] payload = new byte[2 + reasonBytes.Length]; - - // statuscode (2 byte, big endian) - payload[0] = (byte)((ushort)status >> 8); - payload[1] = (byte)((ushort)status & 0xFF); - - if (reasonBytes.Length > 0) - { - Array.Copy(reasonBytes, 0, payload, 2, reasonBytes.Length); - } - - return SendFrameAsync(SocketMessageType.Close, payload); - } - - /// - /// Sends a WebSocket Pong frame asynchronously with the specified payload. - /// - /// - /// The optional application data to include in the Pong frame. May be null - /// or empty if no payload is required. - /// - /// - /// A task that represents the asynchronous send operation. - /// - public Task SendPongAsync(byte[] payload) - { - return SendFrameAsync(SocketMessageType.Pong, payload); - } - - /// - /// Asynchronously sends a WebSocket frame with the specified message type - /// and payload over the underlying stream. - /// - /// - /// The type of the WebSocket message to send. Determines the opcode set in - /// the frame header. - /// - /// - /// The payload data to include in the frame. Must not be null. - /// - /// - /// A task that represents the asynchronous send operation. - /// - private async Task SendFrameAsync(SocketMessageType type, byte[] payload) - { - using var ms = new MemoryStream(); - - // FIN + opcode - ms.WriteByte((byte)(0b1000_0000 | type.ToOpcode())); - - // payload length - if (payload.Length < 126) - { - ms.WriteByte((byte)payload.Length); - } - else if (payload.Length <= ushort.MaxValue) - { - ms.WriteByte(126); - var len = BitConverter.GetBytes((ushort)payload.Length); - if (BitConverter.IsLittleEndian) { Array.Reverse(len); } - ms.Write(len); - } - else - { - ms.WriteByte(127); - var len = BitConverter.GetBytes((ulong)payload.Length); - if (BitConverter.IsLittleEndian) { Array.Reverse(len); } - ms.Write(len); - } - - // payload - ms.Write(payload); - - var buffer = ms.ToArray(); - await _stream.WriteAsync(buffer, 0, buffer.Length, _token); - await _stream.FlushAsync(_token); - } - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseInfo.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseInfo.cs index ba4df4c..dbb453d 100644 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseInfo.cs +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseInfo.cs @@ -1,4 +1,6 @@ -namespace WebExpress.WebCore.WebSocket.Protocol +using System.Net.WebSockets; + +namespace WebExpress.WebCore.WebSocket.Protocol { /// /// Represents information about the reason a socket connection was closed, @@ -9,7 +11,7 @@ public class SocketCloseInfo /// /// Returns the status that indicates the reason the socket was closed. /// - public SocketCloseStatus Status { get; } + public WebSocketCloseStatus Status { get; } /// /// Returns the description associated with this instance. @@ -27,7 +29,7 @@ public class SocketCloseInfo /// An optional textual description providing additional details about the /// socket closure. May be null. /// - public SocketCloseInfo(SocketCloseStatus status, string description) + public SocketCloseInfo(WebSocketCloseStatus status, string description) { Status = status; Description = description; diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseStatus.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseStatus.cs deleted file mode 100644 index 3b03940..0000000 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseStatus.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System; - -namespace WebExpress.WebCore.WebSocket.Protocol -{ - /// - /// Defines WebSocket close status codes according to RFC 6455. - /// - public enum SocketCloseStatus : ushort - { - /// - /// Indicates that the connection was closed normally, as defined by the - /// WebSocket protocol. - /// - NormalClosure = 1000, - - /// - /// Indicates that the connection is closing because the endpoint is going - /// away, such as a server shutdown or a browser navigating away from a page. - /// - GoingAway = 1001, - - /// - /// Indicates that a protocol error has occurred during communication. - /// - ProtocolError = 1002, - - /// - /// Indicates that the received data is not supported by the protocol or - /// application. - /// - UnsupportedData = 1003, - - /// - /// Indicates that no status code was received from the remote endpoint. - /// - NoStatusReceived = 1005, - - /// - /// Indicates that the connection was closed abnormally, without a close - /// frame being sent or received. - /// - AbnormalClosure = 1006, - - /// - /// Indicates that the received data does not conform to the expected payload - /// format or contains invalid data. - /// - InvalidPayloadData = 1007, - - /// - /// Indicates that a message was closed because it violated a policy defined - /// by the endpoint or server. - /// - PolicyViolation = 1008, - - /// - /// Indicates that a message was rejected because its size exceeds the - /// maximum allowed limit. - /// - MessageTooBig = 1009, - - /// - /// Indicates that the extension is required for the operation to proceed. - /// - MandatoryExtension = 1010, - - /// - /// Indicates that an internal server error has occurred. - /// - InternalServerError = 1011 - } - - /// - /// Provides helper and conversion methods for . - /// - public static class SocketCloseStatusExtensions - { - /// - /// Returns a human-readable description for the given close status. - /// - public static string GetDescription(this SocketCloseStatus status) - { - return status switch - { - SocketCloseStatus.NormalClosure => "Normal closure", - SocketCloseStatus.GoingAway => "Going away", - SocketCloseStatus.ProtocolError => "Protocol error", - SocketCloseStatus.UnsupportedData => "Unsupported data", - SocketCloseStatus.NoStatusReceived => "No status received", - SocketCloseStatus.AbnormalClosure => "Abnormal closure", - SocketCloseStatus.InvalidPayloadData => "Invalid payload data", - SocketCloseStatus.PolicyViolation => "Policy violation", - SocketCloseStatus.MessageTooBig => "Message too big", - SocketCloseStatus.MandatoryExtension => "Mandatory extension missing", - SocketCloseStatus.InternalServerError => "Internal server error", - _ => "Unknown close status" - }; - } - - /// - /// Returns true if the close status indicates an error condition. - /// - public static bool IsError(this SocketCloseStatus status) - { - return status switch - { - SocketCloseStatus.NormalClosure => false, - SocketCloseStatus.GoingAway => false, - SocketCloseStatus.NoStatusReceived => false, - _ => true - }; - } - - /// - /// Returns true if the status code is reserved for internal use. - /// - public static bool IsReserved(this SocketCloseStatus status) - { - return status switch - { - SocketCloseStatus.NoStatusReceived => true, - SocketCloseStatus.AbnormalClosure => true, - _ => false - }; - } - - /// - /// Attempts to convert a raw ushort value into a . - /// Returns null if the value is not a valid RFC 6455 close code. - /// - public static SocketCloseStatus? TryParse(ushort code) - { - return Enum.IsDefined(typeof(SocketCloseStatus), code) - ? (SocketCloseStatus)code - : null; - } - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrame.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrame.cs deleted file mode 100644 index 1b7a2af..0000000 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrame.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace WebExpress.WebCore.WebSocket.Protocol -{ - /// - /// Represents a parsed WebSocket frame in the native - /// WebExpress WebSocket protocol implementation. - /// - public class SocketFrame - { - /// - /// Indicates whether this frame is the final frame of the message. - /// - public bool Fin { get; set; } - - /// - /// The message type of the frame (text, binary, close, ping, pong, continuation). - /// - public SocketMessageType MessageType { get; set; } - - /// - /// Indicates whether the payload is masked. - /// Client-to-server frames must be masked; server-to-client frames are not. - /// - public bool Masked { get; set; } - - /// - /// The raw payload data of the frame. - /// - public byte[] Payload { get; set; } = []; - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrameClose.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrameClose.cs deleted file mode 100644 index 1a5b4b6..0000000 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrameClose.cs +++ /dev/null @@ -1,41 +0,0 @@ -using WebExpress.WebCore.WebSocket.Protocol; - -/// -/// Represents a WebSocket close frame containing the close status and -/// an optional description. -/// -public class SocketFrameClose : SocketFrame -{ - /// - /// Returns the status that indicates the reason the socket was closed. - /// - public SocketCloseStatus Status { get; } - - /// - /// Returns the description associated with the current instance. - /// - public string Description { get; } - - /// - /// Initializes a new instance of the SocketFrameClose class with the specified - /// close status, description, and raw payload. - /// - /// - /// The status code indicating the reason for closing the socket connection. - /// - /// - /// A human-readable description providing additional information about the close - /// reason. Can be null or empty if no description is needed. - /// - /// - /// The raw payload data associated with the close frame. Can be null if no - /// payload is included. - /// - public SocketFrameClose(SocketCloseStatus status, string description, byte[] rawPayload) - { - MessageType = SocketMessageType.Close; - Status = status; - Description = description; - Payload = rawPayload; - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrameParser.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrameParser.cs deleted file mode 100644 index a3b6e6d..0000000 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketFrameParser.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.IO; -using System.Text; - -namespace WebExpress.WebCore.WebSocket.Protocol -{ - /// - /// Parses WebSocket frames from a raw network stream. - /// - public static class SocketFrameParser - { - /// - /// Reads and parses a single WebSocket frame from the given stream. - /// - /// The input stream to read from. - public static SocketFrame ReadFrame(Stream stream) - { - var header = new byte[2]; - stream.ReadExactly(header); - - var fin = (header[0] & 0b1000_0000) != 0; - var opcode = header[0] & 0b0000_1111; - var masked = (header[1] & 0b1000_0000) != 0; - var payloadLen = header[1] & 0b0111_1111; - - long actualLength = payloadLen switch - { - 126 => ReadExtendedLength(stream, 2), - 127 => ReadExtendedLength(stream, 8), - _ => payloadLen - }; - - byte[] maskKey = []; - if (masked) - { - maskKey = new byte[4]; - stream.ReadExactly(maskKey); - } - - var payload = new byte[actualLength]; - stream.ReadExactly(payload); - - if (masked) - { - for (var i = 0; i < payload.Length; i++) - { - payload[i] ^= maskKey[i % 4]; - } - } - - var messageType = SocketMessageTypeExtensions.FromOpcode(opcode); - - // special case: close frame - if (messageType == SocketMessageType.Close) - { - SocketCloseStatus status = SocketCloseStatus.NormalClosure; - string reason = null; - - if (payload.Length >= 2) - { - status = (SocketCloseStatus)((payload[0] << 8) | payload[1]); - - if (payload.Length > 2) - { - reason = Encoding.UTF8.GetString(payload, 2, payload.Length - 2); - } - } - - return new SocketFrameClose(status, reason, payload) - { - Fin = fin, - Masked = masked - }; - } - - // default: normal frame - return new SocketFrame - { - Fin = fin, - MessageType = messageType, - Masked = masked, - Payload = payload - }; - } - - /// - /// Reads an extended payload length field (16-bit or 64-bit). - /// - /// The input stream to read from. - /// The number of bytes to read (2 or 8). - /// The parsed length as a long. - private static long ReadExtendedLength(Stream stream, int bytes) - { - var buffer = new byte[bytes]; - stream.ReadExactly(buffer); - - if (BitConverter.IsLittleEndian) - { - Array.Reverse(buffer); - } - - return bytes switch - { - 2 => BitConverter.ToUInt16(buffer), - 8 => BitConverter.ToInt64(buffer), - _ => throw new InvalidOperationException("Invalid extended length field size.") - }; - } - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketHandshakeException.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketHandshakeException.cs index d37f320..8af6680 100644 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketHandshakeException.cs +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketHandshakeException.cs @@ -19,5 +19,21 @@ public SocketHandshakeException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class + /// with the specified error message. + /// + /// + /// A descriptive message that explains the reason for the handshake failure. + /// + /// + /// The exception that is the cause of the current exception, or a null + /// reference if no inner exception is specified. + /// + public SocketHandshakeException(string message, Exception exception) + : base(message, exception) + { + } } } diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStream.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStream.cs index a0f641c..e31f8d0 100644 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStream.cs +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStream.cs @@ -1,4 +1,5 @@ using System; +using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; @@ -7,16 +8,15 @@ namespace WebExpress.WebCore.WebSocket.Protocol /// /// A binary-oriented implementation of , /// exposing incoming WebSocket message data as raw byte segments using - /// the native WebExpress WebSocket protocol. + /// the RFC 6455 WebSocket protocol. /// public sealed class SocketReadStream : ISocketReadStream { - private readonly Socket _socket; + private readonly System.Net.WebSockets.WebSocket _webSocket; private readonly ISocketContext _socketContext; private readonly string _connectionId; - private SocketFrame _currentFrame; - private int _frameOffset = 0; + private WebSocketReceiveResult _lastResult = null; /// /// Returns the context associated with the underlying socket connection. @@ -30,14 +30,14 @@ public sealed class SocketReadStream : ISocketReadStream /// /// Initializes a new instance of the SocketReadStream class for reading data - /// from a native WebExpress WebSocket connection. + /// from a WebSocket connection. /// - /// The WebExpress WebSocket wrapper. + /// The WebSocket instance. /// The logical socket context. /// The connection identifier. - public SocketReadStream(Socket socket, ISocketContext socketContext, string connectionId) + public SocketReadStream(System.Net.WebSockets.WebSocket webSocket, ISocketContext socketContext, string connectionId) { - _socket = socket ?? throw new ArgumentNullException(nameof(socket)); + _webSocket = webSocket ?? throw new ArgumentNullException(nameof(webSocket)); _socketContext = socketContext; _connectionId = connectionId; } @@ -55,63 +55,25 @@ public async Task ReadAsync CancellationToken cancellationToken = default ) { - // load a new frame if needed - if (_currentFrame == null) - { - // uses the internal stream from the Socket class - _currentFrame = await Task.Run(() => SocketFrameParser.ReadFrame(_socket.Stream), cancellationToken); - _frameOffset = 0; - } - - var payload = _currentFrame.Payload; - - // remaining bytes in this frame - int remaining = payload.Length - _frameOffset; - - if (remaining <= 0) - { - // end of message - var messageType = _currentFrame.MessageType; - _currentFrame = null; - - return new SocketReceiveResult( - count: 0, - endOfMessage: true, - messageType: messageType - ); - } - - // copy as much as fits into the buffer - int toCopy = Math.Min(buffer.Count, remaining); - - Array.Copy( - payload, - _frameOffset, - buffer.Array!, - buffer.Offset, - toCopy - ); - - _frameOffset += toCopy; - - bool endOfMessage = _frameOffset >= payload.Length; - var type = _currentFrame.MessageType; - - if (endOfMessage) - { - _currentFrame = null; - } + // reads data from the WebSocket instance into the provided buffer + _lastResult = await _webSocket.ReceiveAsync(buffer, cancellationToken); return new SocketReceiveResult( - count: toCopy, - endOfMessage: endOfMessage, - messageType: type + count: _lastResult.Count, + endOfMessage: _lastResult.EndOfMessage, + messageType: _lastResult.MessageType switch + { + WebSocketMessageType.Binary => SocketMessageType.Binary, + WebSocketMessageType.Text => SocketMessageType.Text, + WebSocketMessageType.Close => SocketMessageType.Close, + _ => SocketMessageType.Binary + } ); } /// /// Marks the current message as fully consumed. - /// For the native protocol, this is a no-op. + /// For the standard WebSocket protocol, this is a no-op. /// /// The cancellation token (unused). public Task CompleteAsync(CancellationToken cancellationToken = default) @@ -126,32 +88,41 @@ public Task CompleteAsync(CancellationToken cancellationToken = default) /// An optional description for the closure. /// A token to monitor for cancellation requests. /// A task that represents the asynchronous close operation. - public Task CloseAsync + public async Task CloseAsync ( - SocketCloseStatus status = SocketCloseStatus.NormalClosure, + WebSocketCloseStatus status = WebSocketCloseStatus.NormalClosure, string description = null, CancellationToken cancellationToken = default ) { - return _socket.SendCloseAsync(status, description); + await _webSocket.CloseAsync( + closeStatus: status, + statusDescription: description, + cancellationToken: cancellationToken + ); } /// /// Performs cleanup operations for the read stream. /// /// A value task indicating the stream was disposed. - public ValueTask DisposeAsync() + public async ValueTask DisposeAsync() { try { - _socket.SendCloseAsync(SocketCloseStatus.NormalClosure, "disposing"); + if (_webSocket != null && _webSocket.State != WebSocketState.Closed && _webSocket.State != WebSocketState.Aborted) + { + await _webSocket.CloseAsync( + WebSocketCloseStatus.NormalClosure, + "disposing", + CancellationToken.None + ); + } } catch { // socket already closed or broken – ignore } - - return ValueTask.CompletedTask; } } } \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStream.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStream.cs index 5d22268..99245a9 100644 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStream.cs +++ b/src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStream.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebCore.WebSocket.Protocol /// public class SocketWriteStream : ISocketWriteStream { - private readonly Socket _socket; + private readonly System.Net.WebSockets.WebSocket _socket; private readonly SocketMessageType _messageType; /// @@ -18,7 +18,7 @@ public class SocketWriteStream : ISocketWriteStream /// /// The underlying native web socket connection. /// The message type (text or binary). - public SocketWriteStream(Socket socket, SocketMessageType messageType) + public SocketWriteStream(System.Net.WebSockets.WebSocket socket, SocketMessageType messageType) { _socket = socket; _messageType = messageType; @@ -33,16 +33,16 @@ public SocketWriteStream(Socket socket, SocketMessageType messageType) /// A token to observe while waiting for the operation to complete. public async Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { - if (_messageType == SocketMessageType.Text) - { - // Convert bytes to UTF8 text - var text = System.Text.Encoding.UTF8.GetString(buffer.Span); - await _socket.SendTextAsync(text); - } - else - { - await _socket.SendBinaryAsync(buffer.ToArray()); - } + //if (_messageType == SocketMessageType.Text) + //{ + // // Convert bytes to UTF8 text + // var text = System.Text.Encoding.UTF8.GetString(buffer.Span); + // await _socket.SendTextAsync(text); + //} + //else + //{ + // await _socket.SendBinaryAsync(buffer.ToArray()); + //} } /// diff --git a/src/WebExpress.WebCore/WebSocket/SocketManager.cs b/src/WebExpress.WebCore/WebSocket/SocketManager.cs index 608bbff..ff0b207 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketManager.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketManager.cs @@ -1,8 +1,11 @@ +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.IO; using System.Linq; +using System.Net.WebSockets; using System.Security.Cryptography; using System.Text; using System.Threading; @@ -21,7 +24,8 @@ namespace WebExpress.WebCore.WebSocket { /// - /// The socket manager manages socket endpoints (see RFC 6455 – The WebSocket Protocol) which can be called with a URI. + /// The socket manager manages socket endpoints (see RFC 6455 – The WebSocket Protocol) + /// which can be called with a URI. /// public class SocketManager : ISocketManager { @@ -99,55 +103,69 @@ private SocketManager(IComponentHub componentHub, IHttpServerContext httpServerC /// public async Task HandleConnectionAsync(IHttpContext httpContext, ISocketContext socketContext) { + // generate a unique connection ID and set initial close description var connectionId = Guid.NewGuid().ToString(); - var closeDescription = "closing"; - var cancellationToken = CancellationToken.None; - var requestFeature = httpContext.Features.Get(); var connection = httpContext.Request.Header.Connection; var upgrade = httpContext.Request.Header.Upgrade; - var key = httpContext.Request.Header.SecWebSocketKey; + var secWebSocketKey = httpContext.Request.Header.SecWebSocketKey; + var secWebSocketAccept = ComputeWebSocketAcceptKey(secWebSocketKey); - // 1. perform handshake - var responseSender = new ResponseSender(); - var response101 = new ResponseSwitchingProtocols - ( - connection, - upgrade, - ComputeWebSocketAcceptKey(key) - ); + var headerFeatures = httpContext.Features.Get(); + headerFeatures.Headers.Append("Upgrade", upgrade); + headerFeatures.Headers.Append("Connection", connection); + headerFeatures.Headers.Append("Sec-WebSocket-Accept", secWebSocketAccept); - await responseSender.SendAsync(httpContext, response101, true); + var upgradeFeature = httpContext.Features.Get() + ?? throw new SocketHandshakeException("Upgrade feature not supported. WebSocket handshake aborted."); + var closeDescription = "closing"; + var options = new WebSocketCreationOptions() + { + IsServer = true, + SubProtocol = socketContext.SupportedSubProtocols.Any() + ? string.Join(";", socketContext.SupportedSubProtocols) + : null + }; - // 2. create native web socket connection using the raw body stream - var webSocket = new Socket(requestFeature.Body, cancellationToken); + // perform protocol upgrade and obtain the raw network stream + var networkStream = default(Stream); + try + { + networkStream = await upgradeFeature.UpgradeAsync(); + } + catch (Exception ex) + { + throw new SocketHandshakeException("WebSocket upgrade failed.", ex); + } + + // create WebSocket class using the raw stream + var webSocket = System.Net.WebSockets.WebSocket.CreateFromStream(networkStream, options); - // 3. create ISocket instance + // create the ISocket application instance (application handler) var instance = await CreateSocketInstance(socketContext, webSocket); - // 4. notify user code + // notify user/application code of the new connection try { await instance.OnConnectedAsync(); } catch { - // optional + // ignore } - try + // receive loop: handle fragmented frames and large payloads + while (webSocket.State == WebSocketState.Open) { - // 5. receive loop - await webSocket.StartAsync(); - } - catch (Exception ex) - { - closeDescription = "transport error"; - await instance.OnErrorAsync(ex); + var stream = new SocketReadStream(webSocket, socketContext, connectionId); + var message = await stream.ReadMessageAsync(CancellationToken.None); + + // dispatch + await DispatchMessage(instance, message); } - var closeInfo = new SocketCloseInfo(SocketCloseStatus.NormalClosure, closeDescription); + var closeInfo = new SocketCloseInfo(WebSocketCloseStatus.NormalClosure, closeDescription); - // 6. disconnect + // notify the handler/application about the disconnection await instance.OnDisconnectedAsync(closeInfo); } @@ -240,14 +258,14 @@ public ISocketContext GetSocket(string applicationId, string socketId) private async Task CreateSocketInstance ( ISocketContext socketContext, - Socket webSocket + System.Net.WebSockets.WebSocket webSocket ) { var resourceItem = _dictionary.GetSocketItem(socketContext); if (resourceItem is not null && resourceItem.Instance is null) { - await using var stream = new SocketWriteStream(webSocket, socketContext.MessageType); + //await using var stream = new SocketWriteStream(webSocket, socketContext.MessageType); var instance = ComponentActivator.CreateInstance ( @@ -255,8 +273,8 @@ Socket webSocket socketContext, _httpServerContext, _componentHub, - socketContext.ApplicationContext, - stream as ISocketWriteStream + socketContext.ApplicationContext //, + // stream as ISocketWriteStream ); if (resourceItem.Cache) From 0f1dcb41a79d4ddac9b0b49b0a6ca42244e48401 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 28 Dec 2025 20:39:52 +0100 Subject: [PATCH 13/53] add: message queue, general improvements and minor bugs --- .../WebSocket/UnitTestWebSocketConnection.cs | 13 - src/WebExpress.WebCore/HttpServer.cs | 8 - .../WebComponent/ComponentActivator.cs | 245 +++++++++++++----- .../WebComponent/ComponentHub.cs | 27 +- .../WebComponent/Model/ComponentDictionary.cs | 2 +- .../WebSocket/SocketManager.cs | 20 +- 6 files changed, 206 insertions(+), 109 deletions(-) delete mode 100644 src/WebExpress.WebCore.Test/WebSocket/UnitTestWebSocketConnection.cs diff --git a/src/WebExpress.WebCore.Test/WebSocket/UnitTestWebSocketConnection.cs b/src/WebExpress.WebCore.Test/WebSocket/UnitTestWebSocketConnection.cs deleted file mode 100644 index 99cf509..0000000 --- a/src/WebExpress.WebCore.Test/WebSocket/UnitTestWebSocketConnection.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace WebExpress.WebCore.Test.WebSocket -{ - public class UnitTestWebSocketConnection - { - [Fact] - public async Task ClientConnection() - { - //using var client = new ClientWebSocket(); - - //await client.ConnectAsync(new Uri("ws://localhost:5000/socket"), CancellationToken.None); - } - } -} diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index ccdd7df..a8cf310 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets; -using Microsoft.AspNetCore.WebSockets; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -133,13 +132,6 @@ public void Start() x.LoggingFields = Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.All; } ); - serviceCollection.AddWebSockets - ( - x => - { - x.KeepAliveInterval = TimeSpan.FromSeconds(120); - } - ); var serverOptions = new OptionsWrapper(new KestrelServerOptions() { diff --git a/src/WebExpress.WebCore/WebComponent/ComponentActivator.cs b/src/WebExpress.WebCore/WebComponent/ComponentActivator.cs index 300a0c7..7467187 100644 --- a/src/WebExpress.WebCore/WebComponent/ComponentActivator.cs +++ b/src/WebExpress.WebCore/WebComponent/ComponentActivator.cs @@ -14,16 +14,32 @@ namespace WebExpress.WebCore.WebComponent public static class ComponentActivator { /// - /// Creates an instance of the specified response type with the component hub and advanced parameters. + /// Creates an instance of the specified response type with the component hub and + /// advanced parameters. /// - /// The type of the response. - /// The type of the response to create. - /// The reference to the context of the host. - /// The component hub to use for dependency injection. - /// Additional parameter with a status message to pass to the response's constructor. - /// Additional parameters to pass to the component's constructor. - /// An instance of the specified response type. - public static T CreateInstance(Type responseType, IHttpServerContext httpServerContext, IComponentHub componentHub, StatusMessage statusMessage, params object[] advancedParameters) where T : Response + /// + /// The type of the response. + /// + /// + /// The type of the response to create. + /// + /// + /// The reference to the context of the host. + /// + /// + /// The component hub to use for dependency injection. + /// + /// + /// Additional parameter with a status message to pass to the response's constructor. + /// + /// + /// Additional parameters to pass to the component's constructor. + /// + /// + /// An instance of the specified response type. + /// + public static TResponse CreateInstance(Type responseType, IHttpServerContext httpServerContext, IComponentHub componentHub, StatusMessage statusMessage, params object[] advancedParameters) + where TResponse : Response { var flags = BindingFlags.NonPublic | BindingFlags.Instance; var constructors = responseType?.GetConstructors(flags); @@ -47,27 +63,37 @@ public static T CreateInstance(Type responseType, IHttpServerContext httpServ .FirstOrDefault() ?? null ).ToArray(); - if (constructor.Invoke(parameterValues) is T component) + if (constructor.Invoke(parameterValues) is TResponse component) { return component; } } } - return Activator.CreateInstance(responseType) as T; + return Activator.CreateInstance(responseType) as TResponse; } /// - /// Creates an instance of the specified component type with the provided context, component hub advanced parameters. + /// Creates an instance of the specified component type with the provided context, + /// component hub advanced parameters. /// - /// The type of the component manager, which must implement . - /// The reference to the context of the host. - /// Additional parameters to pass to the component's constructor. - /// An instance of the specified component type. - public static T CreateInstance(IHttpServerContext httpServerContext, params object[] advancedParameters) where T : class, IComponentHub + /// + /// The type of the component manager, which must implement . + /// + /// + /// The reference to the context of the host. + /// + /// + /// Additional parameters to pass to the component's constructor. + /// + /// + /// An instance of the specified component type. + /// + public static TComponentHub CreateInstance(IHttpServerContext httpServerContext, params object[] advancedParameters) + where TComponentHub : class, IComponentHub { var flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; - var constructors = typeof(T).GetConstructors(flags); + var constructors = typeof(TComponentHub).GetConstructors(flags); if (constructors is not null) { @@ -83,26 +109,40 @@ public static T CreateInstance(IHttpServerContext httpServerContext, params o .FirstOrDefault() ?? null ).ToArray(); - if (constructor.Invoke(parameterValues) is T component) + if (constructor.Invoke(parameterValues) is TComponentHub component) { return component; } } } - return Activator.CreateInstance(typeof(T), advancedParameters) as T; + return Activator.CreateInstance(typeof(TComponentHub), advancedParameters) as TComponentHub; } /// - /// Creates an instance of the specified component type with the provided context, component hub advanced parameters. + /// Creates an instance of the specified component type with the provided context, + /// component hub advanced parameters. /// - /// The type of the component manager, which must implement . - /// The type of the component to create. - /// The reference to the context of the host. - /// The component hub to use for dependency injection. - /// Additional parameters to pass to the component's constructor. - /// An instance of the specified component type. - public static T CreateInstance(Type componentType, IHttpServerContext httpServerContext, IComponentHub componentHub, params object[] advancedParameters) where T : class, IComponentManager + /// + /// The type of the component manager, which must implement . + /// + /// + /// The type of the component to create. + /// + /// + /// The reference to the context of the host. + /// + /// + /// The component hub to use for dependency injection. + /// + /// + /// Additional parameters to pass to the component's constructor. + /// + /// + /// An instance of the specified component type. + /// + public static TComponentManager CreateInstance(Type componentType, IHttpServerContext httpServerContext, IComponentHub componentHub, params object[] advancedParameters) + where TComponentManager : class, IComponentManager { var flags = BindingFlags.NonPublic | BindingFlags.Instance; var constructors = componentType?.GetConstructors(flags); @@ -125,26 +165,40 @@ public static T CreateInstance(Type componentType, IHttpServerContext httpSer .FirstOrDefault() ?? null ).ToArray(); - if (constructor.Invoke(parameterValues) is T component) + if (constructor.Invoke(parameterValues) is TComponentManager component) { return component; } } } - return Activator.CreateInstance(componentType) as T; + return Activator.CreateInstance(componentType) as TComponentManager; } /// - /// Creates an instance of the specified component type with the provided context, component hub advanced parameters. + /// Creates an instance of the specified component type with the provided context, + /// component hub advanced parameters. /// - /// The type of the component manager, which must implement . - /// The reference to the context of the host. - /// The component hub to use for dependency injection. - /// The type of the component to create. - /// Additional parameters to pass to the component's constructor. - /// An instance of the specified component type. - public static T CreateInstance(IHttpServerContext httpServerContext, IComponentHub componentHub, Type componentType, params object[] advancedParameters) where T : IComponent + /// + /// The type of the component manager, which must implement . + /// + /// + /// The reference to the context of the host. + /// + /// + /// The component hub to use for dependency injection. + /// + /// + /// The type of the component to create. + /// + /// + /// Additional parameters to pass to the component's constructor. + /// + /// + /// An instance of the specified component type. + /// + public static TComponent CreateInstance(IHttpServerContext httpServerContext, IComponentHub componentHub, Type componentType, params object[] advancedParameters) + where TComponent : IComponent { var flags = BindingFlags.NonPublic | BindingFlags.Instance; var constructors = componentType?.GetConstructors(flags); @@ -167,28 +221,40 @@ public static T CreateInstance(IHttpServerContext httpServerContext, ICompone .FirstOrDefault() ?? null ).ToArray(); - if (constructor.Invoke(parameterValues) is T component) + if (constructor.Invoke(parameterValues) is TComponent component) { return component; } } } - return (T)Activator.CreateInstance(componentType); + return (TComponent)Activator.CreateInstance(componentType); } /// - /// Creates an instance of the specified component type with the provided context, component hub advanced parameters. + /// Creates an instance of the specified component type with the provided context, + /// component hub advanced parameters. /// - /// The type of the component, which must implement . - /// The reference to the context of the host. - /// The component hub to use for dependency injection. - /// Additional parameters to pass to the component's constructor. - /// An instance of the specified component type. - public static T CreateInstance(IHttpServerContext httpServerContext, IComponentHub componentHub, params object[] advancedParameters) where T : class, IComponent + /// + /// The type of the component, which must implement . + /// + /// + /// The reference to the context of the host. + /// + /// + /// The component hub to use for dependency injection. + /// + /// + /// Additional parameters to pass to the component's constructor. + /// + /// + /// An instance of the specified component type. + /// + public static TComponent CreateInstance(IHttpServerContext httpServerContext, IComponentHub componentHub, params object[] advancedParameters) + where TComponent : class, IComponent { var flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; - var componentType = typeof(T); + var componentType = typeof(TComponent); var constructors = componentType?.GetConstructors(flags); if (constructors is not null) @@ -210,27 +276,44 @@ public static T CreateInstance(IHttpServerContext httpServerContext, ICompone .FirstOrDefault() ?? null ).ToArray(); - if (constructor.Invoke(parameterValues) is T component) + if (constructor.Invoke(parameterValues) is TComponent component) { return component; } } } - return Activator.CreateInstance(componentType) as T; + return Activator.CreateInstance(componentType) as TComponent; } /// - /// Creates an instance of the specified component type with the provided context and component hub and advanced parameters. + /// Creates an instance of the specified component type with the provided context and + /// component hub and advanced parameters. /// - /// The type of the component, which must implement . - /// The type of the context, which must implement . - /// The type of the component to create. - /// The context to pass to the component's constructor. - /// The reference to the context of the host. - /// The component hub to use for dependency injection. - /// Additional parameters to pass to the component's constructor. - /// An instance of the specified component type. + /// + /// The type of the component, which must implement . + /// + /// + /// The type of the context, which must implement . + /// + /// + /// The type of the component to create. + /// + /// + /// The context to pass to the component's constructor. + /// + /// + /// The reference to the context of the host. + /// + /// + /// The component hub to use for dependency injection. + /// + /// + /// Additional parameters to pass to the component's constructor. + /// + /// + /// An instance of the specified component type. + /// public static TComponent CreateInstance(Type componentType, TContext context, IHttpServerContext httpServerContext, IComponentHub componentHub, params object[] advancedParameters) where TComponent : class, IComponent where TContext : IContext @@ -244,7 +327,12 @@ public static TComponent CreateInstance(Type componentType { // injection var parameters = constructor.GetParameters(); - var hubProperties = componentHub.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance); + var components = componentHub.Managers + .Select(x => new + { + type = x.GetType().GetInterfaces().FirstOrDefault(), + value = x + }); var contextIdProperty = context.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(x => x.PropertyType == typeof(IComponentId)) .FirstOrDefault(); @@ -254,9 +342,9 @@ public static TComponent CreateInstance(Type componentType parameter.ParameterType == typeof(IHttpServerContext) ? httpServerContext : parameter.ParameterType == typeof(TContext) ? context : parameter.ParameterType == typeof(IComponentId) ? contextIdProperty?.GetValue(context) : - hubProperties.Where(x => x.PropertyType == parameter.ParameterType) - .FirstOrDefault()? - .GetValue(componentHub) ?? + components.Where(x => x.type == parameter.ParameterType) + .Select(x => x.value) + .FirstOrDefault() ?? advancedParameters.Where(x => x.GetType() == parameter.ParameterType || ( @@ -282,15 +370,30 @@ public static TComponent CreateInstance(Type componentType } /// - /// Creates an instance of the specified component type with the provided context and component hub and advanced parameters. + /// Creates an instance of the specified component type with the provided context and + /// component hub and advanced parameters. /// - /// The type of the context, which must implement . - /// The type of the component to create. - /// The context to pass to the component's constructor. - /// The reference to the context of the host. - /// The component hub to use for dependency injection. - /// Additional parameters to pass to the component's constructor. - /// An instance of the specified component type. + /// + /// The type of the context, which must implement . + /// + /// + /// The type of the component to create. + /// + /// + /// The context to pass to the component's constructor. + /// + /// + /// The reference to the context of the host. + /// + /// + /// The component hub to use for dependency injection. + /// + /// + /// Additional parameters to pass to the component's constructor. + /// + /// + /// An instance of the specified component type. + /// public static IComponent CreateInstance(Type componentType, TContext context, IHttpServerContext httpServerContext, IComponentHub componentHub, params object[] advancedParameters) where TContext : IContext { diff --git a/src/WebExpress.WebCore/WebComponent/ComponentHub.cs b/src/WebExpress.WebCore/WebComponent/ComponentHub.cs index e455777..021e36c 100644 --- a/src/WebExpress.WebCore/WebComponent/ComponentHub.cs +++ b/src/WebExpress.WebCore/WebComponent/ComponentHub.cs @@ -221,7 +221,7 @@ public class ComponentHub : IComponentHub /// Returns the socket manager. /// /// The instance of the socket manager. - public ISocketManager SocketManager => _socketManager; + public ISocketManager SocketManager => _socketManager; /// /// Returns the theme manager. @@ -360,8 +360,9 @@ internal void Register(IPluginContext pluginContext) var assembly = pluginContext.Assembly; - _dictionary.Add(pluginContext, []); - var componentItems = _dictionary[pluginContext]; + // initialize the component entry as an empty list for easier manipulation + var componentList = new List(); + _dictionary.Add(pluginContext, componentList); foreach (var type in assembly .GetExportedTypes() @@ -372,14 +373,15 @@ internal void Register(IPluginContext pluginContext) // determining attributes var componentInstance = CreateInstance(type); - if (!componentItems.Where(x => x.ComponentId.Equals(id, StringComparison.OrdinalIgnoreCase)).Any()) + // check for duplicates + if (!componentList.Any(x => x.ComponentId.Equals(id, StringComparison.OrdinalIgnoreCase))) { - _dictionary[pluginContext] = componentItems.Concat([ new ComponentItem() + componentList.Add(new ComponentItem() { ComponentClass = type, ComponentId = id, ComponentInstance = componentInstance - }]); + }); _httpServerContext.Log.Debug ( @@ -398,6 +400,9 @@ internal void Register(IPluginContext pluginContext) } } + // make sure the dictionary uses IEnumerable as value type, if the dictionary requires it + _dictionary[pluginContext] = componentList; + Log(); } @@ -483,15 +488,19 @@ public void Remove(IPluginContext pluginContext) return; } - if (_dictionary.TryGetValue(pluginContext, out IEnumerable componentItems)) + // try to get a list for safe removal and iteration + if (_dictionary.TryGetValue(pluginContext, out var componentItems)) { - if (!componentItems.Any()) + // for IEnumerable, first eagerly materialize the enumeration + var items = componentItems.ToList(); + if (items.Count == 0) { return; } - foreach (var componentItem in componentItems) + foreach (var componentItem in items) { + // raise the RemoveComponent event for each item OnRemoveComponent(componentItem.ComponentInstance); _httpServerContext.Log.Debug diff --git a/src/WebExpress.WebCore/WebComponent/Model/ComponentDictionary.cs b/src/WebExpress.WebCore/WebComponent/Model/ComponentDictionary.cs index d49fc29..b80e44b 100644 --- a/src/WebExpress.WebCore/WebComponent/Model/ComponentDictionary.cs +++ b/src/WebExpress.WebCore/WebComponent/Model/ComponentDictionary.cs @@ -8,7 +8,7 @@ namespace WebExpress.WebCore.WebComponent.Model /// key = plugin /// value = component item /// - internal class ComponentDictionary : Dictionary> + internal class ComponentDictionary : Dictionary> { } diff --git a/src/WebExpress.WebCore/WebSocket/SocketManager.cs b/src/WebExpress.WebCore/WebSocket/SocketManager.cs index ff0b207..fb01018 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketManager.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketManager.cs @@ -27,7 +27,7 @@ namespace WebExpress.WebCore.WebSocket /// The socket manager manages socket endpoints (see RFC 6455 – The WebSocket Protocol) /// which can be called with a URI. /// - public class SocketManager : ISocketManager + public class SocketManager : ISocketManager, ISystemComponent { private const string _webSocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; private readonly IComponentHub _componentHub; @@ -104,7 +104,7 @@ private SocketManager(IComponentHub componentHub, IHttpServerContext httpServerC public async Task HandleConnectionAsync(IHttpContext httpContext, ISocketContext socketContext) { // generate a unique connection ID and set initial close description - var connectionId = Guid.NewGuid().ToString(); + var connectionId = Guid.NewGuid(); var connection = httpContext.Request.Header.Connection; var upgrade = httpContext.Request.Header.Upgrade; var secWebSocketKey = httpContext.Request.Header.SecWebSocketKey; @@ -141,7 +141,7 @@ public async Task HandleConnectionAsync(IHttpContext httpContext, ISocketContext var webSocket = System.Net.WebSockets.WebSocket.CreateFromStream(networkStream, options); // create the ISocket application instance (application handler) - var instance = await CreateSocketInstance(socketContext, webSocket); + var instance = await CreateSocketInstance(connectionId, socketContext, webSocket); // notify user/application code of the new connection try @@ -156,7 +156,7 @@ public async Task HandleConnectionAsync(IHttpContext httpContext, ISocketContext // receive loop: handle fragmented frames and large payloads while (webSocket.State == WebSocketState.Open) { - var stream = new SocketReadStream(webSocket, socketContext, connectionId); + var stream = new SocketReadStream(webSocket, socketContext, connectionId.ToString()); var message = await stream.ReadMessageAsync(CancellationToken.None); // dispatch @@ -252,11 +252,13 @@ public ISocketContext GetSocket(string applicationId, string socketId) /// Creates a new socket endpoint instance and returns it. /// If an instance is cached, the cached instance is returned. /// + /// The unique connection Id. /// The context used for socket creation. /// The accepted native WebSocket connection. /// The created or cached endpoint instance. private async Task CreateSocketInstance ( + Guid connectionId, ISocketContext socketContext, System.Net.WebSockets.WebSocket webSocket ) @@ -265,7 +267,8 @@ System.Net.WebSockets.WebSocket webSocket if (resourceItem is not null && resourceItem.Instance is null) { - //await using var stream = new SocketWriteStream(webSocket, socketContext.MessageType); + ISocketWriteStream writeStream = new SocketWriteStream(webSocket, socketContext.MessageType); + ISocketReadStream readStream = new SocketReadStream(webSocket, socketContext, connectionId.ToString()); var instance = ComponentActivator.CreateInstance ( @@ -273,8 +276,11 @@ System.Net.WebSockets.WebSocket webSocket socketContext, _httpServerContext, _componentHub, - socketContext.ApplicationContext //, - // stream as ISocketWriteStream + socketContext.ApplicationContext, + connectionId, + writeStream, + readStream + ); if (resourceItem.Cache) From 6714b15f4705f24fd417cb47746ef6e6f40af0bb Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Mon, 29 Dec 2025 16:57:31 +0100 Subject: [PATCH 14/53] update: new signature icon --- icon.png | Bin 3335 -> 10488 bytes src/WebExpress.WebCore/Rocket.ico | Bin 70141 -> 0 bytes src/WebExpress.WebCore/WebEx.cs | 2 +- .../WebExpress.WebCore.csproj | 2 +- src/WebExpress.WebCore/ufo.ico | Bin 0 -> 24250 bytes 5 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 src/WebExpress.WebCore/Rocket.ico create mode 100644 src/WebExpress.WebCore/ufo.ico diff --git a/icon.png b/icon.png index 7ce92702956911b7495e8d6c25325608de09776c..01bf63fbd01b51c4870c0fb69871c2b5b4f21cfb 100644 GIT binary patch literal 10488 zcmdsdg;(9cw{CEEEp7#hyE_F+u@)=tQYh}u!3z{CUgSV=ic_Fif#Om)NO3Rj?r>iE zyZ64g*83A~)=Dxn*_p}6{`TJAB>IiI5)LK>CIA39%CBB(0ssP-L;%nc!P_LhH1EHh zyQY#HP&G=m3ucgQWz}Q>pf&;P!5jt5W4OH1cLxC6u74-OkE9QWVBs?l1w9WfXB!W1 zb2n??jk%qZ2ba2nHk}|BFBdOw3_9s3DD=ZxPuWIUnGHY(GbjKB0?L2dehua*|2qwS z4#0|l^q=y7AH<7*2oQjG1MswqLjF&=1DO7I%y}UIJ4pZU{{OcM0N}$!GyoZt<>BM! zLCE{BdjItoCGWrLN(0mX4n(kK#Q#WYii&`#|9*>?hv(l@{TD$sV+TG{0KihH{8Coi z$K)`_+lW{R-j7yDMdjk1Qh=`Xwj8}tsTZ}jKaZE*t00>9%hdy+n#E@p-(D8pVhLpq zUf#5xjIYss@iCZJtU}1d$h#F2X>KawB1fGow5?4|qet|b#~HqGL2>>jZ+~%U=Iy9= z%ria7XJ$O57Pv}gJggxo5LpTi0DHm=!7LFK3kVa?1IP=z5IgB`NC8y48i40j*#8C* zUZig<-L4r`J-xaI?4FHBmUfnQoQ>aWo9Z0xr%MIm_H5L&Vio@sj+V1&2?=%5c6;FH z0miY5(@x*tB}RqC8Xdg<@S?8vdoB%re&Wzt02O+SgvYgJfueDV?ou^9R_Xfw^IK0f zH5VyWTL=p5_Ekk?|2;Og>#1k0djRu$)&1UfPU1I3{!Y;LcgCdY?ve@RkYrwJ04r}Y zabaHVijrz)y`3lEbHWsk`Vlr*d4_M<8+{LkZAC?EjOLRLivdw!eu~n6GJ>38L3FausbXrF99mIFpF9 z-zvycXMZ9<#LBBwy}}abb3VN2L}%o<;~-4(DSUTw(K)`A_qI6l?r3uOKloJ6v06PNM$~Zpp!QS_O%xO zIb35Th>r3_Zi#uGepF;h}CHLWa?0hLj$c@%qUPfQWgr+V-$N@xxGkd=k zFtM*9Ey+cEG+$j>e4u_SDS{(e10qFd*U;TT?8=uTANXGef@q}+oZAwfq95_0llVxK zAY#_=G4#D+7V@UVEDJb3_%k|yhiQ%gQ4TWa#tkBjR{%^i?}y&juj=*4v}0{JZM#@4 z8xXfVh5D!%5kNV@{jh2R-V4+fL66WKp{MaZ^AzebsD&szQ@_!EkK&PmFW2QyuIgFI z7yz-T8Vb#Kw!W4(^^ewEE*unj>=2BfNVcf)k(Nm>=~KCB1W@7?kUUU$x^HwFuIXZb zmE0)jtmz@kXafc9h%T373ZM|V2m<&~FH!J`2&2CRe=L$OkUXGTon6L_K}7d*OTeH( z>t{%iV@tqzhSY!M^@h0fqBH5o9a&*VGGhGRAsq{lqyXK7e?jQC0LNwon~Hk<0HOcN zK9Q)?YK2GonZfaoL)OBx9Y~UkLp7=>LU_wnQ~?iFTT&*GdRR7KboiF)&`Es!i809y zJ?}Z7;lDf5`PPLaG7N0;f(E_+$^=H(Dbfo_Je|J^cvF`IBZP3ju5Mf!=#6*LK1Sh0 z5cznp%8H#}_tb6Nd*$N5jY5y`k>hTJ0w1H_!Ut@b-bw1eO2b83^Xrf0(Oe0JS4&MW z<+A?z%_@^d#yR`KLKD>NsIlD7V_zDqKRSry3x_5GJmga7o+04%e-Jp1U=Cowilujo z*-9e?WSYyl63WezB@T;naqH0V$oKz@j8xRL)D>1m{$6EtUB@*}E}su5|J?4;6E6Wqh6ko?9rQ+wpL|waXpbT^#@;2=b-49?3NRYIrn`=)=DKurNf)?bakP| z5OpWK;I=QNZ|l-A(UiPLUw@O@+InGqtxbUw_@b#{|9EGjf`5ee_PzCG1InK}rSqC( zR;KvHU}lZ7U;%&jZy|0PN6zfus$Y~k9gZJq6KloAU2HPa;@mmH`mGtV%xtf>($b{1 zZ02;Vn#*&4+ewhe0s&0wETntw@UkDJGFUQ<3G49T5gEcCe)(v4h@d2ViV>GrDHwiC?slQ4bD5%yPNa->4&5% zJZcmEpf#nxx!c7--j}Ocu7*1i2~giMDgevO{5sKDQ}YIrMTKWo zG@{vsNdTAp6H;Rk$O_s*ZziLL_AWeYa#DZG+W|)0KZhe9t=yja4Ix2vY0|8e)I7iX zeud(S$2jCCuos@;lRdUnP8bA=E1#>v$gijhhp&$b@tplj6NgytaLl>S%U7u$nE{9`9ng`#j!QuW&pid}ubm$o$JmrddguGAG0f~B)Fzlj}yS-_0(XUrF13u_4zYN z4hRYoW0QJtmf_vXr-c+vMOb>hoZ|1zDPaLLe(oI3Hu_roD0g@s7tki+!{Em@` zj;EZw*K;RK9r+IwVoZ?JPjylqqqla5R9ffgnS0@UTfluSp5Gn!a;q|IYavuLuGEP{W*YYk?76_tp+cMSflK3_4FAWK@j1 z>|5sfHA^DuGS%(KZts5XL3O%C0t^X}e0?j>b`p?uVRk;}5=dQB(DI zslV&*N%^l30X=-7Xj8H4VyLAOMpW~?;(Y>y?;(l8bh77SoaHX^{8(msAIB&H`3VPr zv?y*VAp(mB$hvr%_KMPrqa&8|4HFs*TZV&}WfCt(r(h{9Nh`w%SF~&F=3k1liXsx} zv49Loy4Oy_0ljm-F62y3jl~4MNUB{9EU`bFEsc%;y{%!{;dxuI=!YakvU5mU@EA3* z#;zcG%;NCZJFW3$^@GF1R~m`Z;#L%%UheDaev-TbaNUYvxyK7Fc^56Z4)7Q7`e4Ql??RD`-p_Z$2N37+*yQj5Yp zf24z5Ag44Eu=-ewrI+-;Foh0UZ`xeH4-l-$!3X}s^%d{Y^YZ`^r!G*#qx^4Vm6zn3 zF#oxPj{M7OWJA)<&}C@Xg&}Gy8?uHS3#*&(w%`a>7Hh_KO;!JagXM5j0!GZIG0Q%U z!-|SrTBcQ`!FQy!=1!)eMQtX>7=(rx+QxAPfk`Bnc4K8zq}g5t#cx+~Yg}4Q0{F@7 zTaR@1R(iK*7RV!|^7s25vc>@bf zlaaIf`n|{r5qtnJB}Rq}bdI)27RU%u@i&DqvXHVEzM!nq++FJ9sLK91*S=R5H33c> z2ZeyX!{B#y4Br+w3R~(cVg9>CqWzi>?SFsfn<)zVNZ~I5pzQ_jG2?l?SLR*3I+Jc>|*U(&llasRcF|Rr>U- zXH6+Y2b6cQwcLMnX-|YA>FqHVglk?>Q=gZhdy9* zlSvmMX_R1Ns?281<}6*t-w{p6$|v>iIEL(Ch3wA`y9#%16gdFvbaiXSiVHym?594Z zax{L=o`BXO(qf@~2)y0$Bhf2Hi?`Z8t`kE*09QJJSa5P(t$A=CiU=A&&vobIjZk85 zhT%3oL!flTjF~nstTbwIxNE9Kh$$jPN;*G_<7H9aXxuc0^+ZDvq5adbPy9o!-asR# z@#`5(v#-aU-RIMCRYqwY6Qd7-QDybr#_d8f4$67=1$(!J-?+Pf2my+Z2XIiXfe2sJO zw=1jYRVr0q$6;DJdpmf+LYpkdu+|d(*O$v2=;W8ZtCTy|CZ-)}L$=Nv-p6WDglGx} zdAbT`BLRKndMBAVqwj^Ruwh`=6BwWx22X!sRivl#ICkW-koAGs3GXIn9N+J zM?2t>@7y5)K|g&tlm*~R^YqWC*YJ>ye0jxc~ATEBSvo2p|J$~>ibm$AR1;+PyXl^R1wo8KT@dTIjfs3 zCjUZD-!BXI3&YUshx<`que%F;VdtNxn7{yCCcQQQfx?lyB?s}uZXpODwoa~6 ziD(h(t-DnT+drF04d>5>>$w<0{BS07Wqe$QguNG|dk1x;$8Is5b*YShvbZw$of`iF zAlBW*^?w@k&THRlheH=*`J-~0HkCkJCyRFxGYNgf6 z9eRkF0yJKq3pcZ*V**TSfN^U-ormdfz0DV!9&mH4?u?Fvv1!VvIgFjLD9Le+(p5xY zVmPA}0r-l9d?$^pko)3QK{r>Ug)FU{XbE(clc1*8*D~+ZHz?N3s~V?tA^^ar=J`x5 zru|{nJcyzSD^y%O)!G2QcJT4Ftmo#bgU@S}DYYSvnK69x)6`~-$+_xYPr+7aXe7u; z0(wFosD_H(MLA<-gf*U zunp_EtRN>1vGdt{uE37qJ+&a;A`q3*iHonDovkmQW>G-U-f^gHSqsh_*ease=n@5H zJ;g&b`E_=Y>Ct!qJhjjJ>;xk+#i=VL-F!)Lt1m2ZvN2ni5JAILyQ$1+g^UDH(Fzil zCk`q3q1L67u(f;WZ{cKj`k9Xr7h+8JuQ}ai6{hi>zo7$xbKPYtdr;BwoV|7**;!%x zlX1=18G0i}BDCrj3&UPA~jJtwSU725#@CiT8&t77B9tr;5zW;Z}W@`>b`o z-WLJjx=bN_L`(^BLhObkD==^H&hWlAXOzRmHj4Jo<%?WT+A$d>Ps1}ma<$8wy3)vyB_Uw?w9z&XXIi+jCGcC5s~Y9 zQ6%KPt_39)^v>yt_2g0%c-Hraq#}4&_+k1HzvIsLCwbCc3N_j3=TE)U``p-s8P|%* zB^>d?)|gERJ#TB=JEZ@vw^P_yF>4Yq%zi#l7M~et=_Kw7HSHG!kheqRu1cx{dmgWgrOAe=&>CcR%+SD0zq*b60P!4(H*=7f8lX?krMDD9L2#JKTI zCJR@tQw&{%-PWMjy<|7?eu?f~Z^Agc!7aLMQb#e*@7j{~i{D8jY~oUeRkY?x#<^I} zSbWC(vxm4Bf5Yuk?LQ9O`Gw_+zMTSzy-Gqp`(1&XR&lB>4L2j@*xg+^}wPDE0=aSO8pi z82!M4`~7$NGFawcvS2lhk`Gh%C67njQCLa!ZZ>GntIcOl$Vx+2xKY17fas-zD&GFx~mRr7$<^0<0jC!>uI@YSju#5<(m@J@c}@_!m&MIN2?ky zSkZ-;Fl_hbe+cojqbaQekGgmm+RHA=k_IGC_wyqEXDoF@e|{Ti_>)DKO8SSG@gy*r z>eoqJEznlcvX_%&%unHpm7uKh^>Q1zY>J~!5}?N{`fMIUopP-nQFG+BzXL{Ysn)Gd zIptd4?gsDHi-Ad3^cW-PkK+~Bm7EbZ9pUbUaL>e1FwT-G8qeldZGGdSwX}HPwEfW< zVn>Ut077viThQ=|H`mNgz@HqIbaY5@Vb$HsQJhe=_!BCKAjH?m?OTkA$jzq4;A!D& zGaV1x@d(7c!BSQ+mu;hh#zDjN7_cfVWQ8Cfzx&}@O7~R99GV#?u@!Wo9z6xvsO>WU z_baln{Me_)LdJ#U_1Vg=Enkj^fcA>hKjs?kQV;qPis2!2tZvtblO0U7wqsnoG%amd zJw;{Bw-fEE2V9*lpn;f~(xQdVo$goGr?xzQv!vLB2`T=Xf24i%8=KEL+goHBSO@nI zs(Y&LpXSF$-5eyjBhGW$^ZqLR8}GJgzIXF4fqi54d{A&9;Y8g;tG>YJXc)!FI7(lJhe>vB^e4r zL0?o;a z-;Ecs>9qRkiNTcWJTFu$P|WDd*}a&dc@*Wft$FwKtZtWc)Wa6SHoBO!E#`6=<$y;P zfpzZ4&xzTJuA|VSkh}rUXn#}bD`UB*!{X89ynH2|KV0}dQ$b-tAA^XlKeph4b5IOj z$RtcR;R?X6iMIH~38Yh5m&H~-iqLLh8>nu7HuelY4dD(_kLNxf!lCdBH>nf%s7dzU zXNfnTS2EE#{Z_jY>1c8_Ci$ow-X3kj0C_H7Y`!h??xedx-te<}ZAs0glwTOYK+;tY zDrarR=xQ9nn8@yE=0R#k8{ojn8I*g=RrS=04l@43^10!b{she2kfN}X??Lld7t+iA z>`qz>39CIaZFkj~2=VQWLA-}=>F~s(pwOUd61>j6dhwu+z%~1~)29_GaR!y?NM2;< z=YRwcl8p-o=txDsGFmB=%7G%XTwtmi0_=H}kqnIcAEpGTH zE=z_KGY90lZzOXM&Ml(buOXb^-0Djhj{-h#epLt^*08sC@@jII(?azfS}#)iIp@Y$ zRjA$}D^IT@!2kYJzI2b;L586x_qV!t%^_-f08xOvGDUimRn_EZ4e7W+QaxzwcoY;6>mx{C?L%-P!0 zVU0|RPgq2@zh&6}+M4NCtpp!??A40sw+VsUSBrk_*zhDwNDU}%r-w98f zFdcPb6n|32T#XBs6!c#85z$2hX=IuuSYHmh{ZyRonO60RM#9CjjrW##EZ3MIFr;}n z=sYCcA2Brsl{~jodniclH6sTJ3{5#xidbKMOWV^-`m;}-}Fsd4#Spo zlh$7eU;JR~7vm{xMuB*eECxTB!jyLgL31X-@zo3Va3V3hKI1njG@IfBZ;EDr!_aWr zT_0MLn_t)?#aH5fF5%F6WyH`Xw~7pS5QTqH-&_tSQpKFa?`$owRX~dmxAc4nVh{TJ zhtb7>Or{mf@8JO4e-3W(eF(d83@(Ce6*z?_i>KDLoY_4w;z(AMw5!+;E`{!0;k90h zFw;Q@Xdb6OnOgiaBe$oS)Yl(c?TVeW+|M`tGhOx5Dg6hG6Ag8~Z@}Pvq8Lk@+Q7*6$|HBe_7;n8mH`A`gWkoI$DxunGG-ge(;qX(B0oh~dV+4ku%)noz5h+2^)xu; zy#%?a??|M$kQJW52>`8?xYn3Qns@cmdjkWfbQ|e4@gCUZ?dzqnO_pbWZc7|{d{FRr zc3MscgDLO&L1OB;m$AWZph*7qPESER7HEUxtKg#S&7Aim5ba|CPX>y}rrYGU1q`m$#9WdpzH917R@GXK9K% z&dXo_Qnv#^m!CiSaGb51mTYUkFh*Vm^!cDv#Tz*6bTG4?b%!55_ax3Xqr(3W?3B|^ z8p}AtPPU?t6RcMRB7>-DHFfsQtRKUV|6z~Kt1180(Uc!tXG5L@eXKxIMDL)2>Fvwz?iSPZWZJt{it!=4&N}?1X=bTSVJG+b7#vU8FOlrxJgn@g{64Zr)YUNyCzAorr z%n~SDs0_2Tp&^T~FCH4(;C;03UFa>^lvQC!YB}BOx>?j75-?5}f0B>ht1~Plz*%ji zjtF!3ys|2+I6}2SgI$Tl;~Mb2h!xeKl<|Yt4k?(=-pnGi23sR~)Cn zlN1>Um2R<@(}Nm|2U(wfn+%S0u0b`7=JlP`7zh(IvESQIlMgQDJ4w=YkEr^f-Qij+ z3pVw_E*iY|DkjD(B&sV64I!wN%#|E0L;b6Kll>rdARO=9W|ED@VkVZbU=@<;OUsSs zYunziSpF5nLx;fRKBodh6q#dKu*AM*TuXk~{W!S)`}dL&-1OM5?DwX?11BN8EoJ2F z!M4(0Fjje-bup6|$P<6MrapM#8izr%&=*7?d84Ik=-Tvk)yl-)SSz8@9>1_3xs#!; zhJ5v4dSk|E7cJe{ILH;+f$8AN2~jB{!Edn2$(f;f#aVZJSB?C|= s!#ws`@lh=u%>H-(HuisignObbH5>6f(dNVgU0Fa`LH%WwoLT7q0dv2I@c;k- delta 3315 zcmV-F*f|Nrs$_3!ud`~CdB-q2{XksyyI zwc5!Lhf*+?Y1ixG@Avfh`}%~zrs44E7>ieYy`cB|`ADOBSgVF$uZ(oLo9*`Uh=0SW z^!oUc$gr-~#EZqN>h$pX{rkt`)@8Df?Dp|pu8NGstM2#nxZBI~`S;rGEHJH z`0VxZozA$Y)4y%DmHGVp$>i9}<$u|EyqkH@Zs!lv^0_OaN<#^Tk{=-#r} z$GP0hN^i$&00009a7bBm000ic000ic0Tn1pfB*msc1c7*RCr$Poqa>wHWR>!O$f0A z3FL)12_=QltQ1De+R~10?PzJ&GS+royS2~reE+ZU(FMn|4L^@k1 z>2!Bmuvjb>i^XEGSS%Kc#bU8oEEbE!VzF2(7K_DVvFJ|8nc>2}Hv`vk7R%+ON@clP z_52yN$zPSqN_o|(L!s+RV{N@z^ZizpPTTKPH@amffa2GR-s)1nI@pA!GVwQ;yVvVg zDBY@5TWx5b7+XWQ*L>=?H-EfICr?Ow6>c}2`dYKI4Q)zB8NR_}rmYw1^fcDtOUz}b zlD_p5D)2R?GHUwJl9X(~7a57WyX-+bQd5KPGLYcTxC5=oK-<)DaQK6N7ut}CRu8_= zEZjXW-KZC%wFqBn6gyjzns4SzD7cqV^AqgDx0=J^!H-aPDVR1B8h;*Qi`DxvESVIR*$Ic=iiaRt5SCAm}Sne{-}KK)nw&RfO&WDE$f* zWzzCz#z7C=4#erh4}WP&?>W>Hr_=727oF0ybjexVZ?=bPP@Ai zv*vJh#Y1&?%^w8b9wCy?$MFgoZ)}4g6?jY4h79-on5#?L*8&! zzrQ!2AQeY!^6WcMUO!&2-~YE2j)TYW-8mFx;mx$Cj`aS3Du2>>cmd^b+&=}n!KSW$ zGCj8bZ@q_dOv-#+cn(E%qt2fHyyZXt8Gmwnfu_5|!S7H%Iv+klIUep`a0iN>1|jzt zs)x`N?zqD(xCKMceUW?YnH+6Lw=s1ErCi@(E5gk+D5n*lIJ#>E%6-RK@&;2^(3ZCp zn;WdaO>_p#xqn1KG&uGzKQjL#W_^Q|$iUuXE)cgh^dBB_rf{{FP!RY#GGQFQh7W+3 zEbl=b7sC~(8-_=&{w`vjeEb7`B&IQF)Vn9mZ5H3^jMo>l{ zcq^dD@{O)9A0ENSGgfg6Nk=d2q(VO&!4 zH_t2kyMNQ?D$oHY`Z2&-c;2JUB0HUkF zwJ5pufYOPVFqR1?Y{JSafP8wuaJ9*(XJSOVK-k)gbb(xYz^I#WtByxl2{NX5gc58F z;37#5Jz$hQq9Y3NYA?e@3SndCB7GE4-UA$|?|(i(tc8}~Vw~D7C^CgU5mMa)dKE?w zO%KsB+}cBI_?*B+616>`-ep>%GC=tCVT&QsYDDiTN_)WHN}m1%=8JP z${rAi?oFqGG-T85C(mEN$1`qI<7>w1dVq5Pzo7em5#fo z@PF}7iA4H+iv8TrWc%r}g@|cY9;J8E0=h6FW*ldl8smyubdgV0lu_HdGz7f5G85ey z3to1+26DVwWI>Jg96Ao| zsnVyxiUMZ2UTw12h&^YFI(dNKY4~iyPE6teMTh#)A_c)T7CSC>n0OakbTx;1+(DK3YPwCp7c4Iu!I>| zqcbHLyr;~_m2<#c&%MWnBvJj52(}}RD!e}WEO(mdBT=#n;P_%G!>9X;_ZaJOMSsh{ zJjXzSPV-h|R@d0yU|FM7Xuv6jNuXM5c}wNd)JL%4?3UL(x()mi6&i5PKdOv?Ld?RY z20lNrkV2P;&ur1_figSD6ayr1sy2I`{t^&fM5RS{2^J0Dr{M15hKwwb`{g+hm%#P`zxgxXkLB;K=BNNKiArFwCmieO{(m6bv=9xW z$^{~5^>IH@cwVW03O7n25<-+0!BzU3@lS9~n6kvwS-B2?VZKY?d5Rv`kmhEi#0oA3 zNT8W2M?)PC^hv~rZ+T7tBFrSv7uJ3x!GGp0$VU5Pf$g73AZ`06H%oX2+F#+ziemWR z)a{@2f(>X!Y6eBOKO>wxDt}xG8vi8nBGZ&85=3nzS$vKQng2M20;E)*k_?_MID%*R#3(e#4*0{$%J0R5WRNJloE`Q_E2|D4O-!tj) zmqsVV0im6zjI9f?KA?Gy=qu`rTn4l;olK7 z7r<-_c$3+i(G~Zs=NTOijml)mX1v3Ud@)yOaE5TU;9-Uq6B-ALPnGXv+tz1{V?qK@ z7Y!ncP&IKF89vPr04u+r2xP)s0y6~gC8MJwBOcfc0f?un^47eq@#L(*4ti%$)6PyV$-yhG@SNPxC07K*>;qmzSF3;_wyRQUhkGa>XlJ2MPl0<>MR zByWJa1AjB*z?A@X3`-yZRhd$yQRlRUzAGjy0qxd7aoBA^C&Gdf(7`{B2eU2co>uS< z&&qX$mPg3PU`YU9$zoZ#2o$Eul?g)vjFqf_1avZnG70)vTUd{fD}f(hK}r1ykhZYh zyxni?CGnO)v0}*6k z0s@dmISoJBmu6@JwkLxc>kektpK>DFKt)zpyauRy)4GxtuKH!{^X8L7|xvrBHXs z1Yp}DT)82d-~M!5x*y%t_J5Z^`b3q+Gn2p#ZvR9AUivC;Zl(bA*G}!m84Kw5iPQ2i9#w;l8_}k*(ys#)+EZlMA_FQTgbjg zS;|hf`=4_?-S^SegLf*y6=Qzqqx;%6sY8G83sf z2$368aFl1QWaU_yZF^9Z`~UN_ta+o;BKWt9NZB zv3OzQT25k>a%US4AMv^a8g#A89rp{fQ}zv7ozR|e2rED2SW40wbyJO5z1XJt`}NmV zpQ%CTW~&r2zAfBjt>oG4n>JFFrUuXnIc(`q^z$85V%}0HDtiO6Rd(mekn6P64~X_= z*+>2%@E-lw<Kh=if`&N ztI4t0{Y;>3W3F|!+NDl$Ny@baxoW^Ii1OM%X2_lM@14M?c0U}U-f(0?b&oLj4sKR$7@PR;oU;~@2%bge{+#f*sfrTXx%an`-2>>8ym-57+DR-0g*+blaR1t6zg-VBfATQ zy>`kBr1Jx9dp+}R)hO0u>{iS=X`kxFSxw0;G$g3))8DRteB9VewX*VVtiW}*llm+b zEXQBTDw97AS#!V=d{tjbP2+MfPMY%Goj^W84*IP{_k^oQl#a)D18R`BbMIBUGEdz* zk&mC1)}9Kl*sbVo>08ZpHH2p+OPDJ5Mv9Ew!~k~u^>CKY=jhSf1(J7Zo#H=Fc_mgx zF9&pLVqu=|o7B=Krs#L4bYB=vUL!CXuee1x$(UBDOeE*#&YYA6mlJH+alM?EeN&0E z?r^+M#d3R45k>MGj18=B9w(L~GvwQ3d2++5iHesr-bv#MJbMQ3owQmDb|})6iKk`R zn+w~fJ!zovsCnc1IjoI_c>QT_q*DV=E1> zg*jGlVgfGgnVTu|SgW)LdChN~-qLP1L9FZ_dx8p&ahrrX!>izZ%1Gi;Twin$x$e=0GQ-@v?0n$VJ!j5}9R=fbIv0*(|EU zy&Se=Yq0V?li$iEa!A3?hgr2(Ds(p@JmUZ%VgBAm3eTaI;8xf`y5iRkUZ8t{t7%o#G zuHteRwIBIvR>leIi?+=?=ws7LDr=)_svO&q*`Wnj{0M@qpy;eOW3e(|fN$R*d)4h?3> zNvIxB7qu>mDbQsYZhx}N*t|^dQ*00)=)T^Yw(B;7?A<3*W`30N=honM~G7+H;Z|^vGlbeQd4Ejski+2P4QBH zkIM71$)UQ7^lzC~uQ)f@IM!40madDE-dWblr?UZj$M2QC15V@K#Jb*MLFtTR=f}8` zlZ>eoY{b6ty=|l#8q5ohI=AN?!_NAzLyfyt9=xR;P#t9zT}#>5YZIHlL|1*~?n^c$ zI&l`@>a}YJQu^(LzP>D=-O*`uaMunt=8F=$9`|VY+%SIeSisQYOM73QkzJF4f|Tn8 zDxCt8_?~t{!AR^Xz^HpRfU;FgW7{TECUsK^wewgeBZ<1Ej;~J##2zVgGw9T;4xk6s ziF%@y)s9Yy{qAz79k<_N@vmqZC0X-6Su;&Ec$~EK(B))?+sh7+6IJi?I;mqKaAq|8 zarM5fGVqu5!$k7y?`meu;cr!(eN7p@nT%8}bk#9_|=aqqEhs2Qx^(BW_8zr#I88)52xJ!ta#DC;$ z&a%DxbXX%ql9x*#f3>nqSn*v$*#_&JtTTDaX7H|QG*4ymRZdCEn!57k%tUK}zVD|H zYpy`Q@Ynlmg-!Pa{*`RVn%2g#jZu$G01e>uKIDk}3pzKF_ilgf#crwqjE4NteDjqxX_k zT&g0xj_YHwMyciTP=O}pNvBUA`G!-tN`fzcP}o&fPchKCfk|fMg(Go_F3+Cr4Wtuf z8VA&)Zf5K`IA*)sRH@MEv4v3@Ut3qSOM@2KmsbX_DQlmJ+<90U)YRVH@AN55admHJ zj+N1tE`Ecqq=rVnyhgp=HLkT=nM=9~Gu`zM!N(!XGRnTZZA+u{JGbu^a;Rn#8Jket z)6P<*U7|*1FpxVK+I*DyuJ!AKR30H~UOF23Y<`!O$nc#!#bae#yG@ttS8FdBY!|jb zbCr67v9}(!k6KhjWtl+tqj9b}79mgPKGm-dYwT?F??;JXeHySK2`Nin_or?@wlQsA zm%bIb2zX;Ur9o@fKvGhy=11qFFt(k-9Mj+(Pp)r9!ZUSKZo~U@pV2<@$;Sold%6pc zx9-;fpBcN`t@z(1un8LQnTTOb>-Xw6rFJy8swZ)=52)uhJ6GFAUXL2eBr-6(+2_9v z431yu`u3&xiND>ebj#gIqk6sfA7SG=Ojo zNvPM-SrsxwcE!7C=|z4=PU6T&gx%}d<}T*TW11q7ldHhkz||WXynH8urTMn*eZwN{ z@@Q#5x`Fb>l8U8%Jo2tL+l4E_carnUF}9E_uQNIicDoHr6)o$c5pY;htM&5mSiju1 zeH=NVY*!ft&)q2CIcq++kB4c`bGh0R;T7^G6UT)|1l+Pj$oI-VaIkSmJ1715X(@?@ zzIwH0+Zivl#){agn?pR@Z9~SsovvMLoyJ?a&go~a2zqup?csO9m@p?JQR=o{UgATy zDAr8|^k^z8zH`@(SA4KTmOVTMe0qOlO6x8_mecGs)D{6Lb4c)}p=eSY!{7V|M3t~Gav)H3O+;#qI(T^?n;{T4r?c>-C1 z%8UJ?)E2B_Y#v@Dvd3SZ)Og#xWutDGx&|L8yeXK@Pt+CH(rXy((VOf?q-M!)xu;=y z6n8BzbGDtFbS%51wJXt$qtgws_hU~)?E;q%Q3*!KdWeQ-AJVX6zTjN(Sth(>O9^pF zV6?JJt@91ap*}l1r!{YUf{#+KvUU$BJxb}gV?6KZiwvPvN1`|MtTnB5B~p9M&UWe2 zVcqw~+sYrXrKK{jEEv##{W2qT3D17r26-lP3QtbSoZhKY!&}r1@AkMHNo@;HrM3{h z*Rt?{(FK=%*cP-@sS8T3g6P8Z$r%f)ydaFKx-eB$W7_T zx&t@)UC-^L5Ftr7E+Wm;*ZCrWr6*qPO#UUYzwcbpCgx3r7B6CRxI1eavoI9j%vpoT zsiWT}G`RN(vL3I^$S4L;MRX?&!=v~+NQHR|MGcQToT;oh-hHQjz%S!XRo||PT%kyx z^a71YeOQSv?x%%wJz8<)b7knu)QBREZL|$oX3f02(GCgqM!LKzqsADDwDnt8R+FZz z>(Y*P$iM()9R^S4nmayW>fsU34hLmCT8GOizx}M1gjk;*OgM&FVQj zeIoOZ+)xT?Fb|4~US=*ByR=p_A|~wiH<=sf-uHd%%QzF!k!J#i%?j4jan$kCl?t%(zABu`-%hoQ}6iNz*eZ3h{I;2$&(b_%D0|l$+GC!oT-GPoSEYT zSbM|xWfV)d4$3EpJp07V!|kB<mwFC9dkJ77t7ntVgkTiA=^ zH-1ndnMc#v;Usc5Wo5DNq<`Kug$kP!2Ai5&c%tR&8PcDx$m2I$4^|S+j;(O#rqu9Cq~bTc!zduuF(%ar0G5uTA?%XCHu9}L|ZNM#9opp zWwA+$milUGweEDI@CdC?FOk4Ecdm-%J-StWMu4uTv9WvT`wr~hgbU2)Dbzt#M79N` zk3s^OulDmN>| zitclJj!QO(I)2QOo{5F=l9C7G=dJ3`ZxN}xFE?G!+I^H7)IOkFanZ!1vbCdLtVJhD zBGhf8AtyOd5cdkH4eR8u60)X%&Cth1Ubivv&V`fMV|L;Jc^NC^UkKBuQUh`eXYPv; zxt0?HhKZ_0jC&`Fx^<|5Qo``)n*1c?%lt!I+`l|wV+{Z*}nDZ=KyrTv#h6^uu+oX7-fppNrI6bf88t*QfLPM8>uh-& z8jo%^YB27_yE!la=Gv~O-`=BB?DXyHX(lL9dpU|5HH16nYJK_`hlea@Efq4f;QI37_^O^q?Bw;GLZl?=+rBN4{NA)G zR)j+)ykazP^j^`m`xG1G-_UH|ywtx`r)m}Ku|D6=p69FiMBFrj2NNSz&s$r!MxeB1 z+&qrY%GZJ@-GsQ^NBj-1^oX40D6V--dfy!56hTk62F)gnKhOiV{B}^ouqZEWEB`(J3=bC z*JISfpuVww=?HatdH#8)uUe;{G21zo-Aac766}1~<*m<_b<}Om&8bcHi{_OOkN-@} z2ClXl@2MG*f8xUgXiWs&;xytN!Eki=}0Y>=KY%Bgfy&m zkvF^Qoai>{y~ub**4W7-u#JcHq*(xCePkBdm;8uL%|x2OZR?QlOXMcF7EZHb#gw8`>uhjH$K`xpGLlxy!Aw4R3m2{(QLNtOm1a7fiv8KEqq@uEEZe-zhn zr9i51@-wgLpq3bEm0Q>aetBNGXi_3w6_K+eiTRSd!nc-+5OaCy9=5PceA_uT=%uG& z(yp~v`wc1EeP5c;2OBR%98$Rc^4PQT_5yMDWRrcJHIml_uLg9RyvgmDq6RT6u!nTC z{=&pQOP8%=tb)U0*WIp=t$_{53JPun3{Ag%%YLb~_m|s}p*?h#-8#^3a;**~PjktUkM_{e4vrm?Iv8<4 z?aoFRwm>4F6&^AB&$k^m2k0M4040DDKnb7(Py#3clmJQqC4dq@37`Z}0w@8L07?KQ zfD%9npaf6?C;^lJN&qE*5KY=4^4w42z8q~E9VrO9hp}ZOQ?v3=Ob`zt~(#kv#>6iQ1MaH4SdqV zfr-g+d8gHcx@dSd0r5LzZ>!@MAPF&^by>;#3u+P;1~{Es-jqYA_$%H5H*Q z8lENq;m>aE%tv)%V&atDGCVrtz@zg?&W^w8vj?w;27bUNTjZUO=V=orkEbVNzXCQd z@kt8@>|dP4E205N06uURf9K=bzQ}PVq}TDa5udbhz$+0oO8;x3A^5<3TeDDWXFix8 znlcIb=U2zgCpjbDH~AK!;_n=WoveT3&Y$M_ZSV^rx|g{wGdRz8i1MH22F(le^>F7y zM;H%(@ChxV-ii8zsSAWX6LbkH?zauUE)HCP(qfX%y0GEtS%u0jMg__^MyTSoe%pYG zEBoSGEc1cA@L)3@pvAKj^NG!fHx4|4*ZOS(YVMpfu>2c`7rprK0L`9CE;0vY7k0eX z?;Lnnle9=;^NDGBZp8y!R#y;cPp&7w9*{HL(Z87u+Ws-{g2(yaHzfYLDK)9=~P1ECFUmU_re%tU- zOVV3`M-mHtz72LRfDWJ3^ts}U0|Oq{Q48YE-d23-PXq8v2aCh2qX9etXob7{a93ce zMSfLWPk+bkHWD03&;1xJ>&S>#fq_N-_!HI(^b4|nE=QON8lFwyq%G#c1MZvt2X8-> zgpf^xB^B>7&k%$5su zF~?sobR8KQ?awR^HwhAl<_LWJA_Ee4D3Xi)XH)&FF)yHl=G8Gbu`gWaj6 zA<#Il;>NL7 z8t=j<|Nj~O^tTMQ2hQEwu<%vzJVaA8BUx#{j&d}7a@3VI?(A;7) zXIDY=TaU%){)1#Bt(W{ATfuC?3&;E=KdWaMGd&pL147A}d+eOD~dqUx(s@As^ExFKlOUdO=qcHG?=<_TOu83T>MPAfDOYHo8?(*iGzYlqV z9Ip8a6Ebm+(Vo%tfPUnj+i*SQ2zY2X~rf0M-Jf# z7t9~e>bkG+kMGR|%x+>6t}=kvc8fNesLc-{7%`u_-ad?v7kPspmE zCJ~=BBM_W+hfs3%t4yVh8H57=lVNDXH}pT4T;%`L!AICinIM>3UXI)lW&{G}l-$Cs+J zo9l#NQ%XY!sTS7G>u3~~?L;Wq__>@gpXu<7Uo%4a-!>eWYCanjKBnVq10flsfJ1ZE z7iz3lmX#L~(_XHbIQ0yxkN9lhGRYoYI!Hi7TxbZ+$>rSs)> z|9`p_Is??=58;sgd~c1xyWQ;R@x^BazaaC$hw-rmUW?D_w~~~$p)PI6m=hiBUR{2; zsF{~$cmmAMt*r4+?>hZfgnxRmQa03(&unNg1{xV4+XZrfT@TNAy*VGdgc*MA|GYpZ zXp)rmyj1`(!r2V2KM^6cNI+unrAz2dSJ9kH$a%gn{56xdp+Pw>Kmb-jLCvfA0K)jL z-nQJ2G12)RF>k#JYm<8iyA}|houqMZ;{v6lNss_+5kZUbjCSU1lK)=J41+2Pd_Dqq zLS+Vqzx{iV7tpeNc51#7(fAnzLNUj*AQ#TlibhYJ%hcp?HvyB4^m|5DQ3 zNVSEFLsRD@0PUsowN6p~a}xU%kvd-6;@&-kFW>zo=7hrN{r~st1vZf2Ko4w!{u?81 z@Ui~)PXNsUPe9F$(=kE?wpU9UQepRY;fFOGr-s&Pye=B}j}w6Ih}xcfCP5N+Bh()z zUiVEl4otI{{_~8#D(krNKVBl5AxZ!xfD%9npaf6?C;^lJN&qE*51d>t^@$!>(n5t;srQmM+ZVOtO1-L z3*>%`0@h)2;OpQQm`50Z3ts@Zlqcx!`vl-}833-t1mfR41aRVZP}flcuKDf*aGFv8 zXOjbM-8H~JFi%-{UT#45(Qa_og%zIDYXGMO0l8H% z0IpmK%tE99oYDsPB;N#|zcqo9hBR05;klwH_> zjQt8&-WmWGzyRe<+2C2GEr`r_2f^ugATL?~Cy4_Ig^xj6SqOk{$O9ek?T`a8fXm4P zn=pA$So;dV>9K&O#})wJ7Y1V!qrf&?5nS}z207P*@SM9ajR(M2RY7E)8;FJ104KEs z@r927TrCUq4RwR(*$x2Cz7F!MVgX#T81xT!131|?vT7v^_W-@eg0LPz_ZZje<$+`2WSt3{5u&01O`Dhs7^~8{PeG9 z;Oj&C1OPuootD=BFYCKHLA52|F;1PwEqc{ppiO6y3qSY5n=zkFzs&M9@udcY*VCz-6}ByfL;vv}tMW zfc^A=!@Sb76D;rGGq3{I(_x$0V)MtS8k?Bn)Ff{MvuS;Ei}}UpO1XA@oxu&mo4{!G zALnx5)?8w9$6Z;=_D6!?)j!nXhyL8cvt!i{&}wO4B-Xif`HJqp)M;t;W{b`hgZsu2 z>fythbBWK4gQd^jmYrJ$BQ?&?6rSVw>}+ji6+xh?wu6@T+#0EfB-T302l_z)5gzMD#Z;AU#LYN*1WYx)uO7c6jx0TQ`T$LQyN35Uo|K z*B~EoV_ryj&a@Q_)0)%&8RwK)Fp`cQx%vOtmML0fhW*(8BMH3N|kdl#8 zP%inIAexR6Knb7(Py)XPfsODo`QWYw_n+H|DWN@Z12FVI03IjZ0Iy2|L2*MWsQL6B z)IyuBYxD&mVMT$nPi9v}fcCHT050)`vlSQA#N+I_>Ci4b{JkG%Z$)f`>dm0X6q^(C*2Hvymb; z&vW ziGS*;1<$h_aCYxjXwyaPrig7_){zn9RmXrQZ;YYMmkioMB|&pnCA5*o!ZZeO`QZ*| zKmUfa4IeQlf;_U|wRYm{+S1StuHwc4@~h(@=R%+e?Z0r{X;4^~gtIXt_VRjYOGj+S zaA824{rND=L)D!VXJen7ngC&0jyPNMCGQatd5ORv3?|rF9?CxrQT(aGRplp8((kH2{ErDB`l-D4Lqzb=V;|(h z*YYRw_wFx8{xkV&OB=zqU$zDdTl$GP{VA$J{hp2#o3H^cJ{_G=2!;K9`@rpa~6RI+y0X1|>rIv&t$!QB`fjIrVwvrB5(%|4`wE zwj!A`vU2hYOXt6f_8(vnJ9y~u5vX)@4>*Rwh)YOfj!Vs3008_ucJ30`&49rO?g2s= z473knL_`;mpHAAii5s&SvEHoX+lIj`!gjNk4a3d>Z7iJYH()SaKg}Ni#+4W*XkB4u zftSL%2zfxiY&ong+{UmNd4My1;Nq);)Gz(0fl(obcByC~d$)=yP* zUIRQ5u7LFN=eT|;;?INlBO(1T#GeM~uOR+Qh#$@c=+}by*$jUlfPFW6*yp(dDq$a_ zq9q6R5jk=F55zwSu0aaiW3_R8N5q#Ysn`!FIkVvUqb|`JI6oewzl-$S5T6*tKj=}C z0g!iE1^scJ;reEXzYWsALi){!p9|uL6aDr+uK$JjIwAchq~ClR`YIv)z&p^-2QJ+V z`&QR+eIBHLiTD$FB7bX`X=>=T~B z`79y+NQ%zPus{3^*AGN|rw~6I%V#HXeW~JxH@JSZJ?!HmK1Si$PPo1*;@gAt*$`hY z#DB;F$|u0SEz<8s`nWA!A7QPa8%h8r@c)g#PUUU$o|1whP@A9|jh&!S=|X`a>ZZws9)199X!tXWSyxL1sl*oMVJ47LkQEXe{*W z#_diKYPh(C@__d7IOs2plP_z^jDx<&czqz!OMFlT*X5BZ`_aa zkx%GPKIb{4e|Y{UT|^cxE{lbWivLMJxj0}>eYlJxzT6+>L7OwSyRTj0$1(DmUlli} z{^aB&LqSbqoNKfu_VUAB*b5JMuqmZaXPrMBqrgJH@;@=P)r=(uxX~-&q$nY~N51E)VFN zKC6sB>j5lWlNbAKpfe6$rvmg%#~!mJ$GXR8<5+a|w#GsGKlZR0(VY3faSB{n(4QZR zTu*I#G48%V@_>H)xO0FE6xw?l;$V9g3;p_$dX78mvKE)$DZ6Db8LJ>|LyJ2`(w}A8 z;li;6-gDLmBH!HkF;2#T0VfB?7qI2cIdPGB_ps3BZw~#`aq;#q&d#a-;~t0OKye5k zgd0*nFLPb7aO?x8i!eg!`iJa%pU6FL_d*GK#NuD~_7A?QZk#{W|B)|p--PElV=dsl z3fucw=aOw1n!6d4B6pk0rye3Pr)527@H2j{{a?k BFy8 /// Returns or sets the path to the favicon image used by the application. /// - public static string Favicon { get; set; } = "webexpress.webui/assets/img/rocket.png"; + public static string Favicon { get; set; } = "webexpress.webui/assets/img/webexpress.svg"; /// /// Running the application. diff --git a/src/WebExpress.WebCore/WebExpress.WebCore.csproj b/src/WebExpress.WebCore/WebExpress.WebCore.csproj index c16f749..ea42bee 100644 --- a/src/WebExpress.WebCore/WebExpress.WebCore.csproj +++ b/src/WebExpress.WebCore/WebExpress.WebCore.csproj @@ -44,7 +44,7 @@ - + True \ diff --git a/src/WebExpress.WebCore/ufo.ico b/src/WebExpress.WebCore/ufo.ico new file mode 100644 index 0000000000000000000000000000000000000000..e4d2fefde38ec61642cb66fbb21a4093c59a7971 GIT binary patch literal 24250 zcmagFbx>SEmj`+W1`RHO;4Z--Xpq4*KyY_=*8qdN26qxHKyY^nPVhho?(XhyzTK_b zKi;diQ#Ey4e&!^8$e2)Hx6 zXDBTd$0jKh_+XDKIz)r9++Q?z7GcCJk%#(zd`?vVR}r^;Lr|t{zo;fXX*P+S3jC*s z7cU{UQ*AhOF-D=K(_Gii%kkUs4$T_LMJdqkm}0SXcyr3~c^4(G;u*!Vw6$4kSQ+*m zxwieB({y~Z$NL3Y8GDMu0sAEp%SA6q>h$iT-%ZYk#x%-f8OfZ3&E7s(^GM<%J^5&Y zxPob37!zFSisb$8$gv#!7%yBj^!;L*Gzx!M6mmj&4uY0(vVvBFgsl7`C>SQ4ESotr zsM6#qiuW`IIg)A7iNuMF-???oidFUNs+7z9d=rywl)~-FCjQ$mf-$OUb$Zd&a6mYQ zj(W+PeLPZz=R&5kPUWlb)TY+3`PhlP9&3Okb8GOA1yrW%;ES}0-j*Q6Ljs{4l%r4- zR5t&YM+zL|x)*)vt!#O%9?;lUTSC-1ylSk>B||6u*M7Srs=zkW;0rZO4jhb|EJwVz z;rsIX6e(`q%S!8iaPIGw68MI5#+;TGT@oSrAM|%5HEax`(VK=tOZ4>XTMVqjtlwxH zpmrMC_n*H-QAp`BozQH)Ex^9aS>9UmJxVkD7o7T+UEQ*lvekx<>U!(vmB zu+CHwbo&>%U9t}EIYj9UziC~^Sx3yBDxFwsHxDv(0?}W>mBYV_5e1HdjVxe>Y9Oa0GoP&i{7g>-!5`*O_fiBTCTI<9uf-4+lY>!N` zi=u9HWTL>Z&UBV~6nMIa5f~O$YVga2aZ0Tn;mH>%c?~{<7Bd-vmyeBVN{{`h=vGh+ zp;-fCTq(&K)SSv>Q>O9;!J)JQo$y4`cE2&A5gA9%+Zws7sb_(~+yKjMhEYdQipms^ z9@!Celgo~>hEEs(ABUMdZ)*xP1zkEjS%Y2Wh#iuTsdEW&j=ji`mBhtBEPN0dT2M{` zW+W-4MklcO@kgrS5cX;q(W1hA1+DNai18|x&qT(`Ul?giLp-(BO0x|m`~{o8T03lg zP$EyL&G#qTM(%h&g8Ey&vI)cpuUCnsoo42E?*@q|<%+lx%vEa|EyivaJBXTOer=vpT zpXZ-x-?G_Yl|PWX*dn52-QoU6A7TrJ-~6vW{6cu-IK|pa|3CDB0@hFQU;5zc<8uW7 zV8;K^hh$y5WJ$cifXm~CP((DkIV@as4mi4RqPW~da)phHZ`G_ugE{cOV~OhoV)<%k z$?ZAl?$Opr5hG6}l%}V?|I}8bjDjir7fY25)23Y_wuiyMs$jAsZg=sm=lt@;lvJ-g zJ(urz_GO;yi|@{urBm>rVBkIKSSQ@EMCY} zu3co)M6QtD-yz?!(qlBRrm9?Drg_DR6T<{=?IQJvL0tF0{xbCJ?YzU`3ymSrEBV{{ zj{~0W`$8t9Xt2MihLS&rY1HH(yqUUb6fSwAk`(iHm3rtpiblTz-ui<4Z5q7s-Yh&O9SKyrL#ZJC2yts6JD?+O~7O560CS zXYy9y6KS}5?poHD8lMOlE=2>YehN-Gv63_&&_ z=SFW{HcUTM=}JtqdvQBb;--Z*REGSWNHPT89;jA)A6Gz*MEN&NJI8^ zF)PTMJ}wYL`7~1a{f*RA8}0pk`gz1NYi+WTS8}N-u52HKD{_%cvYZz}DzV>(qX&2R z6#^fj){6A=AI%!dIXTwz&*nHAkUMi+OAxJx+LOI=mPOT&XwTi2o(PY=Ts8!m#HIHW|#)*diuJGz$=NW6eIJerZEVU&@vSRly zb@aGR9+Ph`(4Jc=ORm?Xmq|X-*!JniuYY>Rz?p)#!yo+-n@ouV{+FOID-Q z9$jPUMD3uxf4_Jk(PK z*DAR5;HXTQx2?omFMibVUaQ=b@b2|h>JlaHe{D`zf`zh11y=a~VRK*s zAi(}#Hb)cg9tK7%{~w!^?rWE-NjfO9>s{q09^tyNGs}SL8k86@%4yl2A2yxpK3Z(@ zwrSwkJAx!p>jJ+L1nH``)nEeAO^Xe@C8y*S89l`3zWz7Ft?a{k?Bn`edq#MBzJBAK z6PGG;7dP9-i=2Oxg!y-$DtA5kyDoEGb;57&cvHHISl84Lir=#jNM1tJLL$ zZ&jZC`?+Y;n%OsDXFB);>pK+@iOUU#o>)7bixyewk70qs^ppOV?jKSdc4zlr6UOc6 zGFqd(cDQI}K405LwwVVp@#XqQrB1I-dL62p{^Bs6+rs&)yz*;pvW|vxRRbJq$B$-S zCX*|uV0%xsZIqL#Hw%wYI~BE9Fd%F9$ckt8x*YWKj%;k&5UGGvk6 zvT=&sBMV!};g%qi{YY9Yr4Obk_=k4Mo#w(-^yO0LbP&8eXp_zGSQ`BrlV=nz!>IY& zK}O*~>I_G#Yxw?_;qN($0kPXfiGtJ4HPK4l&GhEp%Lx0dEP8z@wcP7G9&kV9tBPwX zGOxGiXVh+RGu`HRS+(U=`x|JM*%F;!)_gzM-4+~r1PP&^Y!bTS9evfhgqwSLGfH{A zThv6}$D}nAzx^p@A75eLDksv>cPg7XIlj17{lgd9GJXVI2e{w(hHGw5!0;@`^zbsx zG&w!TZ0YEp;zlH@b_L}gE0&!y)D?=Tux7*lGw8B=4u17ljXLUu??E(21@>#5mcXyP zP~-1A{>n~5l^INn=rq}jGDThDROhxhjQb&Z1?IzF76crLb0E44$EX*lM(y~W;uz*b zHts*bjT>iv2({98CfA^t@7~u>%*A3NfYeI=DZczqedA;*>{N0rJdbW z`&ii<4nB1g^fcFA3W)DpTCr5E3+B0MG!}ddRsA6n^c?P$`?V@)zdO^Wed6D4^;rKT ztyi>`Z$p#|9op@fx5HO5_eNb`ggG>`eJ;-M_z1E|J@uKBYuk?jTD94!pYl7}Q+s})C!PF3Wi-8&LimT7A2Ma8KwwPJvbwD0=DxJ z^twwiMX2Ikjj6OLOT7=eB6J;}f1m#d`GYy4T@LY&!ez(ODoKjQK&+eeM%yI0a30Lq z5#MY_8xQU!?@cE_m@fJ17~E*WK4ed#)3;v}lwg@A^;w=NHTt{q(8(Z9-)lp!$-i4*L@=7%EdFuW-v zq(scI;?CXOip8{Xf}uTdtxjr+J|ODr>`kE+f^oqoCuAGf0sYSNo3pfm2%~M!*X@yUrlbRi` zHCpTRi;d@Nk=A1}Vo;rK31`fZLotHxAoTat8jJsuH~-zaBYn2%gq1DKS|Z2^1-Eo{Ahl;Mtgb zx_$L)FFr&ewZB9TpNqK3C51!ycQ%YOH;i+*{Hw7nVQChd6BqeE&g!W<&S(cRuW>WJcxUr zCjSC%Pr{%Tc(N*%wrOdTKlRHa5~^5vMy<(Re746&i4zv;?Fn$XuO}`&ZKFLMR#|)& zT(8#L#!)cov)T;(QG|MSkAS{SiTRG*N^#u8lxQ;J(OM6Eva|Jq5nzUXw1AN zV(j({!U`;*@%oB!+z|4=Uax^PFu(?fLhS$WdJtGY;geLj9&DG5PQ98ivsSb5ES^pE0H^-8U zX2&~L`|uJoH+`~L560V{xEr*3<6d^blj}Rd zf4bs2@qE&9yL{_`p1q}s4gD$zD91oez}Z{Kcxm0TWI+F?>SnN%9SU>CJt%!$N2}*P zA|%r|)FJF$Yt)6M^jJ6=DY_&0!+hSjXyt~{YxKp*Jb)jb6i%T93iaJn`j~uVLMr($ zOvZ`FojNlN6W}70)ZW04v*5rJ9&xF@l8msU(3*%bQ@g{2T*kZ~-_d|j5snl&k-4c! zh^-RIUFt2~y@9A6TO}ireaD@Vy{GN*iwV=c=JMr)R z!{>g}G_U03omcln-JR)@_bkU-@>iU~S-y3eoG}2%rdDk^?^q@GM69MNC0;v8 zaNNcOVh$*8b9MV{KJXyA*m8OuKY^@bpYcBHhUGA~fsc@^kst|l74a$DzS zF8S&ua)qw7jxc##@sF{h1XE;Ishp-Oww{b-J@Ulw84e$Xr?nmbfrx6IE{)fgE%@CF zWyM*7Vy;w|m!z1hQ7OIGD+<_&YqT8jCK_o*0hD|W_$!}nWx1g~i62(CNgMLE^;FO$ z?%+b$>Ru#h;$kn|niDSGDVG{&Md#0IS*;{aD2ohit1r+%-h(E?e~BK@iJl43$ayAJ zQT_~*6C6q6iSgjOU_buQgLX1@J(lne;PN8)sHcRKTeuzoR||A!K_+wrp7k#jBGI9G z@P5~XIc{_^H^?NL!00(m(6s7rl@eCM!1uokzkcrb(63b7GKC(6cA{mB_~%YDgYt%5 zaDU(go=ntyiMcY4;6P6>1o&{nOsB@1Kp7!PNy{pN8= zhY2d=%Kikw@h1MD;0yR>iaV^C6hK1T93uGjpQ1nKyPTU^45T7$5~x$LexqY?nbj&h zV5C0?5BXsLfYZdc;vJaaFZ0*#so6o_fB%6;XSaIe6>?z>y(HEuaG%^PFv*{N(`{dH zu58|>)e;D17?T2QwGX71h_e?AKUbv%bv9v`cv-qh^VD8yw-3@NfqZVd<&U@X`59_~ z9ye0AP~i*=?+yi^r^RVYWHN0{g+|Uw{tZdCoLg&lQpVA(^M$lb44L%pBZc<^O*Vxo z%dr;^-VUhfRZRX^^5of{v+qyS?5B;f=bWcOS%IG+GNgD{K1CH8P6?}&-*G?kHr5QX zSqDCRnvAm+4OBc__@3{^S55=C00}i-kF=#5`E7a)yA&X%BS?KyYwKAF18x5Ir!zO4f^?Bjmlg3M`?)1Xz~a z?is{O5F)$p@m|H=Z`H$N6jKnrzE2f-Sc1UsBT%T9+g}Px9LrO3Vg_ld9t1H+l8?{+ zxe7o{UHDjgexCyr>S)*GiIQ%zvSMN(r>Jx>$@hq|E)?y(=g0i%?8i6q@AnI6lI~kh zl`DNGMgk-BU##aMt;}T6qwH?}9|M$*7{;w6L_`Yn;i%(ZCnHLrvdA*=LfkxwMk-1I z&Lu}^|EDhzWadavo=QW>?}C`!_u&5HLfn)a$nf^$Zlrll!0w^M4N&M4bwH`7w6BTI+c0Vg(CRhxLTC2$ zeS6gR!s^G|5p8+lTKdR6+(k2~l#9OCqy1M%rz`*1IX3!(v9(wB=wre*BE}!3ByR5SjIv`nxfrbzb=>n{N{FNq;>*(&#ZkyOQzvb){^mOR`BUjMf|z zsvRJVKm5R28K% zGGkLG%|^USjzEx&LFEw0u=UgSqWPLVnA4jp>`#urH%{eExcj{fuwd9-l`79XiAyJjz1@ogkE6C-?RtK?{WoR;M zG$$AEut40sV z3YRNrsK+J+Wn+I^ooE8aJKMV7@Zo^_5?BL`#iP!L(oYb~M`N$Oi9xU{T%)EiJX-y) zph(QYY3hH2qJfn!m6J_AS^q~+1cmiO|0^i!6E+_G&zqe835xVlvPsl4Ub_x5#zL3^ zDWvh1!N}O6flh?V5<^#DWE4#; z3b?UA`2|_z5diGj!#%n7C*B8&44_vAB3Aj&!b<>FPUs)3uAgnBg`icDhz-=h2dlov zKXJgC?Kjb581UbjKdkXC(@Oax93+^h-4NE;Nk=pr2TU5F=jkpZ11&_>>;YgXqV3tz zffO+GM<)0l#ziZ5SA!Q0QYl3=)@2XZZTAt`VzDV9Vil3=1^IYa#_{Wi=U4ehglVd2 zQki;vbk`9};)>;&Kg&Eog4Jp$y0BTjm?`41xU8uxbl{{OlkyX_ zK2VApZtygChC8U4qS!^{dMB4Q(pzo#)GnD|Al_QtAp?1fY(`# z+9{qlZd{J#SG^^clQLGQqs=(L1tK>G>ef$#PAvO2Q8gTpRPJM>xatK6K8@R?USjo7 z2+7=EV18D-MuLvtsVmoT&#uqQP|Kun5k+O>iLnbxQ!Jre79Q`d7ArghYa@3zGW8-O zbS>CKHAMBj^MSw1C$aO?f@_&H%=`c&#V;g{^Aj!=c?KMx@V?dztgxT8fBei$=TG_z zMFc23&#ER-exzm=$zT|56OVI(Uu(THh*+OLy0|5gB4zrj2ANzKH!k2@&}5oF zgIoJ|BQ!NOfOJ!q$|=V>IO&;R_sm3F^5%1p6C6b%JG&U$Mve%pMC%BFqy|dZ3**=w z=ss|L4UJBc4+O=pTe!RCSiI4o5$PMtXwb?)o9he?3&#W^f8-%1hx$BNEA-OO8)_&~ zJ=TW30d^D|j`&{1)-z?U&GB}61Yba_x%Af_Te4YB4rbxo#(* zTXdO$xu~h9qV&@miSA)*K6EXO^^@-Yi*kcSshGd3ljdr7?&H3YE z7vb7yBt1$3ftmyzqvn%3mFduSy1i)uuLyjpH(((;sL{fvM)<(-fXgU=dD{L+C$MR+ z+|1uEKiR>yyhgpsWJNzcOTM_5+phkAWSu*$x{s(f;|63jW3;P^Aqq@?fnTAEnV~;C zQ_$->2aHPVrk!c4%1cg>kLwY=5XTGh2J}bFFZLqH1LwXA8+9`$4f0NY;?3s>^Pm#{ zUDBu1h%!lU=G9CvPr{2hA~cdKb7|@o;Q;y3M8VnPodC2D(1}V&{V1Nj6NiTk&h}9_ zt^zNj$nX6(i9Ym`6$9U8bEndSKN{`@_kJRyc6WD*_YDUO6`g_5c@EFvawnb!_%&5g z1O4wBt*IV#ZH)oWh;xOxJ+C&}@%rNhzEG7jSinhBViF7&n66Wu*Yq?9R{FV0KvaV#&0NJn zs!sxtSfV-9ikS5pBw6B_siIE4M}~d?RH0WUTn3lJ>}efu&~L&Ri&YGvR|#dOop^&y ztifnXe()%HiM4Ff-;TjXmqp56!`s%^C)}2no&!9Bq*5v zgN5G7B)_(#Bb*7>WSg-xLq|eTSw*$_5k48zfYJ2i7jtLu?;2mH-{<6%Y>F4ZeJWP= z8PkuCM6W7^zuwp^-Tg(1=#MM>X!=1V_It;P0PY3|*bn<1g6G1x-6efz!0z=6+?0rC zHg+3);}Tf&L)}LtPa2Ys?vfx`js`3#AA(AXvNP8F?uk<3nLIxxK}-=PbgQS8vY zLi`MlTsYIHr)IA>1vcr;wYHtnF@XJ%$=V=vL2BhfmF#ZP7t~DCptCj75{^aEJp5KT zYbcps*Iktr8P>L0TF0-B?+xQW^XH$L@WH?=5wBs93y?q8`h`%`ThQX=5BYmMmHj6M zMkDL?16MuWc)kGynzN|GTh>Ll_z6$Ul*mbt)x7X5 zCgP#1BPi+L;fyCIYNn(4h4Z9eyi`&@t8hRlcQaaUPIK4ps%mgn*!Vzd|KI@?^zD97 zGPN9n$8H_b^POWj`)jcC&9`D15q^=2-0x zo2!CgI5Ff1(Rg8#Y%Vq%sc;Fkc)o_~;RvJE9V>^Khan)ue)7S-Hysv!1Bq znha|+!OzsTC@4oxXH4R`=gIURr_6W?RS1O19FY&)Z0iLcb*i3=EqDrZ_tR}V+CVs1 zBZ2h515ddF(fuDUlnIuk95A6Zd+9C|vwKA`3-TAgZr=-T=Nw-ySXx)N0IeIk-i>=D8rAQt=+c)y_*VVy z^60h4gV^=NKa3bYm#TqC+>m1+x+Eoy^=mIYWJLw*H(}c)&E7B@E*iPnx0#|CYj%R0 zq*Zal0V}__!>Q((hB`J5OEOK=tkTmHyT;wQyiHPv0w!;yXBV#N_f~OXCx)>SkF!Aq z!R%Z>0?X%&BM?_pf(44g<0Jd#bhF`D{CD!81~dG|nN^eY5-kBe>MX%!c@2|^_#rL+ zf7?ZDpW?+Wrn-|q$dl-dp25R$@PQPR#?Yz~X)2YT9mG+cJkPX)|K?`D|Koa>xNVo$ zp4QyL%{_kxZ}l;(i}FhMfm6y@XJm82Qm{&l*oB^2&-U)%^;fn_cySV(dOf3uV` zE#we5{M%e+w?z?6dYpAUA5Hi;SgQ-SPeU$iwg7Ycsn73>`;P7f@ zVT%6Vm?U%L7dOOYb~ZD1_48UIy=HSQI@$b zk}pn&CdkB+qCjb9=#eNd)|O@x4t9Zon-eRZrj)*i(@PH`$ru=9#Bvy((vB@R3bwW7 zn&`QR?JX|=X}=eDRat4v`$!Mx_M;yWv{sTT8?De;Pc0w)^SAIbUZVadbOyBQX7aB$ z>*yc5W+TaGiar?wcRl*@zGnt8Z62*}YF}>d6ZJo$GhEXk&RQBR5OmkSa_WgTVgSR> zbNyMyw77fm!;D!0_!<)2Q{6U@yrD{eRNhU4_i5l@_-;1qlZ|ZC5QjDUU^teI&zMQjxO?YzzG`YV+x3c8p0iDz|78-tOv$a;;1 z;o98eI=tXxOM87`U$j0M5P>#V>t21w4tK!rqnSv>)r|O(=lX-vehV$v-I-$q#zU}t zcQtSr$+f`=Mf*@Dad;l^mA%GgIQpGs@#4hea#6<`tLhKHjv{c9nit`Fv7gqS)(ng# zlc%9}TYLvIU#?!Pky~asdws{)yh*X#?5K?MEeg|c4=3}{*9Ubs`QU>GYDqA7*72z6n1yaY+tdGLN&HHOYuL0g0@BO$~ zg*s{D$>F5TLF^uB^4f!bGNRvS;l@tA>AT%-F^+UwJgeF6Zxeg%?(o!Jfev_ISBtK0 zp*hy+5e^rmr3G9isBj#|cm+UDlRE?A*%j@R`k7TJc-BG&8(zXm0eIuu~*lN`dc=2=t!(%TbktQ3=iF z9CrW7o$(2ij20VR5NM!$+iB-&oxT5ZLf3=UQ_}6gK+YkWkp#XkQZ4-aed7DPEnK$ z_4hX3plVl?OWEmIcq68LD@MW zZ*#z^Hb*guSv!as4d*GCK<%&3OL)UogSuY39V5T}Ssp>9sn1#!SAx&=(Y&Zt;+qO^ z`_FrQX&Mn0{yA$TO zesbQVc!UA75^iz54Xpv$IW9kkYJDl+1J zOhcFu|8e)tacT5LhWc8%&@1?Aq`F(b#myVj7q>lkbdw8%<9z#s;>m~G^7IzUKWDlwatklg zeB+cyC-ED60XEw7UPZH_o!GNrexh4{1I}3IC94g@ujj?(GZKq+^=;)1at!D|WHWPH zr6p|;xdYXA=t3lZ9rUklMS`6_UW5vQ#_jZKO`GB8&?Hm>%2my|1A7KPc>3%%g1F@0 z>Mj-j7@8;(7OqU&tmpu0?G0HJvtj=ct|ysXuRX_fA*G2_jK|qL`x{uSb_OS8NDJGbonCgX8N=b3^-^c50^<0bi(2OU^?(hKt?O z%mRm=&@=HpK5r4In?SnL*_PH`vBJS<(?IMIO@(;hZCX-KcjEiA*MjHk)5YOb>Q8g; zRO5vffh<{leJ~S$UhIoRgFDX$VAd2~f|Fnt0)__JSd&3LEN!`vNB6-UkrdX z-B_8bg$p3@F+C=iUO2b zK84q3BLkGS={C{0p!|PCnA+ahfY=B5Nq%y|usL<3k!h(A&Yu>qT+`-Eg-5CskTXC@9RSW4xRk`b1slOg)1VIl#PrtFBT zIKY1RgT&8Y5r8t&@9@GUWPsZtLWkg$7na$4edVYmore|v=Z>XDItnio3JmNpc+#_1=ri^<+Pfl zpg94oClE{OBjC3iiH3$2`r^cv<{ROChI>Ck8ICOKU`e;{|JYHs-&$PW=JSw#P+8@F zs@}2bJOBFUalWl=1WFPFrAW{MhX6Rb*D6k;02W+A8w5*+9Zn2DsrFaAT~c zP!@wA=ZA#oAq0Z|gY1?|qoOAUIl_?bRuS4*GKBv@mOC+4Qmjcrkh`P9(qTL)VaRIK zv<*7qKqDA3C?Y~1O9tyd$TB;|G72mS2r@-P#9tWC{~+^6Qoq5FF0f%Tghw@_z@do3 z`YG{Q`tiUcFn}@!zB4WuC+I(ef3FjjVF&J&V*_J_#WcTxLxI4cpcH0FJa8`-796s; z)Dny<1&k|HE|m(#RSw3rG$ig01&%Zh#voj?8czwUGz3z~)^%!+(u_IJ`D4`4tkuemL z1<1%CH>-ZR8gL*3tSBKWYX%O)fGtM8IFm+HiXHe{ssS>^dlf2rWVipUE2-8G6&g{B z_psvU#HcJiixm&{-Q&o>mp+aJVeE`6oOHj{poOmN-{sm}zjSK+i7XyOJ1dR+xH$dh z&)t#X{pjoNH!Eg_t{iciHe~&Ef?;#9b*f}b8CzmyK6wRr=a@P74XX{_5Gq05-3HCR z203xlJ7M6FA{5JMmqL=Nek58b|1yA$}TKRbp{1Jt%qk_A6ZtJ|yQu9j|@j_pA%E#u9FP}RddIS`0 zMCgKT1E)&72wkwpI)82^^AC@1VPnCi3wm6Kj*aE?#4<+5@_dORo=@Yi&CwpzWwb+^ zTu{FHfIAmE#GDS#ZxRamnSl*gieKZi{TqQ;8=YK8she!ZG3Z867~}m@U?f!YZ3yze zI#k$xYy8P}xU+Z#)Cy+Lcx}|DKgml6@|}c=fn&t7-ov`pmLNVLWv}G4z1r+0w7Hxj zISFX*X^XTI<5;Lrj(YbvODq+q%`n0E`M?(m-9n*fi;(L#kIQg>C!64LPmJ)*br zi&dbL)HnW$LT_9~nLmF%PkaxLN2>d&9{$;q+WZOC;&oS`+KyD6h7xL_R-)#J1_lwhx+%%>g#El-&$qhYvC{1X#elDpXj52XlI^d9FEa@X_%neP&1~x6zsuU7!+u#ZnD(C04gtdB32Me4$MI{dRx4 z4#zBUII`h}d2jq5BDF1gD&_cS7-Vv0RL4d?F4$pMXSNVqd9{1v-uIJ0Gq{2nwmUkM zV`gXubFCS{$0IswLdbjkfokW9W;>3CN*{1ME?{Cb8#(o;mgYqdvy|-EUn5y> zzV25?l5I>YgRdNV99jzsoaM~$VUk*kjOlwo@I_|$F1f%ea{85 zuqbdi%c6jie_os@)m-{iNPgmm3;9|s@Fpj_6AQBNuZYHfq>UMe!)Xc8!Y7Y#i<(E< z%1kN*2(n_$Z6f}7Jf@?jhoyWcp;tR?eGfC(bEU>Zzu*2cJ2;jeI_&-Z15639RX&)K zgS8(nO>i(T5%Y7!HxpCFM?$fnO~TQ)NBy?1wfDcGW%b6QFyiDjiaE(+6?I{bhapjc z$NM}YMf;ez{K!qlA_Qp1m&JlR+rVbKoYSgbvp*kEdEAvk&Q}ZfdBSY(GE1>V! zB$!;Y!G>c1$=#m(8bH^Ij$(sr8|1b9J)zB|f(7UFIi(?hAi6oTFpfx%OAMfeM1LqZ zOSZLx?V^>_A7oH6z#Ro^X5R2!CfW&#-N@>=8O^|g{1{KZ0_2o`J?JP$(i~X)iNu1S zEZF%wnqATe>=2@{v~{VOyZj`FiSPqQG9fsy@jw2;zFd)$6?rxuX3(Dr<**xjb@awhvmJJc#f4``{AL`s0 zvKB8sy;Iia(l^64u`dt2!Y-&KvONvw=kh~ROXl8nu#g_He5NHQ6;?Z~@-(caV0xxZ zRpzgBJf1K~)k*fZ?H4rL2^dP6DRI4BO*S_)J#Mk&6XyL%t1~ve(@U26$YNV{RDL#K ziQ+MCeSi#El|k(?VSo=<{>RJRbYu6y6riCE~CGru&`#p-2@35FcoeA4I%6@M8z~_50 zUG?>5kM20nW3(usgUlYr;r1|q!@8R?Y0;Ub)ANjduD`Crhgg!Do4xoGEx89K^MXBB zIJti7NO6Y`SJ*sCruqi>(X1e|ozoF9-8$AP9%u}SZn3}3{xhZc zeXdf@n+)e)Pl;#P!**iaDDfR!)@a&iXN=)HvN8BZvv(A2N6vWsETbFzi~^{8FYs?C zs+60^?h)T*k;YCPfxnzD>Q`g|t%M2<6&*HU8B(~*I9}Kvo$7-Yh3ZKBO6ECKnkSuW z+N@g(1Qh*)AILgeZGLWAB>&h-qF?98?s{5J!Y}hmFlDvWDp?}y&f*QAj}F0&e*b_1 zEuFrZ>;ANuPX<_G>E|}bph$eD70iTzSeo$uZhoFK9-#$T3IzXJwQ1ts^ekK!e^kWv z)sG_}ws89HaTqw~pO;mso5#GSTByLRmtMQnnGisVN}#(_OW*KLZT)Sy5n}jt@P<6D z1pYH|%!UyAOf<;?irDe?qyXHcDaZBn#5=OIjD-z`-r*rueftG2S4#@@*%Yi7Q;cu& zPUjbC_V;LM!EJu2v|AD*$1hoay5f{ie^ex!9!)60d1_>iJ|K^@Sr2*dTxg^G+R?sP z);>#qmmhh;nU>*M`hv{KU8cSJ50jB&P`d*-f)0zThZa~IRWfPJ@}p}$#=lNAD(&zoyLA$r$;Nif>GE|$f@=l2H1Xbn!1b1DU{nF6g zRmFL!s()^c+dYFv>)**qs0b_#x2$&(_b&R%P^0mok4w@UQciqq#J+27Y1XCfed z@X@mQYyM>HPu>|rjYj>B z4(i4JfZ}iRmBnPirY(lurZW;1$OsyaZ2vJ^i)Bpv3Xv~z#$dHLm zkR13cq~d{EdRtI#%Z)hgP(?#Ai7~f>3n>mDv0$6dT?()?Vp8i1jzgs8GEPRHhi!y20Dq(8vU6 zVm5-vMUJIR?}$?mjk6dgxixd8Y~>YR^sdlAM_O>!Z=HW~kAQSl-(X7V(w@Y360-CKUFkN!`4#E!_D@lc z_-EE7lUP^#l1N%@VId-O|`PAG;Ao@0&!Ns^mv|ifD zWThT*ulFRA@IR&b@?I+YihOU*>RaCyF1LcQNT@1<+s@fr)W&XdbQJNMJX2ah6W5%A| z!^(rnf+GeUxSwxmrh{r-{v~#QRX+6p8Jo&_b7HG6uGCY>rA`ODuXO$<;9hn8cd4(H z<9%~sX>yiE3!aL;gTW8w79;?SLh*$RQevK9N!aZ!S$&FfOfZWId$W`Maf$ki5CcxN zV-$M+;AJD%u{$*}nK5Z)@;~wn;b+&s&KXm4C|H&l}Sdi-HBV)4`y1{f|XtPD2q0qPdk-jY^Y&m0Ugc zYPe<#1;|aG74y~uOzJ%SqTtRGVKa>AkPpmMM)|(|YhbxC_yj#xSGj^6+iJ8KY_zz{ zPq=s@!qmL%!u5)b{1U6>Z+#N7fo9ntdP@ZD7fp=xAQH0{hKM-L{pi!$L7!WfD2eOAA$aRk*5rMo$}?DKcG@xaMo$d^o%XKziNkLt-pVn9H(2qmsh3FBRsM{{N6{mitOg zgQ|USViVWIc<8A1hJ_h^|60_Hbk1>H%F|{?LaZ#qk@6LRYuNqt&uIS|e}av|3s=`5 zcjnel$?EiOih7GE<#kxRRIpFp2?fIDhdol|l8kdUo@=?|h#9()q`7ctHDaI4sQ$CU z{CnwIvuyWiKUByFeOwb9&INLIbYvJ6=LtP?=)lkk@$a?4HDZ0mhp=K(J2BW{xL37( zkul{Bx^iNs*lo&1?Z7*3R`>I=lFh~3yKjV<EWTGs65)r>6140W{3nN6AWNUAoT_D1;s)2k zpdXNQ58teclr5Mxqx58nJ^`=I5uX|~kbIxf_;_CKUPt1ZZal2ggk6ew0;-<5#6|6) zC0-Qq!r0QhU$sV-3l92!x;XD}xSr_Y@2 zQKLkO5+y-I?=473wCM5Gd+%l6>wTa1_s8$Q`^F8?_~|3J*uOA_mT11cfL>N-Nzh50z6sVh9&cf&mg~>C zUT!v_4|b0wvA~31pAEdG_ON=_w`ITbWmZ(e=_~p{^M$^WM5e!{^-P11GS)7OU)%j! zoU_M))Q#bM9ab8m3hvM-4^bL}tVQAWJ<_lg+?*#0o$DZIlXo`(byKjYCkml9kBfJL z%kGwW845m61>t_YjQUoNP4cRBkh4($YnZz3^=j@78E7#7f-cW*m$f^vxwxoJbK4%X zePM+yvX-g`-<`iN%^UIL*1r$J#|_q_b$`Zoi* zMd5dAk>9^Ykc}7`ya>Bsd))TnJ2wpiy$b$LQgT|9G&N0R{wudHP?;KwG7ID(j!U=W zZf%23Gv-8Nagp1H9|~S=XZ*}cP&pfMmu(6Ylo-mFi}&{L&uDJWBiZqVyze>wr`*2n zxz1gVwNa+KB&1EWp-&KiEOGlMOFd!Pp)rQS$JdbH+{!)irv|A|&Tx z{DzhV#Wl#38ym3Icf|PI3-`5v1Fhj_=4%qtS~b{FcHb*~wx?_ERdTE1J9}BM$8nwO zr@kDsbkcqPx=b6t!*PZFNGWG(UzP4gmewkQgGy*gTftVBu%A*OQ|^)(zdko8lxEm7 zfEQLZaAe;`!Q%HSIGaN92i5t6sK2?~&fk0{di1wswSUIuLj1mGVQP$G-J-*R{JJ&R zPW_xMtB>wozec~+Tvt!Cj8H5=4nDnm)gp?CZsgC9ojTu_ zPR&PDRaDRjhEAS7d#arT=apjloCh}Q2kyU+R$Iw=H%?9<2eBP^&40+|y(G*&$ z3YtEfOXDj>K--9z@X_=Z&5to{Cw3|Oyqf_Wy^+Z>NAAo6jDl{a$FDj5HO4hpeI$$u zu-G;ob6eTNgG-~BxJz#1Yy^1g)ShQAHaDQTxUQ;!fvZsi9SOzce(gt!EYALkDvfJC zcsz2Ol}=PO4Ngsd;(`uqE_nJwLM0|2?uM2nsG~>t_11pSUnVX}tUkY1VLxT@?CrFH z&Gn5DS!nj7r1M_>Q`?B1uH|X6VCoqP`Prix+vx2FnvqzH*p^}xPc{f@22qELQ3n_5 z?#%SL3(XPE`epDeyRxT(91YOUJ+*{GChi8lfxWIZo2udlff;0oMEEAxRV;R-h4D4c4QCeWI)C2`A;IHNt!_z6<2_?;B?x06 zCBt2<%zYy6)o}D9N%7# z4?V%f%sG#rJp4*i*`-goIvc*FR=(nmQX)xmO7N2e18yF16E>3mqv6Fdb=o<}3{uVj zPn7U?{O+5x$EL1YD)n*88bcOu{$>qs3IdJ! z2gKo>rCo&9n)+q_cSn_>Xe4ImzhB5sicW4#$}5T|zJZt;{Xh!AnI zVadc~C?|?icx3ikkL%cSHC91j*&k->m-b>UI6$UmsdanQ5%aGmiyhkTvOYd);(qG|9vBkxJS45;oo9VL%hrN^n ze~vd2WeHye-qtqSjWKaWV{^mly&6(b_0*51%%1u6;>Kg#ax-Nf49>sZJt~sbs*9|u z9SEs-_?!&_%&b*G+qb9YtAjFxx!Xujh<5hez*E&it>DQAcf3w#`{w4TmB_Is^B1&c z!zlK5T&s58)H8D6z(Ad$4FiT>F@$1Ksj-I#Us~+_- zpE~W!Qz`OSItPXRC^BdL9ID9ndUooz1^4|^;%huT*|7-x^C1*w_tx2Ts>Rx!t(dWA zuu+a-w!A!B2q$s3s?$6=q33~DSkKVp75I6lb?U6RAen=qo`Cu5mR<=<=`>uxv8da{ z$Ax>rjyqOkolBKy$l6C$@mALm;gGE_&7GArl9>u*WBp6V5{EL1yGA^rY=fv5dfqOk ziq+}!v=>|jx-*(}DcqR*_wMNsL$JszAP^d1p7kUS8k7P=w1*!SbFnMJnS)9Wh`2#= zG8yR#ck}S^>Ipxsuzulg%j_pxfLhSYa+ameM=5|Gkr231)a2%HP z1I*kU59>p}!>S%`ooxZZK6V%kLZ*mp?R!yx^5I(WPCM@xG7*Efzk%?mRHQ;5lumH8 z{`#C$y2Njwh@`Y7g}mSoQ8+k;J0H1!Y?WwBc~K0kSni3a^LO1(a^=9w0pseIk#taK zkT0bnULN(wp1EI6{B?Zavb{M&V^u+g#YfMI#zef9E!!Ekju;(ufDEFr6toA_z|7hG zU9nXo8D+Ff_flh}I2PnV?CbDpZu^lv-6_Eg%5qAp1`dZkLLyxoc?VU+(G7>e)3A>j z?7A}GWVerjPN#K5=%!P0+6lcu%c$j*FKY7)neI|Jd%XR?Hap~}3c^Hk<=LstT z*G@K8Yyh%KnfbXDQLcVR`_=cL(Pxg$ao-4nh4P?4!qCEH$>**DEWR_e-iGhJBzeG3 zb`a{J`#K+KhYe^7uPwMt1;`-AU~V>&q(j0x+#e)gq8e#xe4yG&@Xrc$UniVp`6VN> z%q#`eDc9~n&6XB+ezdI#_L0xeIpaIXgKZyGO>z(%PGA~de?9iQZEQOZW47M_;p}EP zD+v^fKPGUL_o1^vM))dmPPzvG+M0*h{k7ZRT26a?m6>#LQV;8 zjNDQ_!-r*|Yg*bKJn@ICwIhIv`w`sWzlFN=%s0fU<$=G~_fxkKHj24lVNHD$8G2>0 zvKK-xVq;}azILaB9QRVn3_F{H^6R<3NH|>2icX_`VUcn$$|EggWsUd&DY^MrX{_wA zbv=XSq86x7H}l-DZ1)p*wQ7Xv_O&Fb{0lcM@>(DqDA{gFw5b9mcYLD&uQA&gR4l4D z!ZyE%Af(aPM!fLp|$yF{Zfv}A+86u%qMYV4kf)52sePB)PIyUF5 zlT^XyE>%Wfvmes-hD>LIMDu);RK)}75-!lSx9PY)RPcZ~>XyiFqDhUH_mAY>d1xs0 z&)-0`5r$st_7#rj?~RD!3(SsHvmx6(v)5Mdz9bgS+3zqzc-VC=-ZPYZ!Wn)|E-NCkQU|$A8mU(gUkYD6Yp?|B_?RjPyvraehn7>6c76D7T`7sy|8l;w$f3 z2V-ZH_aRk;d@XkaX?p!gMo!_cryoUN@DVh~AT}W5kSi+hvv>0u)zgQKZXw+7ekr&A zP2WaW(}{h9T|rcuGQ-_)M#|!~p&Rnm1U^(Ok8`hyEEYOw+Q=BGD4LzGD(AYX3x>>3 z9>2P}2v4+MK1enXZNCzp=wwOMKrJh|4`;Fb;4BSb)^-`TA;KZ)-*_Vts&d^+JGHipp*L;clo*pq}HEP{!nRuejs`EyqEEH0$XMn`^XdY^~p*q z(n`c~rB&ODQ%n4mho<7u(V~^%hX5B3*F;Ab$QsIzY98*ptzfKq$3Fv~cGI@>$VxAd zTB>cJOht?!s1V*1+<2K2T7M84rE}vjML8gEuG6z=hfKk5_SS#!T?lMVH@LvG*jk!A zv{>%E!mYWT^!B3n)9W4Mm_je;T4RFM0gRFrt9w|tz}Dtzsnzi!T9aZTYTV{usand_ zJW=&-+PhbX%IyTN%R3>RdBGu!9&o?J8!Q)i4D9B;%F()y%@u2O%9|}Vzg_Fn+=+J1!6X&`^ zD?R>|qvhUDq)Npc_sxo<_bcs0>K6j>8zg8EroWZ<|NJAOu7O zZrVn9knCf`QD0~d#jMfUxmAWFW>I~=H$>HR@;yGy@ z9-pp`rZ4@-c&hkm_aEbu1D(@%(EqFWQRYtZ_(oxV@)XhJ4Dxa@^+GhshW(BOF;iZV_CU&iPkz_tYhS^TE`NuaB=YjlL0VV- zA`RYyT;)TF%kqT7te}9-28NBhJQfr?p3L~}b~a62$*6`5-tDJ}?HtS`=byDn4Zyy= z1oa&GxY?ozY7PTm8KRA$#8-#Fy)+F1+ria7SiI|7wuLZUz?#r*Ej6pof%H@sn0<;s zxcgmH3p3Z$vy?~!3Z%>Em)7aKw1+PHvCRHYZAT3kIg5lPuT)~zhY}ZLJgqlh@q7ps zE16`T7g!rr-n|)ZMCPaNHVcn@y9$K+4mxo#2tkIK#AtM>zWN0-^)OJqnl^EaykR!F zc@pq?>_qC}e{u3}vW}N$+ph{WF9nr84EuW%D2zEwdHG8nJ$}8xuiQuwPXQ)9V(WnK zbV`>pVx8%A3S|^4j-(!js+cloH?O+a8TXjEtnS>{Xm1ZR^8bCHzFSvTc(>#WtvIj7 zakEJu!qL!gL@_3u@y-AJ@u7PxhjXNIz9X&Ed$wCT_iVig z&G7n=#9LEgDiw|Qpr(0@L;Z^KcRcAse;Z4<|2={W9A5Owkb3`P6_dxH^z-S%vQ4|b zg1}$K=p!6)Z~%Gn?qSy`t*olQ!_o!E&J=lphz9&pm3+hwd$QUm52Fu19R6pAF|#05 z2;SNgJkioh9JF%vMLTyDxi5UTKn3STGdwn{=5velJcA8y@-^GM5WAwKm8n^m}lpnueE^>}NQ zhBvLK{dH;CY`Y^l*j7K;RoLJc!pSjmys{cg-j1&j(7?5>#<7pI$hv^f+q60uy?!DW z1o5Y2srsJh1I(QpvD46k!zeLc(nEvy$)q!_!6adh^nzLV%}6@|I6jZ6>ZM#?S~ zD17wkomceW7+lx}&RIr>@9dlge{y1Ur3k_UF^&P`NPkj`u*!?sZ5!e9MOyL;>hV-1 z+`DMs?~BWodoJFFlv<|kn0 z<4KrS@9ppCb@0kvNjwLnuC`uOozAqRE*(O&AOLS-P~U$PB^|}L>46c1zLDF+;qml3 zOB@`#IWMh#xw+@johKaK`OK!7ZL=DPd3?K`w+d&DeS{3OcM{GCm3j*wyh_O(tsOp1 z&(qqIdo^!U;B0Z2^i2noUsgxe(AnOm4AXMFlr;;sBCGgqspxzBv1B@UvE<}J3J2P$k|-*xHF zO$f=1z-04`o=#fqsJ0YsT@Ete+{>g`@^b>4pUX4J<4rC+{d+}rDL)`L)y3S6*eKz| z7`DUHsi5y70p{r8CQ4lZW(YuYhQfVnl*MF0&l2e%YsneOZZJp^0NPsI$yK9N%+&)T zBLAy2!J2be4IpjT3Z{~)U=T3?fYau*;1Gm)+yOAxEd5MfI>lVoTiy<;(Ula7EP8JS z%N?=T@8^V#Vt}s6EFz2H!*)Q|9>%fiKF}aWppRlOKb599B+C#$ZJ(7r=S_lBQUSnJ z1Tr!WJ{)-~`?6d;9U2q?JU)V|cfAAJ1yI{uJ%<>5#RQfC`1!U~ z0vgxbRuMp}@9a(b5isa&10aqUif}c+@Ove-g=JVI2pAX;{3S44YJ7m?AA8e&KA1Nk zxrbp_RfOM;?7i}y!X_++5X=supns_WNb=um>VWF!hs6M<2$P4HLWAzzQr=z0sSimY zi$J9qJoZ#;&VPl literal 0 HcmV?d00001 From 1be2bf0bf628068fc69c38db633cae6b7fc3d8fa Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Tue, 30 Dec 2025 22:36:45 +0100 Subject: [PATCH 15/53] chore: temporary folder rename to enforce case change --- src/WebExpress.WebCore/{WebRestAPI => WebRestApi_tmp}/IRestApi.cs | 0 .../{WebRestAPI => WebRestApi_tmp}/IRestApiContext.cs | 0 .../{WebRestAPI => WebRestApi_tmp}/IRestApiManager.cs | 0 .../{WebRestApi => WebRestApi_tmp}/IRestApiPaginationInfo.cs | 0 .../{WebRestApi => WebRestApi_tmp}/IRestApiResult.cs | 0 .../{WebRestAPI => WebRestApi_tmp}/IRestApiValidationResult.cs | 0 .../{WebRestAPI => WebRestApi_tmp}/Model/RestApiDictionary.cs | 0 .../{WebRestAPI => WebRestApi_tmp}/Model/RestApiItem.cs | 0 .../{WebRestAPI => WebRestApi_tmp}/RestApiContext.cs | 0 .../{WebRestApi => WebRestApi_tmp}/RestApiError.cs | 0 .../{WebRestAPI => WebRestApi_tmp}/RestApiManager.cs | 0 .../{WebRestApi => WebRestApi_tmp}/RestApiPaginationInfo.cs | 0 .../{WebRestApi => WebRestApi_tmp}/RestApiValidationResult.cs | 0 .../{WebRestApi => WebRestApi_tmp}/RestApiValidator.cs | 0 14 files changed, 0 insertions(+), 0 deletions(-) rename src/WebExpress.WebCore/{WebRestAPI => WebRestApi_tmp}/IRestApi.cs (100%) rename src/WebExpress.WebCore/{WebRestAPI => WebRestApi_tmp}/IRestApiContext.cs (100%) rename src/WebExpress.WebCore/{WebRestAPI => WebRestApi_tmp}/IRestApiManager.cs (100%) rename src/WebExpress.WebCore/{WebRestApi => WebRestApi_tmp}/IRestApiPaginationInfo.cs (100%) rename src/WebExpress.WebCore/{WebRestApi => WebRestApi_tmp}/IRestApiResult.cs (100%) rename src/WebExpress.WebCore/{WebRestAPI => WebRestApi_tmp}/IRestApiValidationResult.cs (100%) rename src/WebExpress.WebCore/{WebRestAPI => WebRestApi_tmp}/Model/RestApiDictionary.cs (100%) rename src/WebExpress.WebCore/{WebRestAPI => WebRestApi_tmp}/Model/RestApiItem.cs (100%) rename src/WebExpress.WebCore/{WebRestAPI => WebRestApi_tmp}/RestApiContext.cs (100%) rename src/WebExpress.WebCore/{WebRestApi => WebRestApi_tmp}/RestApiError.cs (100%) rename src/WebExpress.WebCore/{WebRestAPI => WebRestApi_tmp}/RestApiManager.cs (100%) rename src/WebExpress.WebCore/{WebRestApi => WebRestApi_tmp}/RestApiPaginationInfo.cs (100%) rename src/WebExpress.WebCore/{WebRestApi => WebRestApi_tmp}/RestApiValidationResult.cs (100%) rename src/WebExpress.WebCore/{WebRestApi => WebRestApi_tmp}/RestApiValidator.cs (100%) diff --git a/src/WebExpress.WebCore/WebRestAPI/IRestApi.cs b/src/WebExpress.WebCore/WebRestApi_tmp/IRestApi.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestAPI/IRestApi.cs rename to src/WebExpress.WebCore/WebRestApi_tmp/IRestApi.cs diff --git a/src/WebExpress.WebCore/WebRestAPI/IRestApiContext.cs b/src/WebExpress.WebCore/WebRestApi_tmp/IRestApiContext.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestAPI/IRestApiContext.cs rename to src/WebExpress.WebCore/WebRestApi_tmp/IRestApiContext.cs diff --git a/src/WebExpress.WebCore/WebRestAPI/IRestApiManager.cs b/src/WebExpress.WebCore/WebRestApi_tmp/IRestApiManager.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestAPI/IRestApiManager.cs rename to src/WebExpress.WebCore/WebRestApi_tmp/IRestApiManager.cs diff --git a/src/WebExpress.WebCore/WebRestApi/IRestApiPaginationInfo.cs b/src/WebExpress.WebCore/WebRestApi_tmp/IRestApiPaginationInfo.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi/IRestApiPaginationInfo.cs rename to src/WebExpress.WebCore/WebRestApi_tmp/IRestApiPaginationInfo.cs diff --git a/src/WebExpress.WebCore/WebRestApi/IRestApiResult.cs b/src/WebExpress.WebCore/WebRestApi_tmp/IRestApiResult.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi/IRestApiResult.cs rename to src/WebExpress.WebCore/WebRestApi_tmp/IRestApiResult.cs diff --git a/src/WebExpress.WebCore/WebRestAPI/IRestApiValidationResult.cs b/src/WebExpress.WebCore/WebRestApi_tmp/IRestApiValidationResult.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestAPI/IRestApiValidationResult.cs rename to src/WebExpress.WebCore/WebRestApi_tmp/IRestApiValidationResult.cs diff --git a/src/WebExpress.WebCore/WebRestAPI/Model/RestApiDictionary.cs b/src/WebExpress.WebCore/WebRestApi_tmp/Model/RestApiDictionary.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestAPI/Model/RestApiDictionary.cs rename to src/WebExpress.WebCore/WebRestApi_tmp/Model/RestApiDictionary.cs diff --git a/src/WebExpress.WebCore/WebRestAPI/Model/RestApiItem.cs b/src/WebExpress.WebCore/WebRestApi_tmp/Model/RestApiItem.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestAPI/Model/RestApiItem.cs rename to src/WebExpress.WebCore/WebRestApi_tmp/Model/RestApiItem.cs diff --git a/src/WebExpress.WebCore/WebRestAPI/RestApiContext.cs b/src/WebExpress.WebCore/WebRestApi_tmp/RestApiContext.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestAPI/RestApiContext.cs rename to src/WebExpress.WebCore/WebRestApi_tmp/RestApiContext.cs diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiError.cs b/src/WebExpress.WebCore/WebRestApi_tmp/RestApiError.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi/RestApiError.cs rename to src/WebExpress.WebCore/WebRestApi_tmp/RestApiError.cs diff --git a/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs b/src/WebExpress.WebCore/WebRestApi_tmp/RestApiManager.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs rename to src/WebExpress.WebCore/WebRestApi_tmp/RestApiManager.cs diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiPaginationInfo.cs b/src/WebExpress.WebCore/WebRestApi_tmp/RestApiPaginationInfo.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi/RestApiPaginationInfo.cs rename to src/WebExpress.WebCore/WebRestApi_tmp/RestApiPaginationInfo.cs diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs b/src/WebExpress.WebCore/WebRestApi_tmp/RestApiValidationResult.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs rename to src/WebExpress.WebCore/WebRestApi_tmp/RestApiValidationResult.cs diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs b/src/WebExpress.WebCore/WebRestApi_tmp/RestApiValidator.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs rename to src/WebExpress.WebCore/WebRestApi_tmp/RestApiValidator.cs From 97a0f1b0a860fa05b5a8543dcaab9f7d9aded9cd Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Tue, 30 Dec 2025 22:37:46 +0100 Subject: [PATCH 16/53] chore: temporary folder rename to enforce case change --- src/WebExpress.WebCore/{WebRestApi_tmp => WebRestApi}/IRestApi.cs | 0 .../{WebRestApi_tmp => WebRestApi}/IRestApiContext.cs | 0 .../{WebRestApi_tmp => WebRestApi}/IRestApiManager.cs | 0 .../{WebRestApi_tmp => WebRestApi}/IRestApiPaginationInfo.cs | 0 .../{WebRestApi_tmp => WebRestApi}/IRestApiResult.cs | 0 .../{WebRestApi_tmp => WebRestApi}/IRestApiValidationResult.cs | 0 .../{WebRestApi_tmp => WebRestApi}/Model/RestApiDictionary.cs | 0 .../{WebRestApi_tmp => WebRestApi}/Model/RestApiItem.cs | 0 .../{WebRestApi_tmp => WebRestApi}/RestApiContext.cs | 0 .../{WebRestApi_tmp => WebRestApi}/RestApiError.cs | 0 .../{WebRestApi_tmp => WebRestApi}/RestApiManager.cs | 0 .../{WebRestApi_tmp => WebRestApi}/RestApiPaginationInfo.cs | 0 .../{WebRestApi_tmp => WebRestApi}/RestApiValidationResult.cs | 0 .../{WebRestApi_tmp => WebRestApi}/RestApiValidator.cs | 0 14 files changed, 0 insertions(+), 0 deletions(-) rename src/WebExpress.WebCore/{WebRestApi_tmp => WebRestApi}/IRestApi.cs (100%) rename src/WebExpress.WebCore/{WebRestApi_tmp => WebRestApi}/IRestApiContext.cs (100%) rename src/WebExpress.WebCore/{WebRestApi_tmp => WebRestApi}/IRestApiManager.cs (100%) rename src/WebExpress.WebCore/{WebRestApi_tmp => WebRestApi}/IRestApiPaginationInfo.cs (100%) rename src/WebExpress.WebCore/{WebRestApi_tmp => WebRestApi}/IRestApiResult.cs (100%) rename src/WebExpress.WebCore/{WebRestApi_tmp => WebRestApi}/IRestApiValidationResult.cs (100%) rename src/WebExpress.WebCore/{WebRestApi_tmp => WebRestApi}/Model/RestApiDictionary.cs (100%) rename src/WebExpress.WebCore/{WebRestApi_tmp => WebRestApi}/Model/RestApiItem.cs (100%) rename src/WebExpress.WebCore/{WebRestApi_tmp => WebRestApi}/RestApiContext.cs (100%) rename src/WebExpress.WebCore/{WebRestApi_tmp => WebRestApi}/RestApiError.cs (100%) rename src/WebExpress.WebCore/{WebRestApi_tmp => WebRestApi}/RestApiManager.cs (100%) rename src/WebExpress.WebCore/{WebRestApi_tmp => WebRestApi}/RestApiPaginationInfo.cs (100%) rename src/WebExpress.WebCore/{WebRestApi_tmp => WebRestApi}/RestApiValidationResult.cs (100%) rename src/WebExpress.WebCore/{WebRestApi_tmp => WebRestApi}/RestApiValidator.cs (100%) diff --git a/src/WebExpress.WebCore/WebRestApi_tmp/IRestApi.cs b/src/WebExpress.WebCore/WebRestApi/IRestApi.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi_tmp/IRestApi.cs rename to src/WebExpress.WebCore/WebRestApi/IRestApi.cs diff --git a/src/WebExpress.WebCore/WebRestApi_tmp/IRestApiContext.cs b/src/WebExpress.WebCore/WebRestApi/IRestApiContext.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi_tmp/IRestApiContext.cs rename to src/WebExpress.WebCore/WebRestApi/IRestApiContext.cs diff --git a/src/WebExpress.WebCore/WebRestApi_tmp/IRestApiManager.cs b/src/WebExpress.WebCore/WebRestApi/IRestApiManager.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi_tmp/IRestApiManager.cs rename to src/WebExpress.WebCore/WebRestApi/IRestApiManager.cs diff --git a/src/WebExpress.WebCore/WebRestApi_tmp/IRestApiPaginationInfo.cs b/src/WebExpress.WebCore/WebRestApi/IRestApiPaginationInfo.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi_tmp/IRestApiPaginationInfo.cs rename to src/WebExpress.WebCore/WebRestApi/IRestApiPaginationInfo.cs diff --git a/src/WebExpress.WebCore/WebRestApi_tmp/IRestApiResult.cs b/src/WebExpress.WebCore/WebRestApi/IRestApiResult.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi_tmp/IRestApiResult.cs rename to src/WebExpress.WebCore/WebRestApi/IRestApiResult.cs diff --git a/src/WebExpress.WebCore/WebRestApi_tmp/IRestApiValidationResult.cs b/src/WebExpress.WebCore/WebRestApi/IRestApiValidationResult.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi_tmp/IRestApiValidationResult.cs rename to src/WebExpress.WebCore/WebRestApi/IRestApiValidationResult.cs diff --git a/src/WebExpress.WebCore/WebRestApi_tmp/Model/RestApiDictionary.cs b/src/WebExpress.WebCore/WebRestApi/Model/RestApiDictionary.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi_tmp/Model/RestApiDictionary.cs rename to src/WebExpress.WebCore/WebRestApi/Model/RestApiDictionary.cs diff --git a/src/WebExpress.WebCore/WebRestApi_tmp/Model/RestApiItem.cs b/src/WebExpress.WebCore/WebRestApi/Model/RestApiItem.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi_tmp/Model/RestApiItem.cs rename to src/WebExpress.WebCore/WebRestApi/Model/RestApiItem.cs diff --git a/src/WebExpress.WebCore/WebRestApi_tmp/RestApiContext.cs b/src/WebExpress.WebCore/WebRestApi/RestApiContext.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi_tmp/RestApiContext.cs rename to src/WebExpress.WebCore/WebRestApi/RestApiContext.cs diff --git a/src/WebExpress.WebCore/WebRestApi_tmp/RestApiError.cs b/src/WebExpress.WebCore/WebRestApi/RestApiError.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi_tmp/RestApiError.cs rename to src/WebExpress.WebCore/WebRestApi/RestApiError.cs diff --git a/src/WebExpress.WebCore/WebRestApi_tmp/RestApiManager.cs b/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi_tmp/RestApiManager.cs rename to src/WebExpress.WebCore/WebRestApi/RestApiManager.cs diff --git a/src/WebExpress.WebCore/WebRestApi_tmp/RestApiPaginationInfo.cs b/src/WebExpress.WebCore/WebRestApi/RestApiPaginationInfo.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi_tmp/RestApiPaginationInfo.cs rename to src/WebExpress.WebCore/WebRestApi/RestApiPaginationInfo.cs diff --git a/src/WebExpress.WebCore/WebRestApi_tmp/RestApiValidationResult.cs b/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi_tmp/RestApiValidationResult.cs rename to src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs diff --git a/src/WebExpress.WebCore/WebRestApi_tmp/RestApiValidator.cs b/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs similarity index 100% rename from src/WebExpress.WebCore/WebRestApi_tmp/RestApiValidator.cs rename to src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs From c013699f75ea60a3306973ecdd3234d4af6cdc87 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Wed, 31 Dec 2025 15:09:22 +0100 Subject: [PATCH 17/53] feat: general improvements and minor bugs --- src/WebExpress.WebCore.Test/TestSocketA.cs | 62 +---- src/WebExpress.WebCore/HttpServer.cs | 1 - .../WebAttribute/MessageTypeAttribute.cs | 2 +- .../WebSocket/ISocketConnection.cs | 48 ++++ .../WebSocket/ISocketContext.cs | 1 - src/WebExpress.WebCore/WebSocket/ISockt.cs | 32 +-- .../WebSocket/Model/SocketItem.cs | 1 - .../WebSocket/Protocol/ISocketMessage.cs | 57 ----- .../WebSocket/Protocol/ISocketReadStream.cs | 67 ------ .../WebSocket/Protocol/ISocketWriteStream.cs | 34 --- .../WebSocket/Protocol/SocketMessageBinary.cs | 80 ------- .../WebSocket/Protocol/SocketMessageText.cs | 95 -------- .../WebSocket/Protocol/SocketReadStream.cs | 128 ---------- .../Protocol/SocketReadStreamExtensions.cs | 122 ---------- .../WebSocket/Protocol/SocketWriteStream.cs | 68 ------ .../Protocol/SocketWriteStreamExtensions.cs | 43 ---- .../{Protocol => }/SocketCloseInfo.cs | 2 +- .../WebSocket/SocketConnection.cs | 220 ++++++++++++++++++ .../WebSocket/SocketContext.cs | 1 - .../SocketHandshakeException.cs | 2 +- .../WebSocket/SocketManager.cs | 103 ++------ .../SocketMessageTooLargeException.cs | 2 +- .../{Protocol => }/SocketMessageType.cs | 2 +- .../{Protocol => }/SocketReceiveResult.cs | 2 +- 24 files changed, 293 insertions(+), 882 deletions(-) create mode 100644 src/WebExpress.WebCore/WebSocket/ISocketConnection.cs delete mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/ISocketMessage.cs delete mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/ISocketReadStream.cs delete mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/ISocketWriteStream.cs delete mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageBinary.cs delete mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageText.cs delete mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStream.cs delete mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStreamExtensions.cs delete mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStream.cs delete mode 100644 src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStreamExtensions.cs rename src/WebExpress.WebCore/WebSocket/{Protocol => }/SocketCloseInfo.cs (96%) create mode 100644 src/WebExpress.WebCore/WebSocket/SocketConnection.cs rename src/WebExpress.WebCore/WebSocket/{Protocol => }/SocketHandshakeException.cs (96%) rename src/WebExpress.WebCore/WebSocket/{Protocol => }/SocketMessageTooLargeException.cs (98%) rename src/WebExpress.WebCore/WebSocket/{Protocol => }/SocketMessageType.cs (98%) rename src/WebExpress.WebCore/WebSocket/{Protocol => }/SocketReceiveResult.cs (96%) diff --git a/src/WebExpress.WebCore.Test/TestSocketA.cs b/src/WebExpress.WebCore.Test/TestSocketA.cs index 02b1b35..e3f70d5 100644 --- a/src/WebExpress.WebCore.Test/TestSocketA.cs +++ b/src/WebExpress.WebCore.Test/TestSocketA.cs @@ -1,5 +1,4 @@ using WebExpress.WebCore.WebSocket; -using WebExpress.WebCore.WebSocket.Protocol; namespace WebExpress.WebCore.Test { @@ -9,7 +8,6 @@ namespace WebExpress.WebCore.Test public sealed class TestSocketA : ISocket { private readonly ISocketContext _socketContext; - private readonly ISocketWriteStream _stream; /// /// Initializes a new instance of the TestSocketA class using the specified @@ -19,74 +17,22 @@ public sealed class TestSocketA : ISocket /// The socket context that manages the state and configuration for the socket /// connection. /// - /// - /// The write stream used to send data through the socket. Cannot be null. - /// - public TestSocketA(ISocketContext socketContext, ISocketWriteStream stream) + /// The connection id. + public TestSocketA(ISocketContext socketContext, Guid connectionId) { _socketContext = socketContext ?? throw new ArgumentNullException(nameof(socketContext), "Parameter cannot be null or empty."); - _stream = stream ?? throw new ArgumentNullException(nameof(stream), "Parameter cannot be null or empty."); } /// /// Handles logic to be executed when a new connection is established with the /// socket server. /// - /// - /// An optional message containing information about the connection request. May be - /// null if no message is provided. - /// - /// - /// A cancellation token that can be used to cancel the asynchronous operation. - /// + /// The socket connection. /// /// A task that represents the asynchronous operation. /// - public async Task OnConnectedAsync(ISocketMessage connectMessage = null, CancellationToken cancellationToken = default) - { - } - - /// - /// Handles an incoming socket message asynchronously. - /// - /// - /// The message received from the socket to be processed. Cannot be null. - /// - /// - /// A cancellation token that can be used to cancel the asynchronous operation. - /// - /// - /// A task that represents the asynchronous message handling operation. - /// - public async Task OnReceiveAsync(ISocketMessage message, CancellationToken cancellationToken = default) - { - } - - /// - /// Handles logic to be executed when a socket connection is disconnected. - /// - /// - /// Information about the reason and context for the socket disconnection. - /// - /// - /// A task that represents the asynchronous operation. - /// - public async Task OnDisconnectedAsync(SocketCloseInfo closeInfo) - { - } - - /// - /// Handles an error that has occurred during asynchronous processing. - /// - /// - /// The exception that represents the error to handle. Cannot be null. - /// - /// - /// A task that represents the asynchronous error handling operation. - /// - public async Task OnErrorAsync(Exception exception) + public async Task OnConnectedAsync(ISocketConnection socketConnection) { - throw new NotImplementedException(); } /// diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index a8cf310..cc16a58 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -23,7 +23,6 @@ using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebSitemap; using WebExpress.WebCore.WebSocket; -using WebExpress.WebCore.WebSocket.Protocol; using WebExpress.WebCore.WebStatusPage; using WebExpress.WebCore.WebUri; diff --git a/src/WebExpress.WebCore/WebAttribute/MessageTypeAttribute.cs b/src/WebExpress.WebCore/WebAttribute/MessageTypeAttribute.cs index 8dfb6d9..dd8f0ae 100644 --- a/src/WebExpress.WebCore/WebAttribute/MessageTypeAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/MessageTypeAttribute.cs @@ -1,5 +1,5 @@ using System; -using WebExpress.WebCore.WebSocket.Protocol; +using WebExpress.WebCore.WebSocket; namespace WebExpress.WebCore.WebAttribute { diff --git a/src/WebExpress.WebCore/WebSocket/ISocketConnection.cs b/src/WebExpress.WebCore/WebSocket/ISocketConnection.cs new file mode 100644 index 0000000..00b3b71 --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/ISocketConnection.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// Defines the contract for a socket connection that supports sending and receiving both text and binary messages. + /// + public interface ISocketConnection : IDisposable + { + /// + /// Raised when a text message is received. + /// + event Action TextMessageReceived; + + /// + /// Raised when a binary message is received. + /// + event Action BinaryMessageReceived; + + /// + /// Raised when the WebSocket connection is closed or aborted. + /// + event Action Disconnected; + + /// + /// Sends a text message (UTF-8) over the socket connection. + /// + /// The text message to send. + /// Optional cancellation token. + Task SendTextAsync(string message, CancellationToken cancellation = default); + + /// + /// Sends binary data over the socket connection. + /// + /// The binary data to send. + /// Optional cancellation token. + Task SendBinaryAsync(byte[] data, CancellationToken cancellation = default); + + /// + /// Closes the socket connection gracefully. + /// + /// The reason for closing the connection. + /// Optional cancellation token. + Task CloseAsync(string reason = "closed", CancellationToken cancellation = default); + } +} diff --git a/src/WebExpress.WebCore/WebSocket/ISocketContext.cs b/src/WebExpress.WebCore/WebSocket/ISocketContext.cs index 8734f06..f38984d 100644 --- a/src/WebExpress.WebCore/WebSocket/ISocketContext.cs +++ b/src/WebExpress.WebCore/WebSocket/ISocketContext.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Net.WebSockets; using WebExpress.WebCore.WebEndpoint; -using WebExpress.WebCore.WebSocket.Protocol; namespace WebExpress.WebCore.WebSocket { diff --git a/src/WebExpress.WebCore/WebSocket/ISockt.cs b/src/WebExpress.WebCore/WebSocket/ISockt.cs index 4744451..532c651 100644 --- a/src/WebExpress.WebCore/WebSocket/ISockt.cs +++ b/src/WebExpress.WebCore/WebSocket/ISockt.cs @@ -1,8 +1,6 @@ using System; -using System.Threading; using System.Threading.Tasks; using WebExpress.WebCore.WebEndpoint; -using WebExpress.WebCore.WebSocket.Protocol; namespace WebExpress.WebCore.WebSocket { @@ -16,34 +14,8 @@ public interface ISocket : IEndpoint, IDisposable /// Implementers may use the optional cancellation token to abort long-running startup tasks. /// the optional connectMessage provides initial metadata from the client (may be null). /// - /// Optional initial message or metadata sent by the client during/after connect. - /// A token to cancel startup work. + /// The socket connection. /// An asynchronous task. - Task OnConnectedAsync(ISocketMessage connectMessage = null, CancellationToken cancellationToken = default); - - /// - /// Invoked for each received message (complete message or assembled fragments). - /// Implementations receive a parsed SocketMessage rather than raw byte buffers. - /// - /// The parsed message originated from the client. - /// Cancellation token for cooperative cancellation. - /// An asynchronous task. - Task OnReceiveAsync(ISocketMessage message, CancellationToken cancellationToken = default); - - /// - /// Invoked when the websocket connection is closed or is about to be closed. - /// Implementers should perform cleanup and release resources. - /// - /// Information about the socket closure. - /// An asynchronous task. - Task OnDisconnectedAsync(SocketCloseInfo closeInfo); - - /// - /// Invoked when an unhandled exception occurs during websocket processing. - /// Implementers should use this to log and perform cleanup. - /// - /// The exception that occurred. - /// An asynchronous task. - Task OnErrorAsync(Exception exception); + Task OnConnectedAsync(ISocketConnection socketConnection); } } \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs b/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs index 41283c7..6fc9de7 100644 --- a/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs +++ b/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs @@ -6,7 +6,6 @@ using WebExpress.WebCore.WebCondition; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebPlugin; -using WebExpress.WebCore.WebSocket.Protocol; namespace WebExpress.WebCore.WebSocket.Model { diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/ISocketMessage.cs b/src/WebExpress.WebCore/WebSocket/Protocol/ISocketMessage.cs deleted file mode 100644 index 7ba9ee4..0000000 --- a/src/WebExpress.WebCore/WebSocket/Protocol/ISocketMessage.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace WebExpress.WebCore.WebSocket.Protocol -{ - /// - /// Base interface for structured WebSocket messages exchanged between client and - /// server. Contains routing metadata common to all message types. - /// - public interface ISocketMessage - { - /// - /// Application-defined message type used for routing. - /// - string Type { get; } - - /// - /// The message identifier for deduplication or request/response correlation. - /// - string MessageId { get; } - - /// - /// The application id this payload belongs to, if applicable. - /// - string ApplicationId { get; } - - /// - /// The socket id (endpoint id) this payload targets or originates from. - /// - string SocketId { get; } - - /// - /// The connection id assigned by the socket manager on registration. - /// - string ConnectionId { get; } - - /// - /// Optional sender identifier. - /// - string Sender { get; } - - /// - /// Optional list of target identifiers. - /// - IEnumerable Targets { get; } - - /// - /// Timestamp in UTC when the message was created. - /// - DateTime Timestamp { get; } - - /// - /// Arbitrary metadata as key/value pairs. - /// - IDictionary Meta { get; } - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/ISocketReadStream.cs b/src/WebExpress.WebCore/WebSocket/Protocol/ISocketReadStream.cs deleted file mode 100644 index bf4fb3e..0000000 --- a/src/WebExpress.WebCore/WebSocket/Protocol/ISocketReadStream.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Net.WebSockets; -using System.Threading; -using System.Threading.Tasks; - -namespace WebExpress.WebCore.WebSocket.Protocol -{ - /// - /// Represents an asynchronous read-only stream abstraction for receiving - /// WebSocket message data in one or more frames using the native - /// WebExpress WebSocket protocol. - /// - public interface ISocketReadStream : IAsyncDisposable - { - /// - /// Returns the context associated with the underlying socket connection. - /// - ISocketContext SocketContext { get; } - - /// - /// Returns the unique identifier for the current connection. - /// - string ConnectionId { get; } - - /// - /// Reads a chunk of data from the underlying WebSocket transport. - /// This method does not assume the message is complete; callers may - /// invoke it multiple times to receive a fragmented message. - /// - /// - /// The buffer into which the received data will be written. - /// - /// - /// A token to observe while waiting for the operation to complete. - /// - /// - /// A describing the number of bytes read, - /// whether the message has ended, and the message type. - /// - Task ReadAsync - ( - ArraySegment buffer, - CancellationToken cancellationToken = default - ); - - /// - /// Signals that the current message has been fully consumed. - /// After calling this method, no further reads for the current - /// message are allowed. - /// - Task CompleteAsync(CancellationToken cancellationToken = default); - - /// - /// Asynchronously closes the underlying WebSocket connection. - /// - /// The close status code. - /// An optional description for the closure. - /// A token to monitor for cancellation requests. - /// A task that represents the asynchronous close operation. - Task CloseAsync - ( - WebSocketCloseStatus status = WebSocketCloseStatus.NormalClosure, - string description = null, - CancellationToken cancellationToken = default - ); - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/ISocketWriteStream.cs b/src/WebExpress.WebCore/WebSocket/Protocol/ISocketWriteStream.cs deleted file mode 100644 index f74f4d1..0000000 --- a/src/WebExpress.WebCore/WebSocket/Protocol/ISocketWriteStream.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace WebExpress.WebCore.WebSocket.Protocol -{ - /// - /// Represents an asynchronous write-only stream abstraction for sending - /// WebSocket message data in one or more frames. - /// - public interface ISocketWriteStream : IAsyncDisposable - { - /// - /// Writes a chunk of data to the underlying WebSocket transport. - /// This method does not finalize the message; callers may invoke it - /// multiple times to send a message in fragments. - /// - /// The data buffer to write. - /// - /// A token to observe while waiting for the operation to complete. - /// - Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default); - - /// - /// Completes the message by sending the final WebSocket frame with - /// endOfMessage set to true. - /// After calling this method, no further writes are allowed. - /// - /// - /// A token to observe while waiting for the operation to complete. - /// - Task CompleteAsync(CancellationToken cancellationToken = default); - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageBinary.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageBinary.cs deleted file mode 100644 index 8740412..0000000 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageBinary.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace WebExpress.WebCore.WebSocket.Protocol -{ - /// - /// Represents a WebSocket message containing binary payload. - /// Implements and provides routing metadata - /// together with binary content. - /// - public class SocketMessageBinary : ISocketMessage - { - /// - /// Returns the type identifier associated with the current instance. - /// - public string Type { get; init; } - - /// - /// The message identifier for deduplication or request/response correlation. - /// - public string MessageId { get; set; } - - /// - /// The application id this payload belongs to, if applicable. - /// - public string ApplicationId { get; set; } - - /// - /// The socket id (endpoint id) this payload targets or originates from. - /// - public string SocketId { get; set; } - - /// - /// The connection id assigned by the socket manager on registration. - /// - public string ConnectionId { get; set; } - - /// - /// Returns the identifier of the sender associated with this message. - /// - public string Sender { get; init; } - - /// - /// Returns the collection of target identifiers associated with this instance. - /// - public IEnumerable Targets { get; init; } - - /// - /// Returns the date and time when the object was created or last updated, - /// in Coordinated Universal Time (UTC). - /// - public DateTime Timestamp { get; init; } = DateTime.UtcNow; - - /// - /// Returns a collection of key-value pairs that provide additional - /// metadata associated with the object. - /// - public IDictionary Meta { get; init; } - = new Dictionary(StringComparer.OrdinalIgnoreCase); - - /// - /// The binary payload of the message. - /// Automatically Base64-encoded by System.Text.Json. - /// - public byte[] Data { get; init; } - - /// - /// Creates a new binary message with the specified routing type and payload. - /// - public static SocketMessageBinary Create(string type, byte[] data) - { - return new SocketMessageBinary - { - Type = type, - Data = data, - Timestamp = DateTime.UtcNow - }; - } - } -} diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageText.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageText.cs deleted file mode 100644 index fbe88ed..0000000 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageText.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace WebExpress.WebCore.WebSocket.Protocol -{ - /// - /// Represents a WebSocket message containing UTF-8 text payload. - /// Implements and provides routing metadata - /// together with human-readable content. - /// - public class SocketMessageText : ISocketMessage - { - private static readonly JsonSerializerOptions _serializeOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - /// - /// Application-defined message type used for routing. - /// - public string Type { get; init; } - - /// - /// The message identifier for deduplication or request/response correlation. - /// - public string MessageId { get; set; } - - /// - /// The application id this payload belongs to, if applicable. - /// - public string ApplicationId { get; set; } - - /// - /// The socket id (endpoint id) this payload targets or originates from. - /// - public string SocketId { get; set; } - - /// - /// The connection id assigned by the socket manager on registration. - /// - public string ConnectionId { get; set; } - - /// - /// Returns the identifier of the sender associated with this message. - /// - public string Sender { get; init; } - - /// - /// Returns the collection of target identifiers associated with this instance. - /// - public IEnumerable Targets { get; init; } - - /// - /// Returns the date and time, in Coordinated Universal Time (UTC), - /// when the object was created or last updated. - /// - public DateTime Timestamp { get; init; } = DateTime.UtcNow; - - /// - /// Returns a collection of key-value pairs that provide additional metadata - /// associated with the object. - /// - public IDictionary Meta { get; init; } - = new Dictionary(StringComparer.OrdinalIgnoreCase); - - /// - /// The UTF-8 text payload of the message. - /// - public string Text { get; init; } - - /// - /// Converts the current object to its JSON string representation. - /// - public string ToJson() - { - return JsonSerializer.Serialize(this, _serializeOptions); - } - - /// - /// Creates a new text message with the specified routing type and payload. - /// - public static SocketMessageText Create(string type, string text) - { - return new SocketMessageText - { - Type = type, - Text = text, - Timestamp = DateTime.UtcNow - }; - } - } -} diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStream.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStream.cs deleted file mode 100644 index e31f8d0..0000000 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStream.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System; -using System.Net.WebSockets; -using System.Threading; -using System.Threading.Tasks; - -namespace WebExpress.WebCore.WebSocket.Protocol -{ - /// - /// A binary-oriented implementation of , - /// exposing incoming WebSocket message data as raw byte segments using - /// the RFC 6455 WebSocket protocol. - /// - public sealed class SocketReadStream : ISocketReadStream - { - private readonly System.Net.WebSockets.WebSocket _webSocket; - private readonly ISocketContext _socketContext; - private readonly string _connectionId; - - private WebSocketReceiveResult _lastResult = null; - - /// - /// Returns the context associated with the underlying socket connection. - /// - public ISocketContext SocketContext => _socketContext; - - /// - /// Returns the unique identifier for the current connection. - /// - public string ConnectionId => _connectionId; - - /// - /// Initializes a new instance of the SocketReadStream class for reading data - /// from a WebSocket connection. - /// - /// The WebSocket instance. - /// The logical socket context. - /// The connection identifier. - public SocketReadStream(System.Net.WebSockets.WebSocket webSocket, ISocketContext socketContext, string connectionId) - { - _webSocket = webSocket ?? throw new ArgumentNullException(nameof(webSocket)); - _socketContext = socketContext; - _connectionId = connectionId; - } - - /// - /// Reads a chunk of data from the underlying WebSocket transport. - /// Supports fragmented messages by returning partial payload segments. - /// - /// The buffer receiving the data. - /// The cancellation token for the async read operation. - /// A result indicating the bytes read and message boundaries. - public async Task ReadAsync - ( - ArraySegment buffer, - CancellationToken cancellationToken = default - ) - { - // reads data from the WebSocket instance into the provided buffer - _lastResult = await _webSocket.ReceiveAsync(buffer, cancellationToken); - - return new SocketReceiveResult( - count: _lastResult.Count, - endOfMessage: _lastResult.EndOfMessage, - messageType: _lastResult.MessageType switch - { - WebSocketMessageType.Binary => SocketMessageType.Binary, - WebSocketMessageType.Text => SocketMessageType.Text, - WebSocketMessageType.Close => SocketMessageType.Close, - _ => SocketMessageType.Binary - } - ); - } - - /// - /// Marks the current message as fully consumed. - /// For the standard WebSocket protocol, this is a no-op. - /// - /// The cancellation token (unused). - public Task CompleteAsync(CancellationToken cancellationToken = default) - { - return Task.CompletedTask; - } - - /// - /// Asynchronously closes the underlying WebSocket connection. - /// - /// The close status code. - /// An optional description for the closure. - /// A token to monitor for cancellation requests. - /// A task that represents the asynchronous close operation. - public async Task CloseAsync - ( - WebSocketCloseStatus status = WebSocketCloseStatus.NormalClosure, - string description = null, - CancellationToken cancellationToken = default - ) - { - await _webSocket.CloseAsync( - closeStatus: status, - statusDescription: description, - cancellationToken: cancellationToken - ); - } - - /// - /// Performs cleanup operations for the read stream. - /// - /// A value task indicating the stream was disposed. - public async ValueTask DisposeAsync() - { - try - { - if (_webSocket != null && _webSocket.State != WebSocketState.Closed && _webSocket.State != WebSocketState.Aborted) - { - await _webSocket.CloseAsync( - WebSocketCloseStatus.NormalClosure, - "disposing", - CancellationToken.None - ); - } - } - catch - { - // socket already closed or broken – ignore - } - } - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStreamExtensions.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStreamExtensions.cs deleted file mode 100644 index ef6ceb4..0000000 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketReadStreamExtensions.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System.IO; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace WebExpress.WebCore.WebSocket.Protocol -{ - /// - /// Provides extension methods for reading instances - /// from an using the native WebExpress WebSocket protocol. - /// - public static class SocketReadStreamExtensions - { - /// - /// Reads a complete WebSocket message from the stream and deserializes it - /// into a instance. - /// - /// The source read stream. - /// A token to observe while waiting for the operation to complete. - /// The deserialized message. - public static async Task ReadMessageAsync( - this ISocketReadStream stream, - CancellationToken cancellationToken = default) - { - ulong totalBytes = 0; - using var buffer = new MemoryStream(); - var temp = new byte[4096]; - - var messageType = SocketMessageType.Text; - var maxSize = stream.SocketContext?.MaxMessageSize ?? ulong.MinValue; - - while (true) - { - var result = await stream.ReadAsync(temp, cancellationToken) - .ConfigureAwait(false); - - totalBytes += (ulong)result.Count; - - if (maxSize > ulong.MinValue && totalBytes > maxSize) - { - throw new SocketMessageTooLargeException(totalBytes, maxSize); - } - - if (result.Count == 0) - { - break; // end of message - } - - buffer.Write(temp, 0, result.Count); - messageType = result.MessageType; - } - - await stream.CompleteAsync(cancellationToken); - - return ParseMessage(stream, messageType, buffer); - } - - /// - /// Parses a WebSocket message from the provided buffer and stream, - /// returning a strongly typed socket message instance based on the - /// message type and content. - /// - private static ISocketMessage ParseMessage( - ISocketReadStream stream, - SocketMessageType messageType, - MemoryStream buffer) - { - buffer.Seek(0, SeekOrigin.Begin); - - if (messageType == SocketMessageType.Text) - { - var text = Encoding.UTF8.GetString(buffer.ToArray()); - ISocketMessage parsed = null; - - try - { - using var doc = JsonDocument.Parse(text); - - if (doc.RootElement.TryGetProperty("text", out _)) - { - parsed = JsonSerializer.Deserialize(text); - } - else if (doc.RootElement.TryGetProperty("data", out _)) - { - parsed = JsonSerializer.Deserialize(text); - } - else - { - parsed = JsonSerializer.Deserialize(text); - } - } - catch - { - parsed = new SocketMessageText - { - Type = null, - Text = text, - ConnectionId = stream.ConnectionId, - ApplicationId = stream.SocketContext?.ApplicationContext?.ApplicationId, - SocketId = stream.SocketContext?.EndpointId?.ToString() - }; - } - - return parsed; - } - else // binary - { - var bytes = buffer.ToArray(); - - return new SocketMessageBinary - { - Type = null, - Data = bytes, - ConnectionId = stream.ConnectionId, - ApplicationId = stream.SocketContext?.ApplicationContext?.ApplicationId, - SocketId = stream.SocketContext?.EndpointId?.ToString() - }; - } - } - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStream.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStream.cs deleted file mode 100644 index 99245a9..0000000 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStream.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace WebExpress.WebCore.WebSocket.Protocol -{ - /// - /// Provides a write stream abstraction for sending WebSocket messages - /// using the native WebExpress WebSocket protocol implementation. - /// - public class SocketWriteStream : ISocketWriteStream - { - private readonly System.Net.WebSockets.WebSocket _socket; - private readonly SocketMessageType _messageType; - - /// - /// Initializes a new instance of the class. - /// - /// The underlying native web socket connection. - /// The message type (text or binary). - public SocketWriteStream(System.Net.WebSockets.WebSocket socket, SocketMessageType messageType) - { - _socket = socket; - _messageType = messageType; - } - - /// - /// Writes a chunk of data to the underlying web socket transport. - /// This method does not finalize the message; callers may invoke it - /// multiple times to send a message in fragments. - /// - /// The data buffer to write. - /// A token to observe while waiting for the operation to complete. - public async Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - //if (_messageType == SocketMessageType.Text) - //{ - // // Convert bytes to UTF8 text - // var text = System.Text.Encoding.UTF8.GetString(buffer.Span); - // await _socket.SendTextAsync(text); - //} - //else - //{ - // await _socket.SendBinaryAsync(buffer.ToArray()); - //} - } - - /// - /// Completes the message. For the native protocol implementation, - /// messages are finalized automatically, so this method performs no action. - /// - public Task CompleteAsync(CancellationToken cancellationToken = default) - { - // No explicit final frame required in the native protocol. - return Task.CompletedTask; - } - - /// - /// Performs application-defined tasks associated with freeing, releasing, - /// or resetting unmanaged resources asynchronously. - /// - public ValueTask DisposeAsync() - { - // No unmanaged resources to release. - return ValueTask.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStreamExtensions.cs b/src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStreamExtensions.cs deleted file mode 100644 index 7e726f2..0000000 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketWriteStreamExtensions.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace WebExpress.WebCore.WebSocket.Protocol -{ - /// - /// Provides extension methods for writing instances - /// to an . - /// - public static class SocketWriteStreamExtensions - { - /// - /// Serializes and writes the specified message to the stream and finalizes it. - /// - /// The target write stream. - /// The message to send. - /// A token to observe while waiting for the operation to complete. - public static async Task WriteMessageAsync - ( - this ISocketWriteStream stream, - ISocketMessage message, - CancellationToken cancellationToken = default - ) - { - if (message is SocketMessageBinary) - { - var binary = (message as SocketMessageBinary)?.Data ?? []; - await stream.WriteAsync(binary, cancellationToken); - } - else if (message is SocketMessageText textMessage) - { - { - var json = textMessage.ToJson(); - var bytes = Encoding.UTF8.GetBytes(json); - await stream.WriteAsync(bytes, cancellationToken); - } - - await stream.CompleteAsync(cancellationToken); - } - } - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseInfo.cs b/src/WebExpress.WebCore/WebSocket/SocketCloseInfo.cs similarity index 96% rename from src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseInfo.cs rename to src/WebExpress.WebCore/WebSocket/SocketCloseInfo.cs index dbb453d..6caa85f 100644 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketCloseInfo.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketCloseInfo.cs @@ -1,6 +1,6 @@ using System.Net.WebSockets; -namespace WebExpress.WebCore.WebSocket.Protocol +namespace WebExpress.WebCore.WebSocket { /// /// Represents information about the reason a socket connection was closed, diff --git a/src/WebExpress.WebCore/WebSocket/SocketConnection.cs b/src/WebExpress.WebCore/WebSocket/SocketConnection.cs new file mode 100644 index 0000000..f41b17c --- /dev/null +++ b/src/WebExpress.WebCore/WebSocket/SocketConnection.cs @@ -0,0 +1,220 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace WebExpress.WebCore.WebSocket +{ + /// + /// Encapsulates a System.Net.WebSockets.WebSocket instance and supports both text and + /// binary messages. + /// + internal class SocketConnection : ISocketConnection, IDisposable + { + private readonly System.Net.WebSockets.WebSocket _socket; + private readonly CancellationTokenSource _cts = new(); + private readonly int _bufferSize; + private bool _disconnectRaised; + + /// + /// Raised when a text message is received. + /// + public event Action TextMessageReceived; + + /// + /// Raised when a binary message is received. + /// + public event Action BinaryMessageReceived; + + /// + /// Raised when the WebSocket connection is closed or aborted. + /// + public event Action Disconnected; + + /// + /// Initializes a new instance using a raw network stream and the provided socket context. + /// + /// + /// The underlying network stream used to create the WebSocket instance. + /// + /// + /// Provides WebSocket configuration such as supported subprotocols. + /// + /// + /// The size of the internal receive buffer in bytes. The default is 8192. + /// + public SocketConnection(Stream networkStream, ISocketContext socketContext, int bufferSize = 8192) + { + var options = new WebSocketCreationOptions() + { + IsServer = true, + SubProtocol = socketContext.SupportedSubProtocols.Any() + ? string.Join(";", socketContext.SupportedSubProtocols) + : null + }; + + _socket = System.Net.WebSockets.WebSocket.CreateFromStream(networkStream, options) + ?? throw new ArgumentNullException(nameof(networkStream)); + + _bufferSize = bufferSize; + } + + /// + /// Sends a text message (UTF-8) over the WebSocket. + /// + /// The text message to send. + /// A token used to cancel the send operation. + /// + /// A task that represents the asynchronous send operation. + /// + public async Task SendTextAsync(string message, CancellationToken cancellation = default) + { + var buffer = Encoding.UTF8.GetBytes(message); + await _socket.SendAsync(buffer, WebSocketMessageType.Text, true, cancellation); + } + + /// + /// Sends binary data over the WebSocket. + /// + /// The binary payload to send. + /// A token used to cancel the send operation. + /// + /// A task that represents the asynchronous send operation. + /// + public async Task SendBinaryAsync(byte[] data, CancellationToken cancellation = default) + { + await _socket.SendAsync(data, WebSocketMessageType.Binary, true, cancellation); + } + + /// + /// Internal loop for receiving messages. Invokes the appropriate events for each message. + /// + /// + /// A task that represents the asynchronous receive loop. + /// + internal async Task ReceiveLoopAsync() + { + var buffer = new byte[_bufferSize]; + using var builder = new MemoryStream(); + + while (_socket.State == WebSocketState.Open && !_cts.IsCancellationRequested) + { + WebSocketReceiveResult result; + + try + { + result = await _socket.ReceiveAsync(buffer, _cts.Token); + } + catch + { + RaiseDisconnected(WebSocketCloseStatus.InternalServerError, "connection aborted"); + break; + } + + if (result.MessageType == WebSocketMessageType.Close) + { + RaiseDisconnected(result.CloseStatus, result.CloseStatusDescription); + break; + } + + // accumulate fragments + builder.Write(buffer, 0, result.Count); + + if (result.EndOfMessage) + { + if (result.MessageType == WebSocketMessageType.Text) + { + var message = Encoding.UTF8.GetString(builder.ToArray()); + TextMessageReceived?.Invoke(message); + } + else if (result.MessageType == WebSocketMessageType.Binary) + { + BinaryMessageReceived?.Invoke(builder.ToArray()); + } + + builder.SetLength(0); + } + } + } + + /// + /// Asynchronously closes the underlying WebSocket connection. + /// + /// + /// A textual description sent to the client as part of the close frame. + /// + /// + /// A token used to cancel the close operation. + /// + /// + /// A task that represents the asynchronous close operation. + /// + public async Task CloseAsync(string reason = "closed", CancellationToken cancellation = default) + { + _cts.Cancel(); + + if (_socket.State == WebSocketState.Open || _socket.State == WebSocketState.CloseReceived) + { + try + { + await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, cancellation); + } + catch + { + // ignore + } + } + + RaiseDisconnected(WebSocketCloseStatus.NormalClosure, reason); + } + + /// + /// Ensures the Disconnected event is raised exactly once. + /// + /// The WebSocket close status. + /// The reason for the disconnection. + private void RaiseDisconnected(WebSocketCloseStatus? status, string reason) + { + if (_disconnectRaised) + { + return; + } + + _disconnectRaised = true; + Disconnected?.Invoke(new SocketCloseInfo(status ?? WebSocketCloseStatus.Empty, reason)); + } + + /// + /// Releases all resources used by the socket. + /// + public void Dispose() + { + _cts.Cancel(); + + try + { + if (_socket.State == WebSocketState.Open) + { + _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "disposed", CancellationToken.None) + .Wait(50); + } + } + catch + { + // ignore + } + + RaiseDisconnected(WebSocketCloseStatus.NormalClosure, "disposed"); + + if (_socket is IDisposable d) + { + d.Dispose(); + } + + _cts.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/SocketContext.cs b/src/WebExpress.WebCore/WebSocket/SocketContext.cs index 01e9537..b61febb 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketContext.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketContext.cs @@ -6,7 +6,6 @@ using WebExpress.WebCore.WebCondition; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebPlugin; -using WebExpress.WebCore.WebSocket.Protocol; namespace WebExpress.WebCore.WebSocket { diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketHandshakeException.cs b/src/WebExpress.WebCore/WebSocket/SocketHandshakeException.cs similarity index 96% rename from src/WebExpress.WebCore/WebSocket/Protocol/SocketHandshakeException.cs rename to src/WebExpress.WebCore/WebSocket/SocketHandshakeException.cs index 8af6680..7718d81 100644 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketHandshakeException.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketHandshakeException.cs @@ -1,6 +1,6 @@ using System; -namespace WebExpress.WebCore.WebSocket.Protocol +namespace WebExpress.WebCore.WebSocket { /// /// Represents an error that occurs when a WebSocket handshake request diff --git a/src/WebExpress.WebCore/WebSocket/SocketManager.cs b/src/WebExpress.WebCore/WebSocket/SocketManager.cs index fb01018..628f121 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketManager.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketManager.cs @@ -5,10 +5,8 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; -using System.Net.WebSockets; using System.Security.Cryptography; using System.Text; -using System.Threading; using System.Threading.Tasks; using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebApplication; @@ -19,7 +17,6 @@ using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPlugin; using WebExpress.WebCore.WebSocket.Model; -using WebExpress.WebCore.WebSocket.Protocol; namespace WebExpress.WebCore.WebSocket { @@ -103,31 +100,20 @@ private SocketManager(IComponentHub componentHub, IHttpServerContext httpServerC /// public async Task HandleConnectionAsync(IHttpContext httpContext, ISocketContext socketContext) { - // generate a unique connection ID and set initial close description + // WebSocket handshake var connectionId = Guid.NewGuid(); - var connection = httpContext.Request.Header.Connection; - var upgrade = httpContext.Request.Header.Upgrade; var secWebSocketKey = httpContext.Request.Header.SecWebSocketKey; var secWebSocketAccept = ComputeWebSocketAcceptKey(secWebSocketKey); var headerFeatures = httpContext.Features.Get(); - headerFeatures.Headers.Append("Upgrade", upgrade); - headerFeatures.Headers.Append("Connection", connection); + headerFeatures.Headers.Append("Upgrade", "websocket"); + headerFeatures.Headers.Append("Connection", "Upgrade"); headerFeatures.Headers.Append("Sec-WebSocket-Accept", secWebSocketAccept); var upgradeFeature = httpContext.Features.Get() ?? throw new SocketHandshakeException("Upgrade feature not supported. WebSocket handshake aborted."); - var closeDescription = "closing"; - var options = new WebSocketCreationOptions() - { - IsServer = true, - SubProtocol = socketContext.SupportedSubProtocols.Any() - ? string.Join(";", socketContext.SupportedSubProtocols) - : null - }; - // perform protocol upgrade and obtain the raw network stream - var networkStream = default(Stream); + Stream networkStream; try { networkStream = await upgradeFeature.UpgradeAsync(); @@ -137,36 +123,15 @@ public async Task HandleConnectionAsync(IHttpContext httpContext, ISocketContext throw new SocketHandshakeException("WebSocket upgrade failed.", ex); } - // create WebSocket class using the raw stream - var webSocket = System.Net.WebSockets.WebSocket.CreateFromStream(networkStream, options); - - // create the ISocket application instance (application handler) - var instance = await CreateSocketInstance(connectionId, socketContext, webSocket); - - // notify user/application code of the new connection - try - { - await instance.OnConnectedAsync(); - } - catch - { - // ignore - } - - // receive loop: handle fragmented frames and large payloads - while (webSocket.State == WebSocketState.Open) - { - var stream = new SocketReadStream(webSocket, socketContext, connectionId.ToString()); - var message = await stream.ReadMessageAsync(CancellationToken.None); + // create application socket instance + var instance = CreateSocketInstance(connectionId, socketContext); + var socketConnection = new SocketConnection(networkStream, socketContext); - // dispatch - await DispatchMessage(instance, message); - } + await instance.OnConnectedAsync(socketConnection); - var closeInfo = new SocketCloseInfo(WebSocketCloseStatus.NormalClosure, closeDescription); + await socketConnection.ReceiveLoopAsync(); - // notify the handler/application about the disconnection - await instance.OnDisconnectedAsync(closeInfo); + instance.Dispose(); } /// @@ -254,22 +219,17 @@ public ISocketContext GetSocket(string applicationId, string socketId) /// /// The unique connection Id. /// The context used for socket creation. - /// The accepted native WebSocket connection. /// The created or cached endpoint instance. - private async Task CreateSocketInstance + private ISocket CreateSocketInstance ( Guid connectionId, - ISocketContext socketContext, - System.Net.WebSockets.WebSocket webSocket + ISocketContext socketContext ) { var resourceItem = _dictionary.GetSocketItem(socketContext); if (resourceItem is not null && resourceItem.Instance is null) { - ISocketWriteStream writeStream = new SocketWriteStream(webSocket, socketContext.MessageType); - ISocketReadStream readStream = new SocketReadStream(webSocket, socketContext, connectionId.ToString()); - var instance = ComponentActivator.CreateInstance ( resourceItem.SocketClass, @@ -277,10 +237,7 @@ System.Net.WebSockets.WebSocket webSocket _httpServerContext, _componentHub, socketContext.ApplicationContext, - connectionId, - writeStream, - readStream - + connectionId ); if (resourceItem.Cache) @@ -557,40 +514,6 @@ private void OnRemoveApplication(object sender, IApplicationContext applicationC Remove(applicationContext); } - /// - /// Dispatches the parsed to the socket handler implementation. - /// Invokes the receive callback and forwards any handler exceptions to the error callback. - /// - /// - /// The implementation responsible for processing the message. - /// - /// - /// The message to be delivered to the socket handler. - /// - /// - /// A task that represents the asynchronous dispatch operation. - /// - /// - /// Dispatches a parsed to the socket handler. - /// Invokes the receive callback and forwards any handler exceptions to the error callback. - /// - private static async Task DispatchMessage(ISocket instance, ISocketMessage message) - { - if (instance == null || message == null) - { - return; - } - - try - { - await instance.OnReceiveAsync(message); - } - catch (Exception ex) - { - await instance.OnErrorAsync(ex); - } - } - /// /// Computes the Sec-WebSocket-Accept header value from the client key. /// diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageTooLargeException.cs b/src/WebExpress.WebCore/WebSocket/SocketMessageTooLargeException.cs similarity index 98% rename from src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageTooLargeException.cs rename to src/WebExpress.WebCore/WebSocket/SocketMessageTooLargeException.cs index 6ac1e3c..010488a 100644 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageTooLargeException.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketMessageTooLargeException.cs @@ -1,7 +1,7 @@ using System; using System.Runtime.Serialization; -namespace WebExpress.WebCore.WebSocket.Protocol +namespace WebExpress.WebCore.WebSocket { /// /// Represents an error that occurs when an incoming WebSocket message diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageType.cs b/src/WebExpress.WebCore/WebSocket/SocketMessageType.cs similarity index 98% rename from src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageType.cs rename to src/WebExpress.WebCore/WebSocket/SocketMessageType.cs index 6b8722f..f3f8f88 100644 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketMessageType.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketMessageType.cs @@ -1,4 +1,4 @@ -namespace WebExpress.WebCore.WebSocket.Protocol +namespace WebExpress.WebCore.WebSocket { /// /// Defines the type of WebSocket message represented or sent diff --git a/src/WebExpress.WebCore/WebSocket/Protocol/SocketReceiveResult.cs b/src/WebExpress.WebCore/WebSocket/SocketReceiveResult.cs similarity index 96% rename from src/WebExpress.WebCore/WebSocket/Protocol/SocketReceiveResult.cs rename to src/WebExpress.WebCore/WebSocket/SocketReceiveResult.cs index 2e217f7..64576d3 100644 --- a/src/WebExpress.WebCore/WebSocket/Protocol/SocketReceiveResult.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketReceiveResult.cs @@ -1,4 +1,4 @@ -namespace WebExpress.WebCore.WebSocket.Protocol +namespace WebExpress.WebCore.WebSocket { /// /// Represents the result of a read operation on a WebSocket stream, From 5358129079400a0b7227caae343758d0290b79d1 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Thu, 1 Jan 2026 19:25:54 +0100 Subject: [PATCH 18/53] feat: improved message queue and minor bugs --- .../WebAttribute/DomainAttribute.cs | 28 ++++++++++++ .../WebComponent/ComponentActivator.cs | 4 ++ src/WebExpress.WebCore/WebDomain/IDomain.cs | 12 +++++ src/WebExpress.WebCore/WebMessage/IRequest.cs | 5 +++ src/WebExpress.WebCore/WebMessage/Request.cs | 5 +++ .../WebMessage/RequestWebSocket.cs | 5 +++ .../WebPage/IPageContext.cs | 7 +++ src/WebExpress.WebCore/WebPage/PageContext.cs | 7 +++ src/WebExpress.WebCore/WebPage/PageManager.cs | 6 +++ .../WebSettingPage/SettingPageManager.cs | 6 +++ .../WebSocket/ISocketContext.cs | 6 +-- .../WebSocket/Model/SocketItem.cs | 5 +-- .../WebSocket/SocketConnection.cs | 5 +-- .../WebSocket/SocketContext.cs | 7 ++- .../WebSocket/SocketManager.cs | 21 ++++++--- .../WebSocket/SocketReceiveResult.cs | 45 ------------------- 16 files changed, 107 insertions(+), 67 deletions(-) create mode 100644 src/WebExpress.WebCore/WebAttribute/DomainAttribute.cs create mode 100644 src/WebExpress.WebCore/WebDomain/IDomain.cs delete mode 100644 src/WebExpress.WebCore/WebSocket/SocketReceiveResult.cs diff --git a/src/WebExpress.WebCore/WebAttribute/DomainAttribute.cs b/src/WebExpress.WebCore/WebAttribute/DomainAttribute.cs new file mode 100644 index 0000000..19ad50e --- /dev/null +++ b/src/WebExpress.WebCore/WebAttribute/DomainAttribute.cs @@ -0,0 +1,28 @@ +using System; +using WebExpress.WebCore.WebDomain; + +namespace WebExpress.WebCore.WebAttribute +{ + /// + /// Specifies that the decorated class belongs to a particular domain. + /// Domains represent logical application areas such as workspaces, + /// modules or functional segments. Multiple domain attributes may be + /// applied to the same class to associate it with several domains. + /// + /// + /// The domain type implementing that describes + /// the logical area to which the class belongs. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public class DomainAttribute : Attribute, IPageAttribute, ISettingPageAttribute + where TDomain : class, IDomain + { + /// + /// Initializes a new instance of the class. + /// + public DomainAttribute() + { + + } + } +} diff --git a/src/WebExpress.WebCore/WebComponent/ComponentActivator.cs b/src/WebExpress.WebCore/WebComponent/ComponentActivator.cs index 7467187..036cc1e 100644 --- a/src/WebExpress.WebCore/WebComponent/ComponentActivator.cs +++ b/src/WebExpress.WebCore/WebComponent/ComponentActivator.cs @@ -354,6 +354,10 @@ public static TComponent CreateInstance(Type componentType ( parameter.ParameterType == typeof(IPageContext) && x.GetType().GetInterfaces().Any(x => x == typeof(IPageContext)) + ) || + ( + parameter.ParameterType == typeof(IRequest) && + x.GetType().GetInterfaces().Any(x => x == typeof(IRequest)) ) ) .FirstOrDefault() ?? null diff --git a/src/WebExpress.WebCore/WebDomain/IDomain.cs b/src/WebExpress.WebCore/WebDomain/IDomain.cs new file mode 100644 index 0000000..225d1de --- /dev/null +++ b/src/WebExpress.WebCore/WebDomain/IDomain.cs @@ -0,0 +1,12 @@ +namespace WebExpress.WebCore.WebDomain +{ + /// + /// Represents a logical application domain used to categorize or group + /// components, pages or functional areas within the system. Implementations + /// define domain-specific metadata or identifiers that can be used for + /// routing, filtering or contextual association. + /// + public interface IDomain + { + } +} diff --git a/src/WebExpress.WebCore/WebMessage/IRequest.cs b/src/WebExpress.WebCore/WebMessage/IRequest.cs index 4365900..8ce6325 100644 --- a/src/WebExpress.WebCore/WebMessage/IRequest.cs +++ b/src/WebExpress.WebCore/WebMessage/IRequest.cs @@ -72,6 +72,11 @@ public interface IRequest /// CultureInfo Culture { get; } + /// + /// Returns the collection of parameters associated with the request. + /// + IEnumerable Parameters { get; } + /// /// Adds several parameters. /// diff --git a/src/WebExpress.WebCore/WebMessage/Request.cs b/src/WebExpress.WebCore/WebMessage/Request.cs index e560f2f..2c72525 100644 --- a/src/WebExpress.WebCore/WebMessage/Request.cs +++ b/src/WebExpress.WebCore/WebMessage/Request.cs @@ -100,6 +100,11 @@ public CultureInfo Culture } } + /// + /// Returns the collection of parameters associated with the request. + /// + public IEnumerable Parameters => _param.Values; + /// /// Returns the content. /// diff --git a/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs b/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs index ca68018..4fa7c92 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs @@ -92,6 +92,11 @@ public CultureInfo Culture } } + /// + /// Returns the collection of parameters associated with the request. + /// + public IEnumerable Parameters => _param.Values; + /// /// Returns the current WebSocket message type. /// diff --git a/src/WebExpress.WebCore/WebPage/IPageContext.cs b/src/WebExpress.WebCore/WebPage/IPageContext.cs index 0644061..17fa145 100644 --- a/src/WebExpress.WebCore/WebPage/IPageContext.cs +++ b/src/WebExpress.WebCore/WebPage/IPageContext.cs @@ -26,5 +26,12 @@ public interface IPageContext : IEndpointContext /// determine whether content and how content should be displayed. /// IEnumerable Scopes { get; } + + /// + /// Returns the collection of domain types associated with the decorated element. + /// Domains represent logical application areas such as workspaces, modules + /// or functional segments and can be used for routing, filtering or contextual grouping. + /// + IEnumerable Domains { get; } } } diff --git a/src/WebExpress.WebCore/WebPage/PageContext.cs b/src/WebExpress.WebCore/WebPage/PageContext.cs index c921510..a7e3a11 100644 --- a/src/WebExpress.WebCore/WebPage/PageContext.cs +++ b/src/WebExpress.WebCore/WebPage/PageContext.cs @@ -32,6 +32,13 @@ public class PageContext : IPageContext /// public IEnumerable Scopes { get; internal set; } = []; + /// + /// Returns the collection of domain types associated with the decorated element. + /// Domains represent logical application areas such as workspaces, modules + /// or functional segments and can be used for routing, filtering or contextual grouping. + /// + public IEnumerable Domains { get; internal set; } = []; + /// /// Returns the conditions that must be met for the resource to be active. /// diff --git a/src/WebExpress.WebCore/WebPage/PageManager.cs b/src/WebExpress.WebCore/WebPage/PageManager.cs index 2491e47..6ca6f47 100644 --- a/src/WebExpress.WebCore/WebPage/PageManager.cs +++ b/src/WebExpress.WebCore/WebPage/PageManager.cs @@ -326,6 +326,7 @@ private void Register(IPluginContext pluginContext, IEnumerable(); var conditions = new List(); var cache = false; + var domains = new List(); var attributes = pageType.CustomAttributes .Where(x => !x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute)) && !x.AttributeType.GetInterfaces().Contains(typeof(IPageAttribute))); @@ -368,6 +369,10 @@ private void Register(IPluginContext pluginContext, IEnumerable).Name && customAttribute.AttributeType.Namespace == typeof(DomainAttribute<>).Namespace) + { + domains.Add(customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault()); + } } if (pageType.GetInterfaces().Where(x => x == typeof(IScope)).Any()) @@ -394,6 +399,7 @@ private void Register(IPluginContext pluginContext, IEnumerable(); var group = default(Type); + var domains = new List(); var section = SettingSection.Primary; var includeSubPaths = false; var hide = false; @@ -506,6 +507,10 @@ private void RegisterPage(IPluginContext pluginContext, IEnumerable).Name && customAttribute.AttributeType.Namespace == typeof(DomainAttribute<>).Namespace) + { + domains.Add(customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault()); + } } if (settingPageType.GetInterfaces().Where(x => x == typeof(IScope)).Any()) @@ -533,6 +538,7 @@ private void RegisterPage(IPluginContext pluginContext, IEnumerable - /// Returns the collection of supported websocket subprotocols. - /// implementations should return the subprotocol identifiers the endpoint can speak. + /// Returns the name of the WebSocket subprotocol that is supported by the connection. /// - IEnumerable SupportedSubProtocols { get; } + string SupportedSubProtocol { get; } /// /// Returns the default WebSocket message type used by this endpoint when diff --git a/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs b/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs index 6fc9de7..286bd87 100644 --- a/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs +++ b/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs @@ -35,10 +35,9 @@ internal class SocketItem : IDisposable public Type SocketClass { get; set; } /// - /// Returns the collection of supported websocket subprotocols. - /// implementations should return the subprotocol identifiers the endpoint can speak. + /// Returns the name of the WebSocket subprotocol that is supported by the connection. /// - public IEnumerable SupportedSubProtocols { get; set; } + public string SupportedSubProtocol { get; set; } /// /// Returns the default WebSocket message type used by this endpoint when diff --git a/src/WebExpress.WebCore/WebSocket/SocketConnection.cs b/src/WebExpress.WebCore/WebSocket/SocketConnection.cs index f41b17c..b102d16 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketConnection.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketConnection.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Linq; using System.Net.WebSockets; using System.Text; using System.Threading; @@ -51,9 +50,7 @@ public SocketConnection(Stream networkStream, ISocketContext socketContext, int var options = new WebSocketCreationOptions() { IsServer = true, - SubProtocol = socketContext.SupportedSubProtocols.Any() - ? string.Join(";", socketContext.SupportedSubProtocols) - : null + SubProtocol = socketContext.SupportedSubProtocol }; _socket = System.Net.WebSockets.WebSocket.CreateFromStream(networkStream, options) diff --git a/src/WebExpress.WebCore/WebSocket/SocketContext.cs b/src/WebExpress.WebCore/WebSocket/SocketContext.cs index b61febb..3959495 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketContext.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketContext.cs @@ -36,9 +36,9 @@ public class SocketContext : ISocketContext public IComponentId EndpointId { get; internal set; } /// - /// Collection of supported websocket subprotocols. + /// Returns the name of the WebSocket subprotocol that is supported by the connection. /// - public IEnumerable SupportedSubProtocols { get; set; } + public string SupportedSubProtocol { get; internal set; } /// /// Returns the default WebSocket message type used by this endpoint when @@ -92,8 +92,7 @@ public SocketContext() public override string ToString() { // return a compact representation with name and supported subprotocols - var protocols = SupportedSubProtocols != null ? string.Join(",", SupportedSubProtocols) : string.Empty; - return $"{EndpointId} (protocols: {protocols})"; + return $"{EndpointId} (protocols: {SupportedSubProtocol})"; } } } \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/SocketManager.cs b/src/WebExpress.WebCore/WebSocket/SocketManager.cs index 628f121..4b72b7c 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketManager.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketManager.cs @@ -109,6 +109,10 @@ public async Task HandleConnectionAsync(IHttpContext httpContext, ISocketContext headerFeatures.Headers.Append("Upgrade", "websocket"); headerFeatures.Headers.Append("Connection", "Upgrade"); headerFeatures.Headers.Append("Sec-WebSocket-Accept", secWebSocketAccept); + if (!string.IsNullOrWhiteSpace(socketContext.SupportedSubProtocol)) + { + headerFeatures.Headers.Append("Sec-WebSocket-Protocol", socketContext.SupportedSubProtocol); + } var upgradeFeature = httpContext.Features.Get() ?? throw new SocketHandshakeException("Upgrade feature not supported. WebSocket handshake aborted."); @@ -124,7 +128,7 @@ public async Task HandleConnectionAsync(IHttpContext httpContext, ISocketContext } // create application socket instance - var instance = CreateSocketInstance(connectionId, socketContext); + var instance = CreateSocketInstance(connectionId, socketContext, httpContext.Request); var socketConnection = new SocketConnection(networkStream, socketContext); await instance.OnConnectedAsync(socketConnection); @@ -219,11 +223,13 @@ public ISocketContext GetSocket(string applicationId, string socketId) /// /// The unique connection Id. /// The context used for socket creation. + /// The request. /// The created or cached endpoint instance. private ISocket CreateSocketInstance ( Guid connectionId, - ISocketContext socketContext + ISocketContext socketContext, + IRequest request ) { var resourceItem = _dictionary.GetSocketItem(socketContext); @@ -237,7 +243,8 @@ ISocketContext socketContext _httpServerContext, _componentHub, socketContext.ApplicationContext, - connectionId + connectionId, + request ); if (resourceItem.Cache) @@ -299,7 +306,7 @@ private void Register(IPluginContext pluginContext, IEnumerable(); var cache = false; - var subProtocols = new List(); + var subProtocol = ""; var messageType = SocketMessageType.Text; var maxMessageSize = ulong.MinValue; var attributes = socketType.CustomAttributes @@ -332,7 +339,7 @@ private void Register(IPluginContext pluginContext, IEnumerable - /// Represents the result of a read operation on a WebSocket stream, - /// containing the number of bytes read, the message type, and whether - /// the end of the message has been reached. - /// - public class SocketReceiveResult - { - /// - /// The number of bytes read into the provided buffer. - /// - public int Count { get; } - - /// - /// Indicates whether the end of the current WebSocket message has been reached. - /// - public bool EndOfMessage { get; } - - /// - /// The type of the WebSocket message (text, binary, close, ping, pong, continuation). - /// - public SocketMessageType MessageType { get; } - - /// - /// Initializes a new instance of the class with the specified number of - /// bytes received, end-of-message indicator, and message type. - /// - /// - /// The number of bytes received in the operation. - /// - /// - /// True if the received data marks the end of the message; otherwise, false. - /// - /// - /// The type of message received, indicating how the data should be interpreted. - /// - public SocketReceiveResult(int count, bool endOfMessage, SocketMessageType messageType) - { - Count = count; - EndOfMessage = endOfMessage; - MessageType = messageType; - } - } -} \ No newline at end of file From 558fc7306a8b2572293158f7debc00902f6b4986 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Fri, 2 Jan 2026 16:23:51 +0100 Subject: [PATCH 19/53] feat: general improvements and minor bugs --- .../WebSocket/SocketMessageType.cs | 89 +------------------ 1 file changed, 1 insertion(+), 88 deletions(-) diff --git a/src/WebExpress.WebCore/WebSocket/SocketMessageType.cs b/src/WebExpress.WebCore/WebSocket/SocketMessageType.cs index f3f8f88..b94cdbf 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketMessageType.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketMessageType.cs @@ -14,93 +14,6 @@ public enum SocketMessageType /// /// Specifies that the data is binary. /// - Binary, - - /// - /// Indicates a close control frame. - /// - Close, - - /// - /// Indicates a ping control frame. - /// - Ping, - - /// - /// Indicates a pong control frame. - /// - Pong, - - /// - /// Indicates a continuation frame for fragmented messages. - /// - Continuation - } - - /// - /// Provides helper and conversion methods for . - /// - public static class SocketMessageTypeExtensions - { - /// - /// Converts a WebSocket opcode into a . - /// - public static SocketMessageType FromOpcode(int opcode) - { - return opcode switch - { - 0x0 => SocketMessageType.Continuation, - 0x1 => SocketMessageType.Text, - 0x2 => SocketMessageType.Binary, - 0x8 => SocketMessageType.Close, - 0x9 => SocketMessageType.Ping, - 0xA => SocketMessageType.Pong, - _ => SocketMessageType.Binary // fallback - }; - } - - /// - /// Converts a into the corresponding WebSocket opcode. - /// - public static int ToOpcode(this SocketMessageType type) - { - return type switch - { - SocketMessageType.Continuation => 0x0, - SocketMessageType.Text => 0x1, - SocketMessageType.Binary => 0x2, - SocketMessageType.Close => 0x8, - SocketMessageType.Ping => 0x9, - SocketMessageType.Pong => 0xA, - _ => 0x2 - }; - } - - /// - /// Returns true if the message type represents a control frame. - /// - public static bool IsControl(this SocketMessageType type) - { - return type == SocketMessageType.Close - || type == SocketMessageType.Ping - || type == SocketMessageType.Pong; - } - - /// - /// Returns true if the message type represents a data frame (text or binary). - /// - public static bool IsData(this SocketMessageType type) - { - return type == SocketMessageType.Text - || type == SocketMessageType.Binary; - } - - /// - /// Returns true if the message type represents a continuation frame. - /// - public static bool IsContinuation(this SocketMessageType type) - { - return type == SocketMessageType.Continuation; - } + Binary } } \ No newline at end of file From 9daec52dce3066af5ea6cc8f344ac91804f866ba Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Fri, 2 Jan 2026 23:04:55 +0100 Subject: [PATCH 20/53] feat: general improvements and minor bugs --- src/WebExpress.WebCore/WebMessage/Request.cs | 271 +--------------- .../WebMessage/RequestBase.cs | 296 ++++++++++++++++++ .../WebMessage/RequestWebSocket.cs | 255 +-------------- .../WebSocket/ISocketContext.cs | 5 +- .../WebSocket/Model/SocketItem.cs | 5 +- .../WebSocket/SocketContext.cs | 5 +- 6 files changed, 309 insertions(+), 528 deletions(-) create mode 100644 src/WebExpress.WebCore/WebMessage/RequestBase.cs diff --git a/src/WebExpress.WebCore/WebMessage/Request.cs b/src/WebExpress.WebCore/WebMessage/Request.cs index 2c72525..449a8cf 100644 --- a/src/WebExpress.WebCore/WebMessage/Request.cs +++ b/src/WebExpress.WebCore/WebMessage/Request.cs @@ -1,17 +1,12 @@ using Microsoft.AspNetCore.Http.Features; using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; -using System.Net; using System.Text; using System.Text.RegularExpressions; -using System.Threading.Tasks; using WebExpress.WebCore.WebHtml; using WebExpress.WebCore.WebParameter; -using WebExpress.WebCore.WebSession.Model; -using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebMessage { @@ -19,92 +14,8 @@ namespace WebExpress.WebCore.WebMessage /// See RFC 2616, The Request class encapsulates and extends the /// original request of the HttpListener call. /// - public class Request : IRequest + public class Request : RequestBase { - private readonly ParameterDictionary _param = []; - - /// - /// The context of the web server. - /// - public IHttpServerContext HttpServerContext { get; protected set; } - - /// - /// Returns the request method (e.g. POST). - /// - public RequestMethod Method { get; private set; } - - /// - /// Returns the uri. - /// - public UriEndpoint Uri { get; set; } - - /// - /// Returns the session. - /// - public Session Session { get; private set; } - - /// - /// Returns the http version. - /// - public string Protocoll { get; private set; } - - /// - /// Returns the options from the header. - /// - public RequestHeaderFields Header { get; private set; } - - /// - /// Returns the ip address and port number of the server to which the request is made. - /// - public EndPoint LocalEndPoint { get; private set; } - - /// - /// Returns the ip address and port number of the client from which the request originated. - /// - public EndPoint RemoteEndPoint { get; private set; } - - /// - /// Returns a boolean value that indicates whether the tcp connection used to send the request uses the secure sockets layer (ssl) protocol. - /// - public bool IsSecureConnection { get; private set; } - - /// - /// Returns the shema. This can be http or https. - /// - public UriScheme Scheme { get; private set; } - - /// - /// Returns the request identifier of the incoming http request. - /// - public string RequestTraceIdentifier { get; private set; } - - /// - /// Returns the culture. - /// - public CultureInfo Culture - { - get - { - try - { - // see RFC 5646 - var languages = Header?.AcceptLanguage.FirstOrDefault(); - var language = languages?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); - - return new CultureInfo(language); - } - catch - { - return HttpServerContext.Culture ?? CultureInfo.CurrentCulture; - } - } - } - - /// - /// Returns the collection of parameters associated with the request. - /// - public IEnumerable Parameters => _param.Values; - /// /// Returns the content. /// @@ -117,59 +28,11 @@ public CultureInfo Culture /// The header. /// The context of the web server. internal Request(IFeatureCollection contextFeatures, RequestHeaderFields header, IHttpServerContext httpServerContext) + : base(contextFeatures, header, httpServerContext) { - var connectionFeature = contextFeatures.Get(); var requestFeature = contextFeatures.Get(); - var requestIdentifierFeature = contextFeatures.Get(); - //var sessionFeature = contextFeatures.Get(); - - HttpServerContext = httpServerContext; - RequestTraceIdentifier = requestIdentifierFeature.TraceIdentifier; - Protocoll = requestFeature.Protocol; - - Scheme = requestFeature.Scheme.ToLower() switch - { - "http" => UriScheme.Http, - "https" => UriScheme.Https, - "ftp" => UriScheme.FTP, - "file" => UriScheme.File, - "mailto" => UriScheme.Mailto, - "ldap" => UriScheme.Ldap, - _ => UriScheme.Http - - }; - Method = requestFeature.Method.ToUpper() switch - { - "GET" => RequestMethod.GET, - "POST" => RequestMethod.POST, - "PUT" => RequestMethod.PUT, - "DELETE" => RequestMethod.DELETE, - "HEAD" => RequestMethod.HEAD, - "PATCH" => RequestMethod.PATCH, - _ => RequestMethod.GET - }; - - Header = header; - - LocalEndPoint = new IPEndPoint(connectionFeature.LocalIpAddress, connectionFeature.LocalPort); - RemoteEndPoint = new IPEndPoint(connectionFeature.RemoteIpAddress, connectionFeature.RemotePort); - - Uri = new UriEndpoint - ( - Scheme, - new UriAuthority() - { - Host = Header.Host, - Port = connectionFeature.LocalPort - }, - requestFeature.RawTarget - ); Content = GetContent(requestFeature.Body, Header.ContentLength); - - ParseQueryParams(requestFeature.QueryString); - ParseRequestParams(); - ParseSessionParams(); } /// @@ -191,40 +54,10 @@ internal static byte[] GetContent(Stream body, long? contentLength) return ms.ToArray(); } - /// - /// Returns the parameters from the reuest query (for example, http://www.example.com?key=value). - /// - /// The query. - private void ParseQueryParams(string query) - { - query = query.TrimStart('?'); - - Parallel.ForEach(query.Split('&'), (param) => - { - if (!string.IsNullOrWhiteSpace(param)) - { - var split = param.Split('='); - - if (split.Length == 1) - { - AddParameter(new Parameter(split[0], null, ParameterScope.Parameter)); - } - else if (split.Length == 2) - { - AddParameter(new Parameter(split[0], split[1], ParameterScope.Parameter)); - } - else if (split.Length > 2) - { - AddParameter(new Parameter(split[0], string.Join("=", split.Skip(1)), ParameterScope.Parameter)); - } - } - }); - } - /// /// Parse the request parameters. /// - private void ParseRequestParams() + protected override void ParseRequestParams() { if (string.IsNullOrWhiteSpace(Header.ContentType)) { @@ -421,107 +254,9 @@ private void ParseRequestParams() } default: { - break; } } } - - /// - /// Parse the session parameters. - /// - private void ParseSessionParams() - { - Session = WebEx.ComponentHub?.SessionManager?.GetSession(this); - - var property = Session?.GetProperty(); - if (property is not null && property.Params is not null) - { - foreach (var param in property.Params) - { - AddParameter(new Parameter(param.Key?.ToLower(), param.Value.Value, ParameterScope.Session)); - } - } - } - - /// - /// Adds several parameters. - /// - /// The parameters. - public void AddParameter(IEnumerable param) - { - foreach (var p in param) - { - AddParameter(p); - } - } - - /// - /// Adds one parameter. - /// - /// The parameter. - public void AddParameter(Parameter param) - { - var key = param.Key.ToLower(); - - if (!_param.TryAdd(key, param)) - { - _param[key] = param; - } - } - - /// - /// Returns a parameter by name. - /// - /// The name of the parameter. - /// The value. - public IParameter GetParameter(string name) - { - if (!string.IsNullOrWhiteSpace(name) && HasParameter(name)) - { - return _param[name.ToLower()]; - } - - return null; - } - - /// - /// Returns a parameter by name. - /// - /// The parameter. - /// The value. - public IParameter GetParameter() - where TParameter : IParameter - { - var parameter = Parameter.GetParameter(); - - if (parameter is not null - && !string.IsNullOrWhiteSpace(parameter.Key) - && HasParameter(parameter.Key)) - { - var p = _param[parameter.Key.ToLower()]; - parameter.Value = p.Value; - parameter.Scope = p.Scope; - - return parameter; - } - - return null; - } - - /// - /// Checks whether a parameter exists. - /// - /// The name of the parameter. - /// True if parameters are present, false otherwise. - public bool HasParameter(string name) - { - if (name is null) - { - return false; - } - - return _param.ContainsKey(name.ToLower()); - } } } diff --git a/src/WebExpress.WebCore/WebMessage/RequestBase.cs b/src/WebExpress.WebCore/WebMessage/RequestBase.cs new file mode 100644 index 0000000..6db0cfa --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/RequestBase.cs @@ -0,0 +1,296 @@ +using Microsoft.AspNetCore.Http.Features; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using WebExpress.WebCore.WebParameter; +using WebExpress.WebCore.WebSession.Model; +using WebExpress.WebCore.WebUri; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// See RFC 2616, The Request class encapsulates and extends the + /// original request of the HttpListener call. + /// + public abstract class RequestBase : IRequest + { + private readonly ParameterDictionary _param = []; + + /// + /// The context of the web server. + /// + public IHttpServerContext HttpServerContext { get; protected set; } + + /// + /// Returns the request method (e.g. POST). + /// + public RequestMethod Method { get; private set; } + + /// + /// Returns the uri. + /// + public UriEndpoint Uri { get; set; } + + /// + /// Returns the session. + /// + public Session Session { get; private set; } + + /// + /// Returns the http version. + /// + public string Protocoll { get; private set; } + + /// + /// Returns the options from the header. + /// + public RequestHeaderFields Header { get; private set; } + + /// + /// Returns the ip address and port number of the server to which the request is made. + /// + public EndPoint LocalEndPoint { get; private set; } + + /// + /// Returns the ip address and port number of the client from which the request originated. + /// + public EndPoint RemoteEndPoint { get; private set; } + + /// + /// Returns a boolean value that indicates whether the tcp connection used to send the request uses the secure sockets layer (ssl) protocol. + /// + public bool IsSecureConnection { get; private set; } + + /// + /// Returns the shema. This can be http or https. + /// + public UriScheme Scheme { get; private set; } + + /// + /// Returns the request identifier of the incoming http request. + /// + public string RequestTraceIdentifier { get; private set; } + + /// + /// Returns the culture. + /// + public CultureInfo Culture + { + get + { + try + { + // see RFC 5646 + var languages = Header?.AcceptLanguage.FirstOrDefault(); + var language = languages?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); + + return new CultureInfo(language); + } + catch + { + return HttpServerContext.Culture ?? CultureInfo.CurrentCulture; + } + } + } + + /// + /// Returns the collection of parameters associated with the request. + /// + public IEnumerable Parameters => _param.Values; + + /// + /// Initializes a new instance of the class. + /// + /// Initial set of features. + /// The header. + /// The context of the web server. + internal RequestBase(IFeatureCollection contextFeatures, RequestHeaderFields header, IHttpServerContext httpServerContext) + { + var connectionFeature = contextFeatures.Get(); + var requestFeature = contextFeatures.Get(); + var requestIdentifierFeature = contextFeatures.Get(); + //var sessionFeature = contextFeatures.Get(); + + HttpServerContext = httpServerContext; + RequestTraceIdentifier = requestIdentifierFeature.TraceIdentifier; + Protocoll = requestFeature.Protocol; + + Scheme = requestFeature.Scheme.ToLower() switch + { + "http" => UriScheme.Http, + "https" => UriScheme.Https, + "ftp" => UriScheme.FTP, + "file" => UriScheme.File, + "mailto" => UriScheme.Mailto, + "ldap" => UriScheme.Ldap, + _ => UriScheme.Http + + }; + Method = requestFeature.Method.ToUpper() switch + { + "GET" => RequestMethod.GET, + "POST" => RequestMethod.POST, + "PUT" => RequestMethod.PUT, + "DELETE" => RequestMethod.DELETE, + "HEAD" => RequestMethod.HEAD, + "PATCH" => RequestMethod.PATCH, + _ => RequestMethod.GET + }; + + Header = header; + + LocalEndPoint = new IPEndPoint(connectionFeature.LocalIpAddress, connectionFeature.LocalPort); + RemoteEndPoint = new IPEndPoint(connectionFeature.RemoteIpAddress, connectionFeature.RemotePort); + + Uri = new UriEndpoint + ( + Scheme, + new UriAuthority() + { + Host = Header.Host, + Port = connectionFeature.LocalPort + }, + requestFeature.RawTarget + ); + + ParseQueryParams(requestFeature.QueryString); + ParseRequestParams(); + ParseSessionParams(); + } + + /// + /// Returns the parameters from the reuest query (for example, http://www.example.com?key=value). + /// + /// The query. + private void ParseQueryParams(string query) + { + query = query.TrimStart('?'); + + Parallel.ForEach(query.Split('&'), (param) => + { + if (!string.IsNullOrWhiteSpace(param)) + { + var split = param.Split('='); + + if (split.Length == 1) + { + AddParameter(new Parameter(split[0], null, ParameterScope.Parameter)); + } + else if (split.Length == 2) + { + AddParameter(new Parameter(split[0], split[1], ParameterScope.Parameter)); + } + else if (split.Length > 2) + { + AddParameter(new Parameter(split[0], string.Join("=", split.Skip(1)), ParameterScope.Parameter)); + } + } + }); + } + + /// + /// Parse the request parameters. + /// + protected abstract void ParseRequestParams(); + + /// + /// Parse the session parameters. + /// + private void ParseSessionParams() + { + Session = WebEx.ComponentHub?.SessionManager?.GetSession(this); + + var property = Session?.GetProperty(); + if (property is not null && property.Params is not null) + { + foreach (var param in property.Params) + { + AddParameter(new Parameter(param.Key?.ToLower(), param.Value.Value, ParameterScope.Session)); + } + } + } + + /// + /// Adds several parameters. + /// + /// The parameters. + public void AddParameter(IEnumerable param) + { + foreach (var p in param) + { + AddParameter(p); + } + } + + /// + /// Adds one parameter. + /// + /// The parameter. + public void AddParameter(Parameter param) + { + var key = param.Key.ToLower(); + + if (!_param.TryAdd(key, param)) + { + _param[key] = param; + } + } + + /// + /// Returns a parameter by name. + /// + /// The name of the parameter. + /// The value. + public IParameter GetParameter(string name) + { + if (!string.IsNullOrWhiteSpace(name) && HasParameter(name)) + { + return _param[name.ToLower()]; + } + + return null; + } + + /// + /// Returns a parameter by name. + /// + /// The parameter. + /// The value. + public IParameter GetParameter() + where TParameter : IParameter + { + var parameter = Parameter.GetParameter(); + + if (parameter is not null + && !string.IsNullOrWhiteSpace(parameter.Key) + && HasParameter(parameter.Key)) + { + var p = _param[parameter.Key.ToLower()]; + parameter.Value = p.Value; + parameter.Scope = p.Scope; + + return parameter; + } + + return null; + } + + /// + /// Checks whether a parameter exists. + /// + /// The name of the parameter. + /// True if parameters are present, false otherwise. + public bool HasParameter(string name) + { + if (name is null) + { + return false; + } + + return _param.ContainsKey(name.ToLower()); + } + } +} diff --git a/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs b/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs index 4fa7c92..ff08293 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs @@ -1,112 +1,12 @@ using Microsoft.AspNetCore.Http.Features; -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Net; -using WebExpress.WebCore.WebParameter; -using WebExpress.WebCore.WebSession.Model; -using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebMessage { /// /// Represents a request for a WebSocket connection. /// - public class RequestWebSocket : IRequest + public class RequestWebSocket : RequestBase { - private readonly ParameterDictionary _param = []; - - /// - /// The context of the web server. - /// - public IHttpServerContext HttpServerContext { get; protected set; } - - /// - /// Returns the request method (typically GET for WebSocket handshake). - /// - public RequestMethod Method { get; private set; } - - /// - /// Returns the URI. - /// - public UriEndpoint Uri { get; set; } - - /// - /// Returns the session. - /// - public Session Session { get; private set; } - - /// - /// Returns the HTTP version. - /// - public string Protocoll { get; private set; } - - /// - /// Returns the header fields. - /// - public RequestHeaderFields Header { get; private set; } - - /// - /// Returns the server's local endpoint. - /// - public EndPoint LocalEndPoint { get; private set; } - - /// - /// Returns the client's remote endpoint. - /// - public EndPoint RemoteEndPoint { get; private set; } - - /// - /// Indicates whether the connection is secured (wss). - /// - public bool IsSecureConnection { get; private set; } - - /// - /// Returns the scheme (ws or wss). - /// - public UriScheme Scheme { get; private set; } - - /// - /// Returns the request identifier. - /// - public string RequestTraceIdentifier { get; private set; } - - /// - /// Returns the culture. - /// - public CultureInfo Culture - { - get - { - try - { - var languages = Header?.AcceptLanguage?.FirstOrDefault(); - var language = languages?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); - return new CultureInfo(language); - } - catch - { - return HttpServerContext.Culture ?? CultureInfo.CurrentCulture; - } - } - } - - /// - /// Returns the collection of parameters associated with the request. - /// - public IEnumerable Parameters => _param.Values; - - /// - /// Returns the current WebSocket message type. - /// - public string WebSocketMessageType { get; internal set; } - - /// - /// Returns true, if the WebSocket is open. - /// - public bool IsWebSocketOpen { get; internal set; } - /// /// Initializes a new instance for a WebSocket request. /// Use this after WebSocket handshake is established. @@ -115,162 +15,15 @@ public CultureInfo Culture /// The parsed header fields of the request. /// The context of the web server. internal RequestWebSocket(IFeatureCollection contextFeatures, RequestHeaderFields header, IHttpServerContext httpServerContext) + : base(contextFeatures, header, httpServerContext) { - HttpServerContext = httpServerContext; - Header = header; - - var connectionFeature = contextFeatures.Get(); - var requestFeature = contextFeatures.Get(); - - Method = RequestMethod.GET; // WebSocket handshake always uses GET - Protocoll = requestFeature.Protocol; - - Scheme = requestFeature.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) ? UriScheme.Wss : - requestFeature.Scheme.Equals("ws", StringComparison.OrdinalIgnoreCase) ? UriScheme.Ws : UriScheme.Http; - - LocalEndPoint = new IPEndPoint(connectionFeature.LocalIpAddress, connectionFeature.LocalPort); - RemoteEndPoint = new IPEndPoint(connectionFeature.RemoteIpAddress, connectionFeature.RemotePort); - RequestTraceIdentifier = connectionFeature.ConnectionId; - IsSecureConnection = Scheme == UriScheme.Wss; - - // build the uri-endpoint for WebSocket (assume raw target is path + query) - Uri = new UriEndpoint - ( - Scheme, - new UriAuthority() - { - Host = Header.Host, - Port = connectionFeature.LocalPort - }, - requestFeature.RawTarget - ); - - // WebSocket specific defaults - WebSocketMessageType = null; - IsWebSocketOpen = true; - - ParseSessionParams(); - } - - /// - /// Adds a collection of parameters to the current instance. - /// - /// - /// An enumerable collection of objects to add. Cannot be null. - /// - public void AddParameter(IEnumerable param) - { - foreach (var p in param) - { - AddParameter(p); - } } /// - /// Adds a parameter to the collection, replacing any existing parameter with the - /// same key (case-insensitive). + /// Parse the request parameters. /// - /// - /// The parameter to add to the collection. Cannot be null. The parameter's key - /// is used as the unique identifier. - /// - public void AddParameter(Parameter param) + protected override void ParseRequestParams() { - var key = param.Key.ToLower(); - - if (!_param.TryAdd(key, param)) - { - _param[key] = param; - } - } - - /// - /// Retrieves the parameter with the specified name, if it exists. - /// - /// - /// The name of the parameter to retrieve. Cannot be null, empty, or consist - /// only of white-space characters. The comparison is case-insensitive. - /// - /// - /// The parameter associated with the specified name, or null if no such parameter exists. - /// - public IParameter GetParameter(string name) - { - if (!string.IsNullOrWhiteSpace(name) && HasParameter(name)) - { - return _param[name.ToLower()]; - } - - return null; - } - - /// - /// Retrieves the parameter of the specified type from the current parameter - /// collection, if it exists. - /// - /// - /// The type of parameter to retrieve. Must implement the IParameter interface. - /// - /// - /// An instance of the specified parameter type with its value and scope set - /// if the parameter exists; otherwise, null. - /// - public IParameter GetParameter() - where TParameter : IParameter - { - var parameter = Parameter.GetParameter(); - if - ( - parameter is not null - && !string.IsNullOrWhiteSpace(parameter.Key) - && HasParameter(parameter.Key) - ) - { - var p = _param[parameter.Key.ToLower()]; - parameter.Value = p.Value; - parameter.Scope = p.Scope; - - return parameter; - } - - return null; - } - - /// - /// Determines whether a parameter with the specified name exists. - /// - /// - /// The name of the parameter to locate. The comparison is case-insensitive. Can be null. - /// - /// - /// True if a parameter with the specified name exists; otherwise, false. - /// - public bool HasParameter(string name) - { - if (name is null) - { - return false; - } - - return _param.ContainsKey(name.ToLower()); - } - - /// - /// Parses session parameters from the current session and adds them to - /// the parameter collection. - /// - private void ParseSessionParams() - { - Session = WebEx.ComponentHub?.SessionManager?.GetSession(this); - - var property = Session?.GetProperty(); - if (property is not null && property.Params is not null) - { - foreach (var param in property.Params) - { - AddParameter(new Parameter(param.Key?.ToLower(), param.Value.Value, ParameterScope.Session)); - } - } } } } \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebSocket/ISocketContext.cs b/src/WebExpress.WebCore/WebSocket/ISocketContext.cs index 6139400..2616e19 100644 --- a/src/WebExpress.WebCore/WebSocket/ISocketContext.cs +++ b/src/WebExpress.WebCore/WebSocket/ISocketContext.cs @@ -1,4 +1,3 @@ -using System.Net.WebSockets; using WebExpress.WebCore.WebEndpoint; namespace WebExpress.WebCore.WebSocket @@ -16,8 +15,8 @@ public interface ISocketContext : IEndpointContext /// /// Returns the default WebSocket message type used by this endpoint when - /// sending data. Implementations may choose - /// for JSON or human-readable content, or + /// sending data. Implementations may choose + /// for JSON or human-readable content, or /// for binary payloads. /// SocketMessageType MessageType { get; } diff --git a/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs b/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs index 286bd87..9e0d95c 100644 --- a/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs +++ b/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Net.WebSockets; using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebCondition; @@ -41,8 +40,8 @@ internal class SocketItem : IDisposable /// /// Returns the default WebSocket message type used by this endpoint when - /// sending data. Implementations may choose - /// for JSON or human-readable content, or + /// sending data. Implementations may choose + /// for JSON or human-readable content, or /// for binary payloads. /// public SocketMessageType MessageType { get; set; } diff --git a/src/WebExpress.WebCore/WebSocket/SocketContext.cs b/src/WebExpress.WebCore/WebSocket/SocketContext.cs index 3959495..5ea54f0 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketContext.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketContext.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Net.WebSockets; using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebCondition; @@ -42,8 +41,8 @@ public class SocketContext : ISocketContext /// /// Returns the default WebSocket message type used by this endpoint when - /// sending data. Implementations may choose - /// for JSON or human-readable content, or + /// sending data. Implementations may choose + /// for JSON or human-readable content, or /// for binary payloads. /// public SocketMessageType MessageType { get; set; } From 072d9f3834305f3bffa66300668c4db6555ce9c3 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sat, 3 Jan 2026 00:44:40 +0100 Subject: [PATCH 21/53] feat: improved crud api and minor bugs --- src/WebExpress.WebCore/WebRestApi/IRestApiResult.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WebExpress.WebCore/WebRestApi/IRestApiResult.cs b/src/WebExpress.WebCore/WebRestApi/IRestApiResult.cs index 2b80c6b..645e007 100644 --- a/src/WebExpress.WebCore/WebRestApi/IRestApiResult.cs +++ b/src/WebExpress.WebCore/WebRestApi/IRestApiResult.cs @@ -8,9 +8,9 @@ namespace WebExpress.WebCore.WebRestApi public interface IRestApiResult { /// - /// Converts the current instance into a object. + /// Converts the current instance into a object. /// /// A Response object representing the result of the conversion. - Response ToResponse(); + IResponse ToResponse(); } } \ No newline at end of file From 3602982b374d3c6675b81364cf4fe838cfde830c Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 4 Jan 2026 01:05:25 +0100 Subject: [PATCH 22/53] feat: general improvements and minor bugs --- .../WebParameter/ParameterGuid.cs | 36 +++++++++++++++++++ .../WebSocket/SocketManager.cs | 2 +- .../WebTheme/ThemeManager.cs | 2 +- 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 src/WebExpress.WebCore/WebParameter/ParameterGuid.cs diff --git a/src/WebExpress.WebCore/WebParameter/ParameterGuid.cs b/src/WebExpress.WebCore/WebParameter/ParameterGuid.cs new file mode 100644 index 0000000..28e36a2 --- /dev/null +++ b/src/WebExpress.WebCore/WebParameter/ParameterGuid.cs @@ -0,0 +1,36 @@ +using System; + +namespace WebExpress.WebCore.WebParameter +{ + /// + /// Represents a guid parameter. + /// + public class ParameterGuid : Parameter + { + /// + /// Initializes a new instance of the class. + /// + public ParameterGuid() + : base("Id", null, ParameterScope.Url) + { + } + + /// + /// Initializes a new instance of the class with a specified value. + /// + /// The value of the parameter. + public ParameterGuid(string value) + : base("Id", value, ParameterScope.Url) + { + } + + /// + /// Initializes a new instance of the class with a specified value. + /// + /// The value of the parameter. + public ParameterGuid(Guid value) + : base("Id", value.ToString(), ParameterScope.Url) + { + } + } +} diff --git a/src/WebExpress.WebCore/WebSocket/SocketManager.cs b/src/WebExpress.WebCore/WebSocket/SocketManager.cs index 4b72b7c..2d5a6ce 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketManager.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketManager.cs @@ -24,7 +24,7 @@ namespace WebExpress.WebCore.WebSocket /// The socket manager manages socket endpoints (see RFC 6455 – The WebSocket Protocol) /// which can be called with a URI. /// - public class SocketManager : ISocketManager, ISystemComponent + public sealed class SocketManager : ISocketManager, ISystemComponent { private const string _webSocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; private readonly IComponentHub _componentHub; diff --git a/src/WebExpress.WebCore/WebTheme/ThemeManager.cs b/src/WebExpress.WebCore/WebTheme/ThemeManager.cs index 7465bdf..02ac097 100644 --- a/src/WebExpress.WebCore/WebTheme/ThemeManager.cs +++ b/src/WebExpress.WebCore/WebTheme/ThemeManager.cs @@ -16,7 +16,7 @@ namespace WebExpress.WebCore.WebTheme /// /// Manages themes for the web application. /// - public class ThemeManager : IThemeManager + public class ThemeManager : IThemeManager, ISystemComponent { private readonly IComponentHub _componentHub; private readonly IHttpServerContext _httpServerContext; From f8630a7423569827f3f6bd3fdef03b2cd542256a Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 4 Jan 2026 15:47:21 +0100 Subject: [PATCH 23/53] feat: add tag template for rest table, general improvements and minor bugs --- src/WebExpress.WebCore/WebMessage/Request.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WebExpress.WebCore/WebMessage/Request.cs b/src/WebExpress.WebCore/WebMessage/Request.cs index 449a8cf..6b921f8 100644 --- a/src/WebExpress.WebCore/WebMessage/Request.cs +++ b/src/WebExpress.WebCore/WebMessage/Request.cs @@ -77,7 +77,7 @@ protected override void ParseRequestParams() var dispositions = new List>(); // Item1=position, Item2=size // determine dispositions - for (var i = 0; i < Content.Length; i++) + for (var i = 0; i < Content?.Length; i++) { if (Content[i] == '\r') { From 861c63e1f2f5459beaed6ed8627a2ec3befea3f2 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Mon, 12 Jan 2026 23:25:52 +0100 Subject: [PATCH 24/53] feat: general improvements and minor bugs --- .../WebAttribute/MethodAttribute.cs | 2 +- src/WebExpress.WebCore/WebHtml/TypeEnctype.cs | 34 +- src/WebExpress.WebCore/WebMessage/Request.cs | 476 +++++++++++------- .../WebMessage/RequestBase.cs | 6 - .../WebMessage/RequestWebSocket.cs | 7 - 5 files changed, 314 insertions(+), 211 deletions(-) diff --git a/src/WebExpress.WebCore/WebAttribute/MethodAttribute.cs b/src/WebExpress.WebCore/WebAttribute/MethodAttribute.cs index 26cf128..8c3f8b4 100644 --- a/src/WebExpress.WebCore/WebAttribute/MethodAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/MethodAttribute.cs @@ -8,7 +8,7 @@ namespace WebExpress.WebCore.WebAttribute /// is intended to handle. This attribute can be applied multiple times /// to the same method to declare support for multiple request methods. /// - [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] public class MethodAttribute : Attribute, IEndpointAttribute { /// diff --git a/src/WebExpress.WebCore/WebHtml/TypeEnctype.cs b/src/WebExpress.WebCore/WebHtml/TypeEnctype.cs index 8fad32e..04d6a4c 100644 --- a/src/WebExpress.WebCore/WebHtml/TypeEnctype.cs +++ b/src/WebExpress.WebCore/WebHtml/TypeEnctype.cs @@ -6,24 +6,30 @@ public enum TypeEnctype { /// - /// All characters are encoded (spaces are conferred to "+" and special characters in the hex representation). + /// All characters are encoded (spaces are converted to "+" and special characters to hex representation). /// UrLEncoded, /// - /// No characters will be encodes. Used when transferring files. + /// Multipart form data (used for file uploads and FormData). /// - None, + Multipart, /// /// Only space characters are encoded. /// Text, + /// + /// No characters will be encoded. + /// + None, + /// /// Not assignable. /// Default + } /// @@ -38,13 +44,25 @@ public static class TypeEnctypeExtensions /// The converted encoding. public static TypeEnctype Convert(string enctype) { - return (enctype?.ToLower()) switch + if (string.IsNullOrWhiteSpace(enctype)) + { + return TypeEnctype.Default; + } + + var ct = enctype.ToLowerInvariant(); + + if (ct.StartsWith("multipart/form-data")) + { + return TypeEnctype.Multipart; + } + + return ct switch { - "multipart/form-data" => TypeEnctype.None, "text/plain" => TypeEnctype.Text, "application/x-www-form-urlencoded" => TypeEnctype.UrLEncoded, _ => TypeEnctype.Default, }; + } /// @@ -56,9 +74,11 @@ public static string Convert(this TypeEnctype enctype) { return enctype switch { - TypeEnctype.None => "multipart/form-data", + TypeEnctype.Multipart => "multipart/form-data", TypeEnctype.Text => "text/plain", - _ => "application/x-www-form-urlencoded", + TypeEnctype.UrLEncoded => "application/x-www-form-urlencoded", + TypeEnctype.None => string.Empty, + _ => string.Empty }; } } diff --git a/src/WebExpress.WebCore/WebMessage/Request.cs b/src/WebExpress.WebCore/WebMessage/Request.cs index 6b921f8..1c2b422 100644 --- a/src/WebExpress.WebCore/WebMessage/Request.cs +++ b/src/WebExpress.WebCore/WebMessage/Request.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Http.Features; using System; -using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; @@ -14,8 +13,14 @@ namespace WebExpress.WebCore.WebMessage /// See RFC 2616, The Request class encapsulates and extends the /// original request of the HttpListener call. /// - public class Request : RequestBase + public partial class Request : RequestBase { + [GeneratedRegex(@"([\w-]+)=(.*)")] + private static partial Regex TextRegex(); + + [GeneratedRegex(@"Content-Type:\s*(.*)", RegexOptions.IgnoreCase, "de-DE")] + private static partial Regex ContentRegex(); + /// /// Returns the content. /// @@ -33,6 +38,8 @@ internal Request(IFeatureCollection contextFeatures, RequestHeaderFields header, var requestFeature = contextFeatures.Get(); Content = GetContent(requestFeature.Body, Header.ContentLength); + + ParseRequestParams(); } /// @@ -57,206 +64,295 @@ internal static byte[] GetContent(Stream body, long? contentLength) /// /// Parse the request parameters. /// - protected override void ParseRequestParams() + protected virtual void ParseRequestParams() { - if (string.IsNullOrWhiteSpace(Header.ContentType)) + if (string.IsNullOrWhiteSpace(Header.ContentType) || Content is null || Content.Length == 0) { return; } - var contentType = Header.ContentType?.Split(';'); + // normalize content-type + var ct = Header.ContentType.Split(';') + .Select(x => x.Trim()) + .ToArray(); - switch (TypeEnctypeExtensions.Convert(contentType.FirstOrDefault())) + var mainType = ct.FirstOrDefault()?.ToLowerInvariant(); + var enctype = TypeEnctypeExtensions.Convert(mainType); + + // detect multipart/form-data even if Convert() fails + if (mainType.StartsWith("multipart/form-data")) { - case TypeEnctype.None: - { - var boundary = Header.ContentType; - var boundaryValue = "--" + boundary?.Split('=').Skip(1)?.FirstOrDefault(); - var offset = 0; - int pos = 0; - var dispositions = new List>(); // Item1=position, Item2=size - - // determine dispositions - for (var i = 0; i < Content?.Length; i++) - { - if (Content[i] == '\r') - { - var c = Encoding.UTF8.GetString(Content, offset, boundaryValue.Length).Trim(); - if (c.StartsWith(boundaryValue)) - { - if (i - boundaryValue.Length - pos > 0) - { - dispositions.Add(new Tuple(pos, i - boundaryValue.Length - pos)); - } - - pos = i + 2; - - if (c.EndsWith("--")) - { - break; - } - } - } - else if (Content[i] == '\n') - { - offset = i + 1; - } - else if (i == Content.Length - 1) - { - // at the end - var c = Encoding.UTF8.GetString(Content, offset, boundaryValue.Length).Trim(); - if (c.StartsWith(boundaryValue)) - { - dispositions.Add(new Tuple(pos, i - boundaryValue.Length - pos)); - } - } - } - - foreach (var item in dispositions) - { - var disposition = string.Empty; - var name = string.Empty; - var filename = string.Empty; - var contenttype = string.Empty; - offset = 0; - - var str = Encoding.UTF8.GetString(Content, item.Item1, item.Item2 > 256 ? 256 : item.Item2); - var match = Regex.Match(str, @"^Content-Disposition: (.*)$", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled); - if (match.Groups[1].Success) - { - offset += match.Length + 1; // + Zeilenende - var dispositionParam = match.Groups[1].ToString().Split(';'); - disposition = dispositionParam.FirstOrDefault(); - foreach (var v in dispositionParam.Skip(1)) - { - match = Regex.Match(v.Trim(), @"^name=""(.*)""$", RegexOptions.IgnoreCase | RegexOptions.Compiled); - if (match.Groups[1].Success) - { - name = match.Groups[1].ToString(); - } - - match = Regex.Match(v.Trim(), @"^filename=""(.*)""$", RegexOptions.IgnoreCase | RegexOptions.Compiled); - if (match.Groups[1].Success) - { - filename = match.Groups[1].ToString(); - } - } - } - - match = Regex.Match(str, @"^Content-Type: (.*)$", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled); - if (match.Groups[1].Success) - { - offset += match.Length + 1; // + End of line - contenttype = match.Groups[1].ToString().Trim(); - } - - if (string.IsNullOrWhiteSpace(filename)) - { - offset += 2; // + blank line - if (item.Item2 - offset - 1 >= 0) - { - var value = Encoding.UTF8.GetString(Content, item.Item1 + offset, item.Item2 - offset - 2); - - var param = new Parameter(name, value.TrimEnd(), ParameterScope.Parameter); - AddParameter(param); - } - else - { - var param = new Parameter(name, string.Empty, ParameterScope.Parameter); - AddParameter(param); - } - } - else - { - offset += 2; // + blank line - if (item.Item2 - offset - 1 >= 0) - { - var bytes = new byte[item.Item2 - offset - 2]; - Buffer.BlockCopy(Content, item.Item1 + offset, bytes, 0, item.Item2 - offset - 2); - - var param = new ParameterFile(name, filename, ParameterScope.Parameter) { ContentType = contenttype, Data = bytes }; - AddParameter(param); - } - else - { - var param = new Parameter(name, filename, ParameterScope.Parameter); - AddParameter(param); - } - } - } - - break; - } + enctype = TypeEnctype.Multipart; + } + + switch (enctype) + { + case TypeEnctype.Multipart: + ParseMultipart(ct); + break; + case TypeEnctype.Text: - { - var lines = new List(); - var offset = 0; - - for (var i = 0; i < Content.Length; i++) - { - if (Content[i] == '\r') - { - lines.Add(Encoding.UTF8.GetString(Content, offset, i - offset)); - } - else if (Content[i] == '\n') - { - offset = i + 1; - } - } - - // if not all bytes have been read yet - if (offset < Content.Length) - { - lines.Add(Encoding.UTF8.GetString(Content, offset, Content.Length - offset)); - } - - var last = default(Parameter); - - foreach (var v in lines) - { - var match = Regex.Match(v, @"([\w-]*)=(.*)", RegexOptions.Compiled); - if (match.Groups[1].Success && match.Groups[2].Success) - { - last = new Parameter(match.Groups[1].ToString().Trim(), match.Groups[2].ToString().Trim(), ParameterScope.Parameter); - AddParameter(last); - } - else if (last is not null) - { - last.Value += "\r\n" + v; - - } - } - - if (last is not null) - { - last.Value = last.Value.TrimEnd(); - } - - break; - } + ParseTextPlain(); + break; + case TypeEnctype.UrLEncoded: - { - var str = Encoding.UTF8.GetString(Content, 0, Content.Length); - var param = str.Replace('+', ' '); - - foreach (var v in param.Split('&')) - { - var s = v.Split('='); - AddParameter(new Parameter - ( - s[0], - s.Length > 1 ? s[1]?.TrimEnd() : string.Empty, - ParameterScope.Parameter - )); - } - - break; - } + ParseUrlEncoded(); + break; + default: + // unknown or unsupported content-type + break; + } + } + + /// + /// Parses multipart form data from the provided content type parts and extracts parameters and + /// file uploads. + /// + /// + /// An array of strings representing the parts of the Content-Type header. Each part may include + /// information such as the boundary used to separate multipart sections. + /// + private void ParseMultipart(string[] contentTypeParts) + { + // extract boundary + var boundary = contentTypeParts + .FirstOrDefault(x => x.StartsWith("boundary=", StringComparison.OrdinalIgnoreCase)) + ?["boundary=".Length..]; + + if (string.IsNullOrWhiteSpace(boundary)) + { + return; + } + + var boundaryBytes = Encoding.UTF8.GetBytes("--" + boundary); + var endBoundaryBytes = Encoding.UTF8.GetBytes("--" + boundary + "--"); + + int pos = 0; + + while (true) + { + // find next boundary + int start = IndexOf(Content, boundaryBytes, pos); + if (start < 0) + { + break; + } + + // check for end boundary + bool isFinal = StartsWith(Content, endBoundaryBytes, start); + + // move to header start + int headerStart = start + boundaryBytes.Length + 2; // skip CRLF + + // find header end (empty line) + int headerEnd = IndexOf(Content, Encoding.UTF8.GetBytes("\r\n\r\n"), headerStart); + if (headerEnd < 0) + { + break; + } + + var headerText = Encoding.UTF8.GetString(Content, headerStart, headerEnd - headerStart); + + // parse headers + var name = ExtractHeaderValue(headerText, "name"); + var filename = ExtractHeaderValue(headerText, "filename"); + var contentType = ExtractContentType(headerText); + + // content start + int dataStart = headerEnd + 4; + + // find next boundary to determine data length + int nextBoundary = IndexOf(Content, boundaryBytes, dataStart); + if (nextBoundary < 0) + { + break; + } + + int dataLength = nextBoundary - dataStart - 2; // remove trailing CRLF + + if (string.IsNullOrEmpty(filename)) + { + // normal field + var value = Encoding.UTF8.GetString(Content, dataStart, dataLength).TrimEnd(); + AddParameter(new Parameter(name, value, ParameterScope.Parameter)); + } + else + { + // file upload + var bytes = new byte[dataLength]; + Buffer.BlockCopy(Content, dataStart, bytes, 0, dataLength); + + AddParameter(new ParameterFile(name, filename, ParameterScope.Parameter) { - break; - } + ContentType = contentType, + Data = bytes + }); + } + + if (isFinal) + { + break; + } + + pos = nextBoundary; } } + + /// + /// Parses the request content as plain text and extracts parameters from lines in + /// the format 'key=value'. + /// + private void ParseTextPlain() + { + var text = Encoding.UTF8.GetString(Content); + var lines = text.Split('\n'); + + Parameter last = null; + + foreach (var line in lines) + { + var trimmed = line.TrimEnd('\r'); + + var match = TextRegex().Match(trimmed); + if (match.Success) + { + last = new Parameter(match.Groups[1].Value, match.Groups[2].Value, ParameterScope.Parameter); + AddParameter(last); + } + else if (last != null) + { + last.Value += "\r\n" + trimmed; + } + } + + last?.Value = last.Value.TrimEnd(); + } + + /// + /// Parses the request content as a URL-encoded form and adds each key-value pair + /// as a parameter. + /// + private void ParseUrlEncoded() + { + var text = Encoding.UTF8.GetString(Content); + foreach (var pair in text.Split('&')) + { + var parts = pair.Split('='); + var key = parts[0]; + var value = parts.Length > 1 ? parts[1].Replace('+', ' ') : string.Empty; + + AddParameter(new Parameter(key, value, ParameterScope.Parameter)); + } + } + + /// + /// Searches for the first occurrence of a specified byte sequence within a byte array, + /// starting at a given index. + /// + /// + /// The search is performed using ordinal byte comparison. If needle is an empty array, + /// the method returns start. If start is greater than haystack.Length - needle.Length, + /// the method returns -1. + /// + /// + /// The byte array to search within. + /// + /// + /// The byte sequence to locate within the haystack array. + /// + /// + /// The zero-based index in the haystack array at which to begin searching. Must be + /// non-negative and less than or equal to haystack.Length. + /// + /// + /// The zero-based index of the first occurrence of needle within haystack, starting at + /// the specified index; or -1 if the sequence is not found. + /// + private static int IndexOf(byte[] haystack, byte[] needle, int start) + { + for (int i = start; i <= haystack.Length - needle.Length; i++) + { + if (StartsWith(haystack, needle, i)) + { + return i; + } + } + return -1; + } + + /// + /// Determines whether a specified segment of a byte array begins with the given prefix. + /// + /// + /// If plus the length of exceeds the + /// length of , the method returns . + /// + /// + /// The byte array to examine. + /// + /// + /// The byte sequence to compare against the segment of . + /// + /// + /// The zero-based index in at which to begin the comparison. + /// + /// + /// if the segment of starting at + /// begins with ; otherwise, + /// . + /// + private static bool StartsWith(byte[] data, byte[] prefix, int offset) + { + if (offset + prefix.Length > data.Length) + { + return false; + } + + for (int i = 0; i < prefix.Length; i++) + { + if (data[offset + i] != prefix[i]) + { + return false; + } + } + return true; + } + + /// + /// Extracts the value associated with the specified key from a header string formatted + /// as key-value pairs. + /// + /// + /// The header string containing key-value pairs, where values are enclosed in double quotes. + /// + /// + /// The key whose associated value is to be extracted from the header. The search is case-insensitive. + /// + /// + /// The value associated with the specified key if found; otherwise, an empty string. + /// + private static string ExtractHeaderValue(string header, string key) + { + var match = Regex.Match(header, key + "=\"([^\"]*)\"", RegexOptions.IgnoreCase); + return match.Success ? match.Groups[1].Value : string.Empty; + } + + /// + /// Extracts the value of the Content-Type header from the specified header string. + /// + /// + /// The header string from which to extract the Content-Type value. This should contain a + /// line starting with 'Content-Type:'. + /// + /// + /// A string containing the value of the Content-Type header if found; otherwise, + /// an empty string. + /// + private static string ExtractContentType(string header) + { + var match = ContentRegex().Match(header); + return match.Success ? match.Groups[1].Value.Trim() : string.Empty; + } } } diff --git a/src/WebExpress.WebCore/WebMessage/RequestBase.cs b/src/WebExpress.WebCore/WebMessage/RequestBase.cs index 6db0cfa..9a1c066 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestBase.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestBase.cs @@ -157,7 +157,6 @@ internal RequestBase(IFeatureCollection contextFeatures, RequestHeaderFields hea ); ParseQueryParams(requestFeature.QueryString); - ParseRequestParams(); ParseSessionParams(); } @@ -191,11 +190,6 @@ private void ParseQueryParams(string query) }); } - /// - /// Parse the request parameters. - /// - protected abstract void ParseRequestParams(); - /// /// Parse the session parameters. /// diff --git a/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs b/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs index ff08293..c180845 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestWebSocket.cs @@ -18,12 +18,5 @@ internal RequestWebSocket(IFeatureCollection contextFeatures, RequestHeaderField : base(contextFeatures, header, httpServerContext) { } - - /// - /// Parse the request parameters. - /// - protected override void ParseRequestParams() - { - } } } \ No newline at end of file From ca200c429ee0bf56ba721046480052aca088bf7e Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 18 Jan 2026 15:16:27 +0100 Subject: [PATCH 25/53] chord: bring test comments into harmony --- .../Html/UnitTestHtmlElement.cs | 44 ++++++------ .../Html/UnitTestHtmlElementExtension.cs | 28 ++++---- .../Html/UnitTestHtmlElementFieldLabel.cs | 18 ++--- .../Html/UnitTestHtmlElementMetadataBase.cs | 8 +-- .../Html/UnitTestHtmlElementRootHtml.cs | 4 +- .../Html/UnitTestHtmlElementTextContentP.cs | 18 ++--- .../Html/UnitTestHtmlImage.cs | 2 +- .../Html/UnitTestHtmlText.cs | 6 +- .../Manager/UnitTestApplicationManager.cs | 44 ++++++------ .../Manager/UnitTestAssetManager.cs | 28 ++++---- .../Manager/UnitTestComponentManager.cs | 8 +-- .../Manager/UnitTestEventManager.cs | 28 ++++---- .../Manager/UnitTestFragmentManager.cs | 24 +++---- .../Manager/UnitTestIdentityManager.cs | 36 +++++----- .../Manager/UnitTestIncludeManager.cs | 28 ++++---- .../Manager/UnitTestInternationalization.cs | 30 ++++---- .../Manager/UnitTestJobManager.cs | 24 +++---- .../Manager/UnitTestLogManager.cs | 12 ++-- .../Manager/UnitTestPackageManager.cs | 32 ++++----- .../Manager/UnitTestPageManager.cs | 28 ++++---- .../Manager/UnitTestPluginManager.cs | 56 +++++++-------- .../Manager/UnitTestResourceManager.cs | 24 +++---- .../Manager/UnitTestRestApiManager.cs | 58 +++++++-------- .../Manager/UnitTestSessionManager.cs | 20 +++--- .../Manager/UnitTestSettingPageManager.cs | 72 +++++++++---------- .../Manager/UnitTestSitemapManager.cs | 20 +++--- .../Manager/UnitTestSocketManager.cs | 24 +++---- .../Manager/UnitTestStatusPageManager.cs | 40 +++++------ .../Manager/UnitTestTaskManager.cs | 28 ++++---- .../Manager/UnitTestThemeManager.cs | 36 +++++----- .../Route/UnitTestRoute.cs | 16 ++--- .../Schedule/UnitTestClock.cs | 30 ++++---- .../Schedule/UnitTestCron.cs | 36 +++++----- .../WebUri/UnitTestUri.cs | 34 ++++----- 34 files changed, 472 insertions(+), 472 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs index e4df43e..bfcc0ef 100644 --- a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs @@ -14,7 +14,7 @@ public class UnitTestHtmlElement [Fact] public void FindSingel() { - // preconditions + // arrange var html = new HtmlElementTextContentDiv ( new HtmlElementTextSemanticsI(), @@ -22,7 +22,7 @@ public void FindSingel() new HtmlElementTextSemanticsB() ); - // test execution + // act var res = html.Find(x => x is HtmlElementTextSemanticsSpan).FirstOrDefault(); // validation @@ -35,7 +35,7 @@ public void FindSingel() [Fact] public void Find() { - // preconditions + // arrange var html = new HtmlElement[] { new HtmlElementTextContentDiv @@ -47,7 +47,7 @@ public void Find() new HtmlElementMultimediaImg() }; - // test execution + // act var res = html.Find(x => x is HtmlElementTextSemanticsSpan).FirstOrDefault(); // validation @@ -60,10 +60,10 @@ public void Find() [Fact] public void AddClassTest() { - // preconditions + // arrange var div = new HtmlElementTextContentDiv(); - // test execution + // act div.AddClass("test-class"); // validation @@ -76,11 +76,11 @@ public void AddClassTest() [Fact] public void RemoveClassTest() { - // preconditions + // arrange var div = new HtmlElementTextContentDiv(); div.AddClass("test-class"); - // test execution + // act div.RemoveClass("test-class"); // validation @@ -93,10 +93,10 @@ public void RemoveClassTest() [Fact] public void AddStyleTest() { - // preconditions + // arrange var div = new HtmlElementTextContentDiv(); - // test execution + // act div.AddStyle("color:red;"); // validation @@ -109,11 +109,11 @@ public void AddStyleTest() [Fact] public void RemoveStyleTest() { - // preconditions + // arrange var div = new HtmlElementTextContentDiv(); div.AddStyle("color", "red"); - // test execution + // act div.RemoveStyle("color"); // validation @@ -126,10 +126,10 @@ public void RemoveStyleTest() [Fact] public void AddMultipleClassesTest() { - // preconditions + // arrange var div = new HtmlElementTextContentDiv(); - // test execution + // act div.AddClass("class1"); div.AddClass("class2"); @@ -143,12 +143,12 @@ public void AddMultipleClassesTest() [Fact] public void RemoveOneOfMultipleClassesTest() { - // preconditions + // arrange var div = new HtmlElementTextContentDiv(); div.AddClass("class1"); div.AddClass("class2"); - // test execution + // act div.RemoveClass("class1"); // validation @@ -162,10 +162,10 @@ public void RemoveOneOfMultipleClassesTest() [Fact] public void AddMultipleStylesTest() { - // preconditions + // arrange var div = new HtmlElementTextContentDiv(); - // test execution + // act div.AddStyle("color:red;"); div.AddStyle("background:blue;"); @@ -180,12 +180,12 @@ public void AddMultipleStylesTest() [Fact] public void RemoveOneOfMultipleStylesTest() { - // preconditions + // arrange var div = new HtmlElementTextContentDiv(); div.AddStyle("color:red;"); div.AddStyle("background:blue;"); - // test execution + // act div.RemoveStyle("color:red;"); // validation @@ -199,7 +199,7 @@ public void RemoveOneOfMultipleStylesTest() [Fact] public void ToStringEmptyDivTest() { - // preconditions + // arrange var div = new HtmlElementTextContentDiv(); // validation @@ -212,7 +212,7 @@ public void ToStringEmptyDivTest() [Fact] public void ToStringWithChildrenTest() { - // preconditions + // arrange var div = new HtmlElementTextContentDiv( new HtmlElementTextSemanticsB(), new HtmlElementTextSemanticsI() diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementExtension.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementExtension.cs index 7bfe0a0..3375d00 100644 --- a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementExtension.cs +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementExtension.cs @@ -22,10 +22,10 @@ public class UnitTestHtmlElementExtension [InlineData(null, "new", "new")] public void AddClass(string initial, string toAdd, string expected) { - // preconditions + // arrange var element = new HtmlElementTextContentDiv { Class = initial } as IHtmlNode; - // test execution + // act element.AddClass(toAdd); Assert.Equal(expected, (element as IHtmlElement).Class); @@ -42,10 +42,10 @@ public void AddClass(string initial, string toAdd, string expected) [InlineData(null, "gamma", "")] public void RemoveClass(string initial, string toRemove, string expected) { - // preconditions + // arrange var element = new HtmlElementTextContentDiv { Class = initial } as IHtmlNode; - // test execution + // act element.RemoveClass(toRemove); // validation @@ -68,10 +68,10 @@ public void RemoveClass(string initial, string toRemove, string expected) [InlineData(null, "new", "new")] public void AddStyle(string initial, string toAdd, string expected) { - // preconditions + // arrange var element = new HtmlElementTextContentDiv { Style = initial } as IHtmlNode; - // test execution + // act element.AddStyle(toAdd); // validation @@ -91,10 +91,10 @@ public void AddStyle(string initial, string toAdd, string expected) [InlineData(null, "gamma", "")] public void RemoveStyle(string initial, string toRemove, string expected) { - // preconditions + // arrange var element = new HtmlElementTextContentDiv { Style = initial } as IHtmlNode; - // test execution + // act element.RemoveStyle(toRemove); // validation @@ -109,10 +109,10 @@ public void RemoveStyle(string initial, string toRemove, string expected) [InlineData("data-id", "42", "data-id=42")] public void AddUserAttribute(string name, string value, string expected) { - // preconditions + // arrange var element = new HtmlElementTextContentDiv() as IHtmlNode; - // test execution + // act if (!string.IsNullOrWhiteSpace(value)) { element.AddUserAttribute(name, value); @@ -145,11 +145,11 @@ public void AddUserAttribute(string name, string value, string expected) [InlineData("data-id", "42")] public void RemoveUserAttribute(string name, string value) { - // preconditions + // arrange var element = new HtmlElementTextContentDiv() as IHtmlNode; element.AddUserAttribute(name, value); - // test execution + // act element.RemoveUserAttribute(name); // validation @@ -162,7 +162,7 @@ public void RemoveUserAttribute(string name, string value) [Fact] public void Find() { - // preconditions + // arrange var root = new HtmlElementTextContentDiv(); var node = root as IHtmlNode; var child1 = new HtmlElementTextSemanticsSpan(); @@ -170,7 +170,7 @@ public void Find() root.Add(child1); root.Add(child2); - // test execution + // act var result = node.Find(e => e is HtmlElementTextSemanticsSpan).ToList(); // validation diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldLabel.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldLabel.cs index cd716f1..de21028 100644 --- a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldLabel.cs +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldLabel.cs @@ -14,7 +14,7 @@ public class UnitTestHtmlElementFieldLabel [Fact] public void Empty() { - // test execution + // act var html = new HtmlElementFieldLabel(); Assert.Equal(@"", html.Trim()); @@ -26,7 +26,7 @@ public void Empty() [Fact] public void TextAtInstancing() { - // test execution + // act var html = new HtmlElementFieldLabel("abcdef"); Assert.Equal(@"", html.Trim()); @@ -38,7 +38,7 @@ public void TextAtInstancing() [Fact] public void TextAtProperty() { - // test execution + // act var html = new HtmlElementFieldLabel { Text = "abcdef" @@ -53,7 +53,7 @@ public void TextAtProperty() [Fact] public void TextAtHtmlText() { - // test execution + // act var html = new HtmlElementFieldLabel(new HtmlText("abc"), new HtmlText("def")); Assert.Equal(@"", html.Trim()); @@ -65,7 +65,7 @@ public void TextAtHtmlText() [Fact] public void TextWithId() { - // test execution + // act var html = new HtmlElementFieldLabel() { Id = "identity" @@ -80,7 +80,7 @@ public void TextWithId() [Fact] public void Inline() { - // test execution + // act var html = new HtmlElementFieldLabel(); Assert.False(html.Inline); @@ -92,7 +92,7 @@ public void Inline() [Fact] public void CloseTag() { - // test execution + // act var html = new HtmlElementFieldLabel(); Assert.True(html.CloseTag); @@ -104,7 +104,7 @@ public void CloseTag() [Fact] public void Class() { - // test execution + // act var html = new HtmlElementFieldLabel() { Class = "abc" @@ -119,7 +119,7 @@ public void Class() [Fact] public void Style() { - // test execution + // act var html = new HtmlElementFieldLabel() { Style = "abc" diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataBase.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataBase.cs index 8a80013..67fdea8 100644 --- a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataBase.cs +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataBase.cs @@ -14,10 +14,10 @@ public class UnitTestHtmlElementMetadataBase [Fact] public void Constructor() { - // preconditions + // arrange var url = "https://example.com"; - // test execution + // act var element = new HtmlElementMetadataBase(url); // validation @@ -32,10 +32,10 @@ public void Constructor() [InlineData("https://example.com", "https://example.com")] public void Href(string uri, string expected) { - // preconditions + // arrange var element = new HtmlElementMetadataBase(); - // test execution + // act element.Href = uri; // validation diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementRootHtml.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementRootHtml.cs index d5115e3..c167356 100644 --- a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementRootHtml.cs +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementRootHtml.cs @@ -14,10 +14,10 @@ public class UnitTestHtmlElementRootHtml [Fact] public void HeadBase() { - // preconditions + // arrange var url = "https://example.com"; - // test execution + // act var element = new HtmlElementRootHtml(); element.Head.Base = url; diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentP.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentP.cs index 555fdfa..1ec474d 100644 --- a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentP.cs +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentP.cs @@ -14,7 +14,7 @@ public class UnitTestHtmlElementTextContentP [Fact] public void Empty() { - // test execution + // act var html = new HtmlElementTextContentP(); Assert.Equal(@"

", html.Trim()); @@ -26,7 +26,7 @@ public void Empty() [Fact] public void TextAtInstancing() { - // test execution + // act var html = new HtmlElementTextContentP("abcdef"); Assert.Equal(@"

abcdef

", html.Trim()); @@ -38,7 +38,7 @@ public void TextAtInstancing() [Fact] public void TextAtProperty() { - // test execution + // act var html = new HtmlElementTextContentP { Text = "abcdef" @@ -53,7 +53,7 @@ public void TextAtProperty() [Fact] public void TextAtHtmlText() { - // test execution + // act var html = new HtmlElementTextContentP(new HtmlText("abc"), new HtmlText("def")); var str = html.ToString(); @@ -66,7 +66,7 @@ public void TextAtHtmlText() [Fact] public void TextWithId() { - // test execution + // act var html = new HtmlElementTextContentP() { Id = "identity" @@ -81,7 +81,7 @@ public void TextWithId() [Fact] public void Inline() { - // test execution + // act var html = new HtmlElementTextContentP(); Assert.False(html.Inline); @@ -93,7 +93,7 @@ public void Inline() [Fact] public void CloseTag() { - // test execution + // act var html = new HtmlElementTextContentP(); Assert.True(html.CloseTag); @@ -105,7 +105,7 @@ public void CloseTag() [Fact] public void Class() { - // test execution + // act var html = new HtmlElementTextContentP() { Class = "abc" @@ -120,7 +120,7 @@ public void Class() [Fact] public void Style() { - // test execution + // act var html = new HtmlElementTextContentP() { Style = "abc" diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlImage.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlImage.cs index f5c9d85..5679f85 100644 --- a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlImage.cs +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlImage.cs @@ -14,7 +14,7 @@ public class UnitTestHtmlImage [Fact] public void Empty() { - // test execution + // act var html = new HtmlElementMultimediaImg(); Assert.Equal(@"", html.ToString().Trim()); diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlText.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlText.cs index 4d499af..a89b3b4 100644 --- a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlText.cs +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlText.cs @@ -14,7 +14,7 @@ public class UnitTestHtmlText [Fact] public void Empty() { - // test execution + // act var html = new HtmlText(); Assert.Null(html.Value); @@ -26,7 +26,7 @@ public void Empty() [Fact] public void TextAtInstancing() { - // test execution + // act var html = new HtmlText("abcdef"); Assert.Equal(@"abcdef", html.Value); @@ -38,7 +38,7 @@ public void TextAtInstancing() [Fact] public void TextAtProperty() { - // test execution + // act var html = new HtmlText { Value = "abcdef" diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestApplicationManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestApplicationManager.cs index ec0b2c2..d7e011b 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestApplicationManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestApplicationManager.cs @@ -17,11 +17,11 @@ public class UnitTestApplicationManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); var pluginManager = componentHub.PluginManager as PluginManager; - // test execution + // act pluginManager.Register(); // validation @@ -37,12 +37,12 @@ public void Register() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var applicationManager = componentHub.ApplicationManager as ApplicationManager; var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); - // test execution + // act applicationManager.Remove(plugin); // validation @@ -58,11 +58,11 @@ public void Remove() [InlineData(typeof(TestApplicationC), "webexpress.webcore.test.testapplicationc")] public void Id(Type applicationType, string id) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - // test execution + // act Assert.Equal(id, application.ApplicationId); } @@ -75,11 +75,11 @@ public void Id(Type applicationType, string id) [InlineData(typeof(TestApplicationC), "TestApplicationC")] public void Name(Type applicationType, string name) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - // test execution + // act Assert.Equal(name, application.ApplicationName); } @@ -92,11 +92,11 @@ public void Name(Type applicationType, string name) [InlineData(typeof(TestApplicationC), "application.description")] public void Description(Type applicationType, string description) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - // test execution + // act Assert.Equal(description, application.Description); } @@ -109,11 +109,11 @@ public void Description(Type applicationType, string description) [InlineData(typeof(TestApplicationC), "/server/assets/img/Logo.png")] public void Icon(Type applicationType, string icon) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - // test execution + // act Assert.Equal(icon, application.Icon.ToString()); } @@ -126,11 +126,11 @@ public void Icon(Type applicationType, string icon) [InlineData(typeof(TestApplicationC), "/server")] public void ContextPath(Type applicationType, string contextPath) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - // test execution + // act Assert.Equal(contextPath, application.Route.ToString()); } @@ -143,11 +143,11 @@ public void ContextPath(Type applicationType, string contextPath) [InlineData(typeof(TestApplicationC), "*/")] public void AssetPath(Type applicationType, string assetPath) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - // test execution + // act AssertExtensions.EqualWithPlaceholders(assetPath, application.AssetPath); } @@ -160,11 +160,11 @@ public void AssetPath(Type applicationType, string assetPath) [InlineData(typeof(TestApplicationC), "*/")] public void DataPath(Type applicationType, string dataPath) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - // test execution + // act AssertExtensions.EqualWithPlaceholders(dataPath, application.DataPath); } @@ -174,10 +174,10 @@ public void DataPath(Type applicationType, string dataPath) [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.ApplicationManager.GetType())); } @@ -187,10 +187,10 @@ public void IsIComponentManager() [Fact] public void IsIContext() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act foreach (var application in componentHub.ApplicationManager.Applications) { Assert.True(typeof(IContext).IsAssignableFrom(application.GetType()), $"Application context {application.GetType().Name} does not implement IContext."); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs index 1974d85..d7edb9f 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs @@ -19,10 +19,10 @@ public class UnitTestAssetManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.Equal(12, componentHub.AssetManager.Assets.Count()); } @@ -32,12 +32,12 @@ public void Register() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); var resourceManager = componentHub.AssetManager as AssetManager; - // test execution + // act resourceManager.Remove(plugin); Assert.Empty(componentHub.AssetManager.Assets); @@ -59,12 +59,12 @@ public void Remove() [InlineData(typeof(TestApplicationC), "webexpress.webcore.test.js.myjavascript.mini.js")] public void Id(Type applicationType, string id) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var asset = componentHub.AssetManager.GetAssets(application)?.FirstOrDefault(x => x.EndpointId.ToString() == id); - // test execution + // act Assert.Equal(id, asset?.EndpointId.ToString()); } @@ -84,13 +84,13 @@ public void Id(Type applicationType, string id) [InlineData(typeof(TestApplicationC), "/server/assets/js/myjavascript.mini.js")] public void Uri(Type applicationType, string route) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var asset = componentHub.AssetManager.GetAssets(application)? .FirstOrDefault(x => x.Route.ToString() == route); - // test execution + // act Assert.Equal(route, asset?.Route.ToString()); } @@ -110,14 +110,14 @@ public void Uri(Type applicationType, string route) [InlineData("http://localhost:8080/server/assets/js/myjavascript.mini.js", "js/myjavascript.mini.js")] public void Request(string uri, string resource) { - // preconditions + // arrange var embeddedResource = UnitTestFixture.GetEmbeddedResource(resource); var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestFixture.CreateHttpContextMock(); var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); componentHub.SitemapManager.Refresh(); - // test execution + // act var searchResult = componentHub.SitemapManager.SearchResource(new System.Uri(uri), new SearchContext() { HttpServerContext = httpServerContext, @@ -140,10 +140,10 @@ public void Request(string uri, string resource) [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.AssetManager.GetType())); } @@ -153,10 +153,10 @@ public void IsIComponentManager() [Fact] public void IsIContext() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act foreach (var asset in componentHub.AssetManager.Assets) { Assert.True(typeof(IContext).IsAssignableFrom(asset.GetType()), $"Asset context {asset.GetType().Name} does not implement IContext."); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestComponentManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestComponentManager.cs index d0ebaaf..52a5a9a 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestComponentManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestComponentManager.cs @@ -14,10 +14,10 @@ public class UnitTestComponentManager [Fact] public void PluginManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); - // test execution + // act Assert.NotNull(componentHub.PluginManager); } @@ -27,10 +27,10 @@ public void PluginManager() [Fact] public void ApplicationManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); - // test execution + // act Assert.NotNull(componentHub.ApplicationManager); } } diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestEventManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestEventManager.cs index 049f21b..09573b2 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestEventManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestEventManager.cs @@ -16,10 +16,10 @@ public class UnitTestEventManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.Equal(6, componentHub.EventManager.EventHandlers.Count()); } @@ -29,12 +29,12 @@ public void Register() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var eventManager = componentHub.EventManager as EventManager; var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); - // test execution + // act eventManager.Remove(plugin); Assert.Empty(componentHub.EventManager.EventHandlers); @@ -46,10 +46,10 @@ public void Remove() [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.EventManager.GetType())); } @@ -63,11 +63,11 @@ public void IsIComponentManager() [InlineData(typeof(TestApplicationB), typeof(TestEventB), "webexpress.webcore.test.testeventhandlerb")] public void Id(Type applicationType, Type eventType, string id) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - // test execution + // act var eventHandlers = componentHub.EventManager.GetEventHandlers(application, eventType); if (id is null) @@ -89,11 +89,11 @@ public void Id(Type applicationType, Type eventType, string id) [InlineData(typeof(TestApplicationB), typeof(TestEventB), "webexpress.webcore.test.testeventb")] public void EventId(Type applicationType, Type eventType, string id) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - // test execution + // act var eventHandlers = componentHub.EventManager.GetEventHandlers(application, eventType); if (id is null) @@ -111,11 +111,11 @@ public void EventId(Type applicationType, Type eventType, string id) [Fact] public void RaiseEventA1() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplication(); - // test execution + // act var eventArgument = new TestEventArgument() { TestProperty = false }; componentHub.EventManager.RaiseEvent(application, this, eventArgument); @@ -129,11 +129,11 @@ public void RaiseEventA1() [Fact] public void RaiseEventB1() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplication(); - // test execution + // act var eventArgument = new TestEventArgument() { TestProperty = false }; componentHub.EventManager.RaiseEvent(application, this, eventArgument); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentManager.cs index fdaa0e4..4a9b406 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentManager.cs @@ -19,10 +19,10 @@ public class UnitTestFragmentManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.Equal(15, componentHub.FragmentManager.Fragments.Count()); } @@ -32,12 +32,12 @@ public void Register() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var fragmentManager = componentHub.FragmentManager as FragmentManager; var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); - // test execution + // act fragmentManager.Remove(plugin); Assert.Empty(componentHub.FragmentManager.Fragments); @@ -49,10 +49,10 @@ public void Remove() [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.EventManager.GetType())); } @@ -71,11 +71,11 @@ public void IsIComponentManager() [InlineData(typeof(TestApplicationC), typeof(TestFragmentC), "webexpress.webcore.test.testfragmentc")] public void Id(Type applicationType, Type fragmentType, string id) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - // test execution + // act var fragment = componentHub.FragmentManager.GetFragments(application, fragmentType); if (id is null) @@ -98,12 +98,12 @@ public void Id(Type applicationType, Type fragmentType, string id) [InlineData(typeof(TestApplicationB), typeof(About), 1)] public void GetFragments(Type applicationType, Type scopeType, int count) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); var renderContext = UnitTestFixture.CrerateRenderContextMock(application, [scopeType]); - // test execution + // act var fragments = componentHub.FragmentManager.GetFragments(application, renderContext?.PageContext?.Scopes).ToList(); Assert.NotNull(fragments); @@ -121,13 +121,13 @@ public void GetFragments(Type applicationType, Type scopeType, int count) [InlineData(typeof(TestApplicationA), typeof(TestSectionA), typeof(TestScopeD), true)] public void Render(Type applicationType, Type sectionType, Type scopeType, bool empty) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); var renderContext = UnitTestFixture.CrerateRenderContextMock(application, [scopeType]); var visualTree = new VisualTree(); - // test execution + // act var html = componentHub.FragmentManager.Render(renderContext, visualTree, sectionType); Assert.NotNull(html); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs index 9f381a0..6a98c86 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs @@ -18,10 +18,10 @@ public class UnitTestIdentityManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.Equal(9, componentHub.IdentityManager.Permissions.Count()); Assert.Equal(6, componentHub.IdentityManager.Policies.Count()); } @@ -32,12 +32,12 @@ public void Register() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); var identityManager = componentHub.IdentityManager as IdentityManager; - // test execution + // act identityManager.Remove(plugin); Assert.Empty(componentHub.IdentityManager.Permissions); @@ -50,10 +50,10 @@ public void Remove() [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.IdentityManager.GetType())); } @@ -72,13 +72,13 @@ public void IsIComponentManager() [InlineData(typeof(TestApplicationA), "Charlie", typeof(TestIdentityPermissionC), false)] public void CheckAccessIdentity(Type application, string identityName, Type permission, bool expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var identityManager = componentHub.IdentityManager as IdentityManager; var applicationContext = componentHub.ApplicationManager.GetApplications(application).FirstOrDefault(); var identity = MockIdentityFactory.GetIdentity(identityName); - // test execution + // act var access = identityManager.CheckAccess(applicationContext, identity, permission); Assert.Equal(expected, access); @@ -99,13 +99,13 @@ public void CheckAccessIdentity(Type application, string identityName, Type perm [InlineData(typeof(TestApplicationA), "Guests", typeof(TestIdentityPermissionC), false)] public void CheckAccessGroup(Type application, string groupName, Type permission, bool expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var identityManager = componentHub.IdentityManager as IdentityManager; var applicationContext = componentHub.ApplicationManager.GetApplications(application).FirstOrDefault(); var group = MockIdentityFactory.GetIdentityGroup(groupName); - // test execution + // act var access = identityManager.CheckAccess(applicationContext, group, permission); Assert.Equal(expected, access); @@ -123,12 +123,12 @@ public void CheckAccessGroup(Type application, string groupName, Type permission [InlineData(typeof(TestApplicationA), typeof(TestIdentityPolicyB), typeof(TestIdentityPermissionC), false)] public void CheckAccess(Type application, Type policy, Type permission, bool expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var identityManager = componentHub.IdentityManager as IdentityManager; var applicationContext = componentHub.ApplicationManager.GetApplications(application).FirstOrDefault(); - // test execution + // act var access = identityManager.CheckAccess(applicationContext, policy, permission); Assert.Equal(expected, access); @@ -142,7 +142,7 @@ public void CheckAccess(Type application, Type policy, Type permission, bool exp [InlineData("Alice", "123", false)] public void Login(string identityName, string password, bool expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var identityManager = componentHub.IdentityManager as IdentityManager; var request = UnitTestFixture.CrerateRequestMock(); @@ -151,7 +151,7 @@ public void Login(string identityName, string password, bool expected) password.ToList().ForEach(x => securePassword.AppendChar(x)); securePassword.MakeReadOnly(); - // test execution + // act var res = identityManager.Login(request, identity, securePassword); Assert.Equal(expected, res); @@ -166,7 +166,7 @@ public void Login(string identityName, string password, bool expected) [InlineData("Charlie", "abc")] public void Logout(string identityName, string password) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var identityManager = componentHub.IdentityManager as IdentityManager; var request = UnitTestFixture.CrerateRequestMock(); @@ -176,7 +176,7 @@ public void Logout(string identityName, string password) securePassword.MakeReadOnly(); identityManager.Login(request, identity, securePassword); - // test execution + // act identityManager.Logout(request); var res = identityManager.GetCurrentIdentity(request); @@ -192,7 +192,7 @@ public void Logout(string identityName, string password) [InlineData("Charlie", "abc")] public void GetCurrentIdentity(string identityName, string password) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var identityManager = componentHub.IdentityManager as IdentityManager; var request = UnitTestFixture.CrerateRequestMock(); @@ -202,7 +202,7 @@ public void GetCurrentIdentity(string identityName, string password) securePassword.MakeReadOnly(); identityManager.Login(request, identity, securePassword); - // test execution + // act var res = identityManager.GetCurrentIdentity(request); Assert.Equal(identity, res); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestIncludeManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestIncludeManager.cs index c194bdd..bf1755e 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestIncludeManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestIncludeManager.cs @@ -16,10 +16,10 @@ public class UnitTestIncludeManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.Equal(12, componentHub.IncludeManager.Includes.Count()); } @@ -29,12 +29,12 @@ public void Register() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); var includeManager = componentHub.IncludeManager as IncludeManager; - // test execution + // act includeManager.Remove(plugin); // validation @@ -59,12 +59,12 @@ public void Remove() [InlineData(typeof(TestApplicationC), typeof(TestIncludeCssB), "webexpress.webcore.test.testincludecssb")] public void Id(Type applicationType, Type includeType, string expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var include = componentHub.IncludeManager.GetIncludes(application, includeType)?.FirstOrDefault(); - // test execution + // act var id = include?.IncludeId.ToString(); // validation @@ -89,12 +89,12 @@ public void Id(Type applicationType, Type includeType, string expected) [InlineData(typeof(TestApplicationC), typeof(TestIncludeCssB), "/myX.css;/myY.css;/myZ.css")] public void Files(Type applicationType, Type resourceType, string expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var include = componentHub.IncludeManager.GetIncludes(application, resourceType)?.FirstOrDefault(); - // test execution + // act var files = include.Files.Select(x => x.FileName); // validation @@ -119,12 +119,12 @@ public void Files(Type applicationType, Type resourceType, string expected) [InlineData(typeof(TestApplicationC), typeof(TestIncludeCssB), "StyleSheet;StyleSheet;StyleSheet")] public void FileType(Type applicationType, Type resourceType, string expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var include = componentHub.IncludeManager.GetIncludes(application, resourceType)?.FirstOrDefault(); - // test execution + // act var files = include.Files.Select(x => x.Type); // validation @@ -137,10 +137,10 @@ public void FileType(Type applicationType, Type resourceType, string expected) [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.IncludeManager.GetType())); } @@ -150,10 +150,10 @@ public void IsIComponentManager() [Fact] public void IsIContext() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act foreach (var include in componentHub.IncludeManager.Includes) { Assert.True(typeof(IContext).IsAssignableFrom(include.GetType()), $"Include context '{include.GetType().Name}' does not implement IContext."); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestInternationalization.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestInternationalization.cs index 8421e2b..f33ce9d 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestInternationalization.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestInternationalization.cs @@ -18,11 +18,11 @@ public class UnitTestInternationalization [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); var pluginManager = componentHub.PluginManager as PluginManager; - // test execution + // act pluginManager.Register(); Assert.Equal("This is a test", I18N.Translate("webexpress.webcore.test:unit.test.message")); @@ -34,12 +34,12 @@ public void Register() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var internationalizationManager = componentHub.InternationalizationManager as InternationalizationManager; var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); - // test execution + // act internationalizationManager.Remove(plugin); Assert.Equal("webexpress.webcore.test:unit.test.message", I18N.Translate("webexpress.webcore.test:unit.test.message")); @@ -51,10 +51,10 @@ public void Remove() [Fact] public void GetDefaultCulture() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.Equal(CultureInfo.GetCultureInfo("en"), InternationalizationManager.DefaultCulture); } @@ -72,47 +72,47 @@ public void GetDefaultCulture() [InlineData("non.existent.key", "non.existent.key", "de")] public void Translate(string key, string excepted, string cultureName = null, string pluginID = null, params object[] param) { - // preconditions + // arrange UnitTestFixture.CreateAndRegisterComponentHubMock(); if (cultureName is null && param.Length == 0) { - // test execution + // act var result = I18N.Translate(key); Assert.Equal(excepted, result); } if (cultureName is null && param.Length != 0) { - // test execution + // act var result = I18N.Translate(key, param); Assert.Equal(excepted, result); } if (cultureName is not null && pluginID is null && param.Length == 0) { - // test execution + // act var result = I18N.Translate(CultureInfo.GetCultureInfo(cultureName), key); Assert.Equal(excepted, result); } if (cultureName is not null && pluginID is null && param.Length != 0) { - // test execution + // act var result = I18N.Translate(CultureInfo.GetCultureInfo(cultureName), key, param); Assert.Equal(excepted, result); } if (cultureName is not null && pluginID is not null && param.Length == 0) { - // test execution + // act var result = I18N.Translate(CultureInfo.GetCultureInfo(cultureName), pluginID, key); Assert.Equal(excepted, result); } if (cultureName is not null && pluginID is not null && param.Length != 0) { - // test execution + // act var result = I18N.Translate(CultureInfo.GetCultureInfo(cultureName), pluginID, key, param); Assert.Equal(excepted, result); @@ -125,10 +125,10 @@ public void Translate(string key, string excepted, string cultureName = null, st [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.InternationalizationManager.GetType())); } } diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestJobManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestJobManager.cs index 3f4eb85..1d347c3 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestJobManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestJobManager.cs @@ -16,10 +16,10 @@ public class UnitTestJobManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.Equal(3, componentHub.JobManager.Jobs.Count()); } @@ -29,12 +29,12 @@ public void Register() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); var jobManager = componentHub.JobManager as JobManager; - // test execution + // act jobManager.Remove(plugin); Assert.Empty(componentHub.JobManager.Jobs); @@ -46,10 +46,10 @@ public void Remove() [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.ResourceManager.GetType())); } @@ -59,10 +59,10 @@ public void IsIComponentManager() [Fact] public void IsIContext() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act foreach (var job in componentHub.JobManager.Jobs) { Assert.True(typeof(IContext).IsAssignableFrom(job.GetType()), $"Job context {job.GetType().Name} does not implement IContext."); @@ -79,12 +79,12 @@ public void IsIContext() public void Id(Type applicationType, Type jobType, string id) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); var job = componentHub.JobManager.GetJob(application, jobType); - // test execution + // act Assert.Equal(id, job?.JobId.ToString()); } @@ -97,12 +97,12 @@ public void Id(Type applicationType, Type jobType, string id) [InlineData(typeof(TestApplicationC), typeof(TestJobA), 50, 8, 31, new[] { 1, 2 }, 0)] public void Cron(Type applicationType, Type jobType, int minute, int hour, int day, int[] month, int weekday) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); var job = componentHub.JobManager.GetJob(application, jobType); - // test execution + // act Assert.Equal(minute, job?.Cron.Minute.FirstOrDefault() ?? -1); Assert.Equal(hour, job?.Cron.Hour.FirstOrDefault() ?? -1); Assert.Equal(day, job?.Cron.Day.FirstOrDefault() ?? -1); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestLogManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestLogManager.cs index 96f8c3d..2a94866 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestLogManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestLogManager.cs @@ -16,11 +16,11 @@ public class UnitTestLogManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); var logManager = componentHub.LogManager as LogManager; - // test execution + // act Assert.NotNull(logManager); } @@ -30,11 +30,11 @@ public void Register() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); var logManager = componentHub.LogManager as LogManager; - // test execution + // act Assert.NotNull(logManager); } @@ -44,11 +44,11 @@ public void Remove() [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var logManager = componentHub.LogManager as LogManager; - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(logManager.GetType())); } } diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs index 93ce7da..5814623 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs @@ -19,11 +19,11 @@ public class UnitTestPackageManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); var packageManager = componentHub.PackageManager as PackageManager; - // test execution + // act Assert.NotNull(packageManager); } @@ -33,11 +33,11 @@ public void Register() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); var packageManager = componentHub.PackageManager as PackageManager; - // test execution + // act Assert.NotNull(packageManager); } @@ -47,11 +47,11 @@ public void Remove() [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var packageManager = componentHub.PackageManager as PackageManager; - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(packageManager.GetType())); } @@ -61,7 +61,7 @@ public void IsIComponentManager() [Fact] public void AddPackageEvent() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); var packageManager = componentHub.PackageManager as PackageManager; bool eventFired = false; @@ -70,7 +70,7 @@ public void AddPackageEvent() // create dummy package var package = new PackageCatalogItem() { Id = "test", File = "test.wxp", State = PackageCatalogeItemState.Active }; - // test execution + // act var method = typeof(PackageManager).GetMethod("OnAddPackage", BindingFlags.NonPublic | BindingFlags.Instance); method.Invoke(packageManager, [package]); @@ -84,7 +84,7 @@ public void AddPackageEvent() [Fact] public void RemovePackageEvent() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); var packageManager = componentHub.PackageManager as PackageManager; bool eventFired = false; @@ -93,7 +93,7 @@ public void RemovePackageEvent() // create dummy package var package = new PackageCatalogItem() { Id = "test", File = "test.wxp", State = PackageCatalogeItemState.Active }; - // test execution + // act var method = typeof(PackageManager).GetMethod("OnRemovePackage", BindingFlags.NonPublic | BindingFlags.Instance); method.Invoke(packageManager, [package]); @@ -106,7 +106,7 @@ public void RemovePackageEvent() [Fact] public void ScanDetectsNewPackage() { - // preconditions + // arrange var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); var packageManager = componentHub.PackageManager as PackageManager; @@ -131,7 +131,7 @@ public void ScanDetectsNewPackage() "); } - // test execution - scan should detect the new file + // act - scan should detect the new file packageManager.Scan(); // validation @@ -152,7 +152,7 @@ public void ScanDetectsNewPackage() [Fact] public void ScanDetectsRemovedPackage() { - // preconditions + // arrange var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); var packageManager = componentHub.PackageManager as PackageManager; @@ -183,7 +183,7 @@ public void ScanDetectsRemovedPackage() // remove file and scan again File.Delete(dummyFile); - // test execution - scan should detect the removed file + // act - scan should detect the removed file packageManager.Scan(); // validation @@ -204,7 +204,7 @@ public void ScanDetectsRemovedPackage() [Fact] public void LoadPackageReadsSpec() { - // preconditions + // arrange var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); var packageManager = componentHub.PackageManager as PackageManager; @@ -231,7 +231,7 @@ public void LoadPackageReadsSpec() // use private LoadPackage method via reflection var method = typeof(PackageManager).GetMethod("LoadPackage", BindingFlags.NonPublic | BindingFlags.Instance); - // test execution + // act var result = method.Invoke(packageManager, [dummyFile]) as PackageCatalogItem; // validation diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestPageManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestPageManager.cs index fc39f30..cd35974 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestPageManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestPageManager.cs @@ -17,10 +17,10 @@ public class UnitTestPageManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.Equal(33, componentHub.PageManager.Pages.Count()); } @@ -30,12 +30,12 @@ public void Register() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); var pageManager = componentHub.PageManager as PageManager; - // test execution + // act pageManager.Remove(plugin); Assert.Empty(componentHub.PageManager.Pages); @@ -58,12 +58,12 @@ public void Remove() [InlineData(typeof(TestApplicationA), typeof(WWW.Products.Details.Index), "webexpress.webcore.test.www.products.details.index")] public void Id(Type applicationType, Type pageType, string id) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var page = componentHub.PageManager.GetPages(pageType, application)?.FirstOrDefault(); - // test execution + // act Assert.Equal(id, page.EndpointId.ToString()); } @@ -83,12 +83,12 @@ public void Id(Type applicationType, Type pageType, string id) public void Title(Type applicationType, Type resourceType, string title) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var page = componentHub.PageManager.GetPages(resourceType, application)?.FirstOrDefault(); - // test execution + // act Assert.Equal(title, page.PageTitle); } @@ -107,12 +107,12 @@ public void Title(Type applicationType, Type resourceType, string title) [InlineData(typeof(TestApplicationC), typeof(Contact), "/server/contact")] public void RoutePath(Type applicationType, Type resourceType, string path) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var page = componentHub.PageManager.GetPages(resourceType, application)?.FirstOrDefault(); - // test execution + // act Assert.Equal(path, page.Route.ToString()); } @@ -122,10 +122,10 @@ public void RoutePath(Type applicationType, Type resourceType, string path) [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.PageManager.GetType())); } @@ -135,10 +135,10 @@ public void IsIComponentManager() [Fact] public void IsIContext() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act foreach (var pages in componentHub.PageManager.Pages) { Assert.True(typeof(IContext).IsAssignableFrom(pages.GetType()), $"Page context {pages.GetType().Name} does not implement IContext."); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestPluginManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestPluginManager.cs index 2b1d5d7..815f347 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestPluginManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestPluginManager.cs @@ -16,11 +16,11 @@ public class UnitTestPluginManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); var pluginManager = componentHub.PluginManager as PluginManager; - // test execution + // act pluginManager.Register(); Assert.Single(componentHub.PluginManager.Plugins); @@ -33,7 +33,7 @@ public void Register() [Fact] public void RegisterEvent() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); var pluginManager = componentHub.PluginManager as PluginManager; var i = 0; @@ -41,7 +41,7 @@ public void RegisterEvent() componentHub.PluginManager.AddPlugin += (s, e) => { i++; triggered = true; }; - // test execution + // act pluginManager.Register(); Assert.Single(componentHub.PluginManager.Plugins); @@ -56,13 +56,13 @@ public void RegisterEvent() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); var pluginManager = componentHub.PluginManager as PluginManager; pluginManager.Register(); var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); - // test execution + // act pluginManager.Remove(plugin); Assert.Empty(componentHub.PluginManager.Plugins); @@ -74,7 +74,7 @@ public void Remove() [Fact] public void RemoveEvent() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); var pluginManager = componentHub.PluginManager as PluginManager; var i = 1; @@ -84,7 +84,7 @@ public void RemoveEvent() pluginManager.Register(); var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); - // test execution + // act pluginManager.Remove(plugin); Assert.Empty(componentHub.PluginManager.Plugins); @@ -98,10 +98,10 @@ public void RemoveEvent() [Fact] public void GetPluginById() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act var plugin = componentHub.PluginManager.GetPlugin("webexpress.webcore.test"); Assert.Equal("webexpress.webcore.test", plugin?.PluginId.ToString()); @@ -113,10 +113,10 @@ public void GetPluginById() [Fact] public void GetPluginByType() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); Assert.Equal("webexpress.webcore.test", plugin?.PluginId.ToString()); @@ -128,11 +128,11 @@ public void GetPluginByType() [Fact] public void Id() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); - // test execution + // act Assert.Equal(typeof(TestPlugin).Namespace.ToLower(), plugin.PluginId.ToString()); } @@ -142,11 +142,11 @@ public void Id() [Fact] public void GetName() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); - // test execution + // act Assert.Equal("TestPlugin", plugin.PluginName); } @@ -156,11 +156,11 @@ public void GetName() [Fact] public void GetDescription() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); - // test execution + // act Assert.Equal("plugin.description", plugin.Description); } @@ -170,11 +170,11 @@ public void GetDescription() [Fact] public void GetIcon() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); - // test execution + // act Assert.Equal("/server/assets/img/Logo.png", plugin.Icon.ToString()); } @@ -188,13 +188,13 @@ public void GetIcon() [InlineData(null, null)] public void Boot(string pluginId, string expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); var pluginManager = componentHub.PluginManager as PluginManager; pluginManager.Register(); var plugin = componentHub.PluginManager.GetPlugin(pluginId); - // test execution + // act pluginManager.Boot(plugin); Assert.Single(componentHub.PluginManager.Plugins); @@ -211,13 +211,13 @@ public void Boot(string pluginId, string expected) [InlineData(null, null)] public void ShutDown(string pluginId, string expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); var pluginManager = componentHub.PluginManager as PluginManager; pluginManager.Register(); var plugin = componentHub.PluginManager.GetPlugin(pluginId); - // test execution + // act pluginManager.ShutDown(plugin); Assert.Single(componentHub.PluginManager.Plugins); @@ -230,11 +230,11 @@ public void ShutDown(string pluginId, string expected) [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var pluginManager = componentHub.PluginManager as PluginManager; - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(pluginManager.GetType())); } @@ -244,10 +244,10 @@ public void IsIComponentManager() [Fact] public void IsIContext() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act foreach (var plugin in componentHub.PluginManager.Plugins) { Assert.True(typeof(IContext).IsAssignableFrom(plugin.GetType()), $"Plugin context {plugin.GetType().Name} does not implement IContext."); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestResourceManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestResourceManager.cs index 7a6728d..71fefd6 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestResourceManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestResourceManager.cs @@ -17,10 +17,10 @@ public class UnitTestResourceManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.Equal(12, componentHub.ResourceManager.Resources.Count()); } @@ -30,12 +30,12 @@ public void Register() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); var resourceManager = componentHub.ResourceManager as ResourceManager; - // test execution + // act resourceManager.Remove(plugin); Assert.Empty(componentHub.ResourceManager.Resources); @@ -59,12 +59,12 @@ public void Remove() [InlineData(typeof(TestApplicationC), typeof(TestResourceD), "webexpress.webcore.test.www.resources.testresourced")] public void Id(Type applicationType, Type resourceType, string id) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var resource = componentHub.ResourceManager.GetResorces(resourceType, application)?.FirstOrDefault(); - // test execution + // act Assert.Equal(id, resource?.EndpointId.ToString()); } @@ -87,12 +87,12 @@ public void Id(Type applicationType, Type resourceType, string id) public void RoutePath(Type applicationType, Type resourceType, string path) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var resource = componentHub.ResourceManager.GetResorces(resourceType, application)?.FirstOrDefault(); - // test execution + // act Assert.Equal(path, resource.Route.ToString()); } @@ -102,10 +102,10 @@ public void RoutePath(Type applicationType, Type resourceType, string path) [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.ResourceManager.GetType())); } @@ -115,10 +115,10 @@ public void IsIComponentManager() [Fact] public void IsIContext() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act foreach (var resources in componentHub.ResourceManager.Resources) { Assert.True(typeof(IContext).IsAssignableFrom(resources.GetType()), $"Resource context {resources.GetType().Name} does not implement IContext."); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs index aafbca6..65e1e29 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs @@ -21,10 +21,10 @@ public class UnitTestRestApiManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.Equal(9, componentHub.RestApiManager.RestApis.Count()); } @@ -34,12 +34,12 @@ public void Register() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); var apiManager = componentHub.RestApiManager as RestApiManager; - // test execution + // act apiManager.Remove(plugin); // validation @@ -61,12 +61,12 @@ public void Remove() [InlineData(typeof(TestApplicationC), typeof(TestRestApiC), "webexpress.webcore.test.www.api._3.testrestapic")] public void Id(Type applicationType, Type resourceType, string id) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var api = componentHub.RestApiManager.GetRestApi(resourceType, application)?.FirstOrDefault(); - // test execution + // act Assert.Equal(id, api?.EndpointId.ToString()); } @@ -85,12 +85,12 @@ public void Id(Type applicationType, Type resourceType, string id) [InlineData(typeof(TestApplicationC), typeof(TestRestApiC), "/server/api/3/testrestapic")] public void RoutePath(Type applicationType, Type resourceType, string path) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var api = componentHub.RestApiManager.GetRestApi(resourceType, application)?.FirstOrDefault(); - // test execution + // act Assert.Equal(path, api?.Route.ToString()); } @@ -109,19 +109,19 @@ public void RoutePath(Type applicationType, Type resourceType, string path) [InlineData(typeof(TestApplicationC), typeof(TestRestApiC), "3")] public void Version(Type applicationType, Type resourceType, string expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var api = componentHub.RestApiManager.GetRestApi(resourceType, application)?.FirstOrDefault(); componentHub.SitemapManager.Refresh(); var uri = componentHub.SitemapManager.GetUri(resourceType, application); - // test execution + // act var version = uri.Parameters .Where(x => x.Key == "_apiversion") .FirstOrDefault(); - // test execution + // act Assert.Equal(expected, version.Value); } @@ -134,12 +134,12 @@ public void Version(Type applicationType, Type resourceType, string expected) [InlineData(typeof(TestApplicationA), typeof(TestRestApiB), RequestMethod.GET)] public void Method(Type applicationType, Type resourceType, RequestMethod method) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var api = componentHub.RestApiManager.GetRestApi(resourceType, application)?.FirstOrDefault(); - // test execution + // act Assert.Contains(method, api?.Methods); } @@ -149,10 +149,10 @@ public void Method(Type applicationType, Type resourceType, RequestMethod method [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.RestApiManager.GetType())); } @@ -162,10 +162,10 @@ public void IsIComponentManager() [Fact] public void IsIContext() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act foreach (var api in componentHub.RestApiManager.RestApis) { Assert.True(typeof(IContext).IsAssignableFrom(api.GetType()), $"Api context {api.GetType().Name} does not implement IContext."); @@ -181,11 +181,11 @@ public void IsIContext() [InlineData(" ")] public void ValidateRequire(string input) { - // preconditions + // arrange var request = UnitTestFixture.CrerateRequestMock($"name={input}"); request.AddParameter(new Parameter("name", input, ParameterScope.Parameter)); - // test execution + // act var validator = new RestApiValidator(request) .Require("name"); @@ -203,11 +203,11 @@ public void ValidateRequire(string input) [InlineData("ab")] public void ValidateMinLength(string input) { - // preconditions + // arrange var request = UnitTestFixture.CrerateRequestMock(); request.AddParameter(new Parameter("code", input, ParameterScope.Parameter)); - // test execution + // act var validator = new RestApiValidator(request) .MinLength("code", 3); @@ -223,12 +223,12 @@ public void ValidateMinLength(string input) [InlineData(300)] public void ValidateMaxLength(int length) { - // preconditions + // arrange var input = new string('x', length); var request = UnitTestFixture.CrerateRequestMock(); request.AddParameter(new Parameter("bio", input, ParameterScope.Parameter)); - // test execution + // act var validator = new RestApiValidator(request) .MaxLength("bio", 255); @@ -246,11 +246,11 @@ public void ValidateMaxLength(int length) [InlineData("@nouser.com")] public void ValidateEmail(string email) { - // preconditions + // arrange var request = UnitTestFixture.CrerateRequestMock(); request.AddParameter(new Parameter("email", email, ParameterScope.Parameter)); - // test execution + // act var validator = new RestApiValidator(request) .Email("email"); @@ -268,11 +268,11 @@ public void ValidateEmail(string email) [InlineData("123a")] public void ValidateIsInt(string input) { - // preconditions + // arrange var request = UnitTestFixture.CrerateRequestMock(); request.AddParameter(new Parameter("age", input, ParameterScope.Parameter)); - // test execution + // act var validator = new RestApiValidator(request) .IsInt("age"); @@ -289,11 +289,11 @@ public void ValidateIsInt(string input) [InlineData("WrongCase")] public void ValidateEqualTo(string input) { - // preconditions + // arrange var request = UnitTestFixture.CrerateRequestMock(); request.AddParameter(new Parameter("role", input, ParameterScope.Parameter)); - // test execution + // act var validator = new RestApiValidator(request) .EqualTo("role", "admin"); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs index ec14484..c1d630a 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs @@ -17,10 +17,10 @@ public class UnitTestSessionManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.NotNull(componentHub.SessionManager); } @@ -30,10 +30,10 @@ public void Register() [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.SessionManager.GetType())); } @@ -43,11 +43,11 @@ public void IsIComponentManager() [Fact] public void GetSession() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var request = UnitTestFixture.CrerateRequestMock(); - // test execution + // act var session = componentHub.SessionManager.GetSession(request); Assert.NotNull(session); @@ -59,12 +59,12 @@ public void GetSession() [Fact] public void AddPropertyToSession() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var request = UnitTestFixture.CrerateRequestMock(); var session = componentHub.SessionManager.GetSession(request); - // test execution + // act session.SetProperty(new SessionPropertyParameter(new Parameter("test", "test param", ParameterScope.Session))); var testProperty = session.GetProperty(); @@ -81,12 +81,12 @@ public void AddPropertyToSession() [Fact] public void RemovePropertyFromSession() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var request = UnitTestFixture.CrerateRequestMock(); var session = componentHub.SessionManager.GetSession(request); - // test execution + // act session.SetProperty(new SessionPropertyParameter(new Parameter("test", "test param", ParameterScope.Session))); session.RemoveProperty(); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSettingPageManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSettingPageManager.cs index ae0b801..098c40d 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSettingPageManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSettingPageManager.cs @@ -17,10 +17,10 @@ public class UnitTestSettingPageManager [Fact] public void RegisterSettingPages() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.Equal(9, componentHub.SettingPageManager.SettingPages.Count()); } @@ -30,10 +30,10 @@ public void RegisterSettingPages() [Fact] public void RegisterSettingCategories() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.Equal(9, componentHub.SettingPageManager.SettingCategories.Count()); } @@ -43,10 +43,10 @@ public void RegisterSettingCategories() [Fact] public void RegisterSettingGroups() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.Equal(9, componentHub.SettingPageManager.SettingGroups.Count()); } @@ -56,12 +56,12 @@ public void RegisterSettingGroups() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); var settingPageManager = componentHub.SettingPageManager as SettingPageManager; - // test execution + // act settingPageManager.Remove(plugin); Assert.Empty(componentHub.SettingPageManager.SettingPages); @@ -82,12 +82,12 @@ public void Remove() [InlineData(typeof(TestApplicationC), typeof(TestSettingPageC), "webexpress.webcore.test.www.settings.testsettingpagec")] public void Id(Type applicationType, Type resourceType, string id) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var settingPage = componentHub.SettingPageManager.GetSettingPages(resourceType, application)?.FirstOrDefault(); - // test execution + // act Assert.Equal(id, settingPage.EndpointId.ToString()); } @@ -103,12 +103,12 @@ public void Id(Type applicationType, Type resourceType, string id) [InlineData(typeof(TestApplicationC), typeof(TestSettingPageB), "webindex:settingpageb.label")] public void Title(Type applicationType, Type resourceType, string title) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var settingPage = componentHub.SettingPageManager.GetSettingPages(resourceType, application)?.FirstOrDefault(); - // test execution + // act Assert.Equal(title, settingPage.PageTitle); } @@ -127,12 +127,12 @@ public void Title(Type applicationType, Type resourceType, string title) [InlineData(typeof(TestApplicationC), typeof(TestSettingPageC), "/server/settings/testsettingpagec")] public void RoutePath(Type applicationType, Type resourceType, string path) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var settingPage = componentHub.SettingPageManager.GetSettingPages(resourceType, application)?.FirstOrDefault(); - // test execution + // act Assert.Equal(path, settingPage.Route.ToString()); } @@ -142,10 +142,10 @@ public void RoutePath(Type applicationType, Type resourceType, string path) [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.SettingPageManager.GetType())); } @@ -155,10 +155,10 @@ public void IsIComponentManager() [Fact] public void IsIContext() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act foreach (var settingPages in componentHub.SettingPageManager.SettingPages) { Assert.True(typeof(IContext).IsAssignableFrom(settingPages.GetType()), $"Page context {settingPages.GetType().Name} does not implement IContext."); @@ -174,12 +174,12 @@ public void IsIContext() [InlineData(typeof(TestApplicationC), new[] { "SettingCategory A", "SettingCategory B", "SettingCategory C" })] public void CategoryName(Type applicationType, params string[] names) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var settingCategories = componentHub.SettingPageManager.GetSettingCategories(application); - // test execution + // act Assert.Equal([.. names], [.. settingCategories.Select(x => x.Name)]); } @@ -192,12 +192,12 @@ public void CategoryName(Type applicationType, params string[] names) [InlineData(typeof(TestApplicationC), new[] { "WebExpress.WebCore.Test.TestIconBell", "WebExpress.WebCore.Test.TestIconProfile", null })] public void CategoryIcon(Type applicationType, params string[] icons) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var settingCategories = componentHub.SettingPageManager.GetSettingCategories(application); - // test execution + // act Assert.Equal([.. icons], [.. settingCategories.Select(x => x.Icon?.ToString())]); } @@ -210,12 +210,12 @@ public void CategoryIcon(Type applicationType, params string[] icons) [InlineData(typeof(TestApplicationC), new[] { "Description of category a.", "Description of category b.", "Description of category c." })] public void CategoryDescription(Type applicationType, params string[] descriptions) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var settingCategories = componentHub.SettingPageManager.GetSettingCategories(application); - // test execution + // act Assert.Equal([.. descriptions], [.. settingCategories.Select(x => x.Description)]); } @@ -228,12 +228,12 @@ public void CategoryDescription(Type applicationType, params string[] descriptio [InlineData(typeof(TestApplicationC), new[] { SettingSection.Preferences, SettingSection.Primary, SettingSection.Secondary })] public void CategorySection(Type applicationType, params SettingSection[] sections) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var settingCategories = componentHub.SettingPageManager.GetSettingCategories(application); - // test execution + // act Assert.Equal([.. sections], [.. settingCategories.Select(x => x.Section)]); } @@ -248,13 +248,13 @@ public void CategorySection(Type applicationType, params SettingSection[] sectio [InlineData(typeof(TestApplicationA), null, new[] { "SettingGroup C" })] public void GroupName(Type applicationType, Type settingCategoryType, params string[] names) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var settingCategory = componentHub.SettingPageManager.GetSettingCategories(application).FirstOrDefault(x => x.CategoryId.ToString() == settingCategoryType?.FullName.ToLower()); var settinGroups = componentHub.SettingPageManager.GetSettingGroups(application, settingCategory); - // test execution + // act Assert.Equal([.. names], [.. settinGroups.Select(x => x.Name)]); } @@ -269,13 +269,13 @@ public void GroupName(Type applicationType, Type settingCategoryType, params str [InlineData(typeof(TestApplicationA), null, new[] { "Description of group c." })] public void GroupDescription(Type applicationType, Type settingCategoryType, params string[] descriptions) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var settingCategory = componentHub.SettingPageManager.GetSettingCategories(application).FirstOrDefault(x => x.CategoryId.ToString() == settingCategoryType?.FullName.ToLower()); var settinGroups = componentHub.SettingPageManager.GetSettingGroups(application, settingCategory); - // test execution + // act Assert.Equal([.. descriptions], [.. settinGroups.Select(x => x.Description)]); } @@ -290,13 +290,13 @@ public void GroupDescription(Type applicationType, Type settingCategoryType, par [InlineData(typeof(TestApplicationA), null, new[] { SettingSection.Secondary })] public void GroupSection(Type applicationType, Type settingCategoryType, params SettingSection[] sections) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var settingCategory = componentHub.SettingPageManager.GetSettingCategories(application).FirstOrDefault(x => x.CategoryId.ToString() == settingCategoryType?.FullName.ToLower()); var settinGroups = componentHub.SettingPageManager.GetSettingGroups(application, settingCategory); - // test execution + // act Assert.Equal([.. sections], [.. settinGroups.Select(x => x.Section)]); } @@ -311,13 +311,13 @@ public void GroupSection(Type applicationType, Type settingCategoryType, params [InlineData(typeof(TestApplicationA), null)] public void GroupCategory(Type applicationType, Type settingCategoryType) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var settingCategory = componentHub.SettingPageManager.GetSettingCategories(application).FirstOrDefault(x => x.CategoryId.ToString() == settingCategoryType?.FullName.ToLower()); var settinGroups = componentHub.SettingPageManager.GetSettingGroups(application, settingCategory); - // test execution + // act Assert.Equal(settinGroups.Count(), settinGroups.Where(x => x.SettingCategory == settingCategory).Count()); } @@ -332,7 +332,7 @@ public void GroupCategory(Type applicationType, Type settingCategoryType) [InlineData(typeof(TestApplicationA), null, typeof(TestSettingPageC))] public void GetFirstSettingPage(Type applicationType, Type settingCategoryType, Type firstPageType) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); var settingCategory = componentHub.SettingPageManager.GetSettingCategories(application).FirstOrDefault(x => x.CategoryId.ToString() == settingCategoryType?.FullName.ToLower()); @@ -341,7 +341,7 @@ public void GetFirstSettingPage(Type applicationType, Type settingCategoryType, : null; var settingPage = componentHub.SettingPageManager.GetFirstSettingPage(application, settingCategory); - // test execution + // act Assert.Equal(firstPage, settingPage); } } diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs index bd036a4..9d4310e 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs @@ -22,10 +22,10 @@ public class UnitTestSitemapManager [InlineData(106)] public void Refresh(int expected) { - // preconditions + // arrange var componentManager = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act componentManager.SitemapManager.Refresh(); // validation @@ -66,13 +66,13 @@ public void Refresh(int expected) [InlineData("http://localhost:8080/uri/does/not/exist", null)] public void SearchResource(string uri, string id) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestFixture.CreateHttpContextMock(); var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); componentHub.SitemapManager.Refresh(); - // test execution + // act var searchResult = componentHub.SitemapManager.SearchResource(new System.Uri(uri), new SearchContext() { HttpServerContext = httpServerContext, @@ -113,12 +113,12 @@ public void SearchResource(string uri, string id) [InlineData(typeof(TestApplicationA), typeof(WWW.Products.Details.Index), 2, "/server/appa/products/2")] public void GetUri(Type applicationType, Type resourceType, int? param, string expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); componentHub.SitemapManager.Refresh(); - // test execution + // act var uri = componentHub.SitemapManager.GetUri(resourceType, application, [param.HasValue ? new TestParameterA(param.Value) : null]); // validation @@ -160,11 +160,11 @@ public void GetUri(Type applicationType, Type resourceType, int? param, string e [InlineData("http://localhost:8080/server/appa/products/10E96737-5C72-4C25-9E74-F96D8863D123/", "webexpress.webcore.test.www.products.details.index")] public void GetEndpoint(string uri, string expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); componentHub.SitemapManager.Refresh(); - // test execution + // act var endpoint = componentHub.SitemapManager.GetEndpoint(new UriEndpoint(uri)); // validation @@ -177,10 +177,10 @@ public void GetEndpoint(string uri, string expected) [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.SitemapManager.GetType())); } } diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSocketManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSocketManager.cs index e704689..e3cba42 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSocketManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSocketManager.cs @@ -17,12 +17,12 @@ public class UnitTestSocketManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateComponentHubMock(); var pluginManager = componentHub.PluginManager as PluginManager; var socketManager = componentHub.SocketManager as SocketManager; - // test execution + // act pluginManager.Register(); // validation @@ -37,12 +37,12 @@ public void Register() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var socketManager = componentHub.SocketManager as SocketManager; var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); - // test execution + // act socketManager.Remove(plugin); // validation @@ -58,13 +58,13 @@ public void Remove() [InlineData(typeof(TestApplicationC), "webexpress.webcore.test.testsocketa")] public void Id(Type applicationType, string id) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var applicationContext = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); var socket = componentHub.SocketManager.GetSockets(applicationContext) .FirstOrDefault(); - // test execution + // act Assert.Equal(id, socket.EndpointId?.ToString()); } @@ -77,13 +77,13 @@ public void Id(Type applicationType, string id) [InlineData(typeof(TestApplicationC), "/server/testsocketa")] public void ContextPath(Type applicationType, string contextPath) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var applicationContext = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); var socket = componentHub.SocketManager.GetSockets(applicationContext) .FirstOrDefault(); - // test execution + // act Assert.Equal(contextPath, socket.Route.ToString()); } @@ -93,10 +93,10 @@ public void ContextPath(Type applicationType, string contextPath) [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.SocketManager.GetType())); } @@ -106,10 +106,10 @@ public void IsIComponentManager() [Fact] public void IsIContext() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act foreach (var application in componentHub.SocketManager.Sockets) { Assert.True(typeof(IContext).IsAssignableFrom(application.GetType()), $"Socket context {application.GetType().Name} does not implement IContext."); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestStatusPageManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestStatusPageManager.cs index 56ded64..b6cbce4 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestStatusPageManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestStatusPageManager.cs @@ -16,10 +16,10 @@ public class UnitTestStatusPageManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.Equal(12, componentHub.StatusPageManager.StatusPages.Count()); } @@ -29,12 +29,12 @@ public void Register() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); var statusPageManager = componentHub.StatusPageManager as StatusPageManager; - // test execution + // act statusPageManager.Remove(plugin); Assert.Empty(componentHub.StatusPageManager.StatusPages); @@ -51,12 +51,12 @@ public void Remove() public void Id(Type applicationType, Type statusPageType, string id) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); var statusPage = componentHub.StatusPageManager.GetStatusPage(application, statusPageType); - // test execution + // act Assert.Equal(id, statusPage.StatusPageId.ToString()); } @@ -78,12 +78,12 @@ public void Id(Type applicationType, Type statusPageType, string id) [InlineData(typeof(TestApplicationC), typeof(TestStatusPage500), "webindex:homepage.label")] public void Title(Type applicationType, Type resourceType, string title) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); var statusPage = componentHub.StatusPageManager.GetStatusPage(application, resourceType); - // test execution + // act Assert.Equal(title, statusPage.StatusTitle); } @@ -105,12 +105,12 @@ public void Title(Type applicationType, Type resourceType, string title) [InlineData(typeof(TestApplicationC), typeof(TestStatusPage500), 500)] public void Code(Type applicationType, Type statusPageType, int? code) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); var statusPage = componentHub.StatusPageManager.GetStatusPage(application, statusPageType); - // test execution + // act Assert.Equal(code, statusPage?.StatusCode); } @@ -132,12 +132,12 @@ public void Code(Type applicationType, Type statusPageType, int? code) [InlineData(typeof(TestApplicationC), typeof(TestStatusPage500), "/server/webexpress/icon.png")] public void Icon(Type applicationType, Type statusPageType, string icon) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); var statusPage = componentHub.StatusPageManager.GetStatusPage(application, statusPageType); - // test execution + // act Assert.Equal(icon, statusPage?.StatusIcon?.ToString()); } @@ -152,12 +152,12 @@ public void Icon(Type applicationType, Type statusPageType, string icon) [InlineData(typeof(TestApplicationA), 500, 500)] public void CreateAndCheckCode(Type applicationType, int statusCode, int? expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); var statusResponse = componentHub.StatusPageManager.CreateStatusResponse("content", statusCode, application, UnitTestFixture.CreateHttpContextMock().Request); - // test execution + // act Assert.Equal(expected, statusResponse?.Status); } @@ -169,12 +169,12 @@ public void CreateAndCheckCode(Type applicationType, int statusCode, int? expect [InlineData(typeof(TestApplicationA), 500, "content", "content", 78)] public void CreateAndCheckMessage(Type applicationType, int statusCode, string content, string expected, int length) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); var statusResponse = componentHub.StatusPageManager.CreateStatusResponse(content, statusCode, application, UnitTestFixture.CreateHttpContextMock().Request); - // test execution + // act Assert.Contains(expected, statusResponse?.Content?.ToString()); Assert.Equal(length, statusResponse?.Header?.ContentLength); } @@ -185,10 +185,10 @@ public void CreateAndCheckMessage(Type applicationType, int statusCode, string c [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.StatusPageManager.GetType())); } @@ -198,10 +198,10 @@ public void IsIComponentManager() [Fact] public void IsIContext() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act foreach (var application in componentHub.StatusPageManager.StatusPages) { Assert.True(typeof(IContext).IsAssignableFrom(application.GetType()), $"Page context {application.GetType().Name} does not implement IContext."); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestTaskManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestTaskManager.cs index cc5b0db..c15ed63 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestTaskManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestTaskManager.cs @@ -15,10 +15,10 @@ public class UnitTestTaskManager [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.ResourceManager.GetType())); } @@ -28,12 +28,12 @@ public void IsIComponentManager() [Fact] public void IsCompopnent() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); componentHub.TaskManager.CreateTask("test"); - // test execution + // act foreach (var task in componentHub.TaskManager.Tasks) { Assert.True(typeof(IComponent).IsAssignableFrom(task.GetType()), $"Task {task.GetType().Name} does not implement IComponent."); @@ -46,10 +46,10 @@ public void IsCompopnent() [Fact] public void CreateSystemTask() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act var task = componentHub.TaskManager.CreateTask("test"); Assert.Equal("test", task?.Id); } @@ -60,10 +60,10 @@ public void CreateSystemTask() [Fact] public void CreateOwnTask() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act var task = componentHub.TaskManager.CreateTask("test", null, []); Assert.Equal("test", task?.Id); } @@ -74,11 +74,11 @@ public void CreateOwnTask() [Fact] public void ContainsTask() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var task = componentHub.TaskManager.CreateTask("test"); - // test execution + // act var res = componentHub.TaskManager.ContainsTask("test"); Assert.True(res); } @@ -89,11 +89,11 @@ public void ContainsTask() [Fact] public void GetTask() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var task = componentHub.TaskManager.CreateTask("test"); - // test execution + // act var res = componentHub.TaskManager.GetTask("test"); Assert.Equal(task, res); } @@ -104,11 +104,11 @@ public void GetTask() [Fact] public void RemoveTask() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var task = componentHub.TaskManager.CreateTask("test"); - // test execution + // act componentHub.TaskManager.RemoveTask(task); Assert.Empty(componentHub.TaskManager.Tasks); } diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs index f4b01a9..dfc14a6 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs @@ -16,10 +16,10 @@ public class UnitTestThemeManager [Fact] public void Register() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.Equal(6, componentHub.ThemeManager.Themes.Count()); } @@ -29,12 +29,12 @@ public void Register() [Fact] public void Remove() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var themeManager = componentHub.ThemeManager as ThemeManager; var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); - // test execution + // act themeManager.Remove(plugin); // validation @@ -47,10 +47,10 @@ public void Remove() [Fact] public void IsIComponentManager() { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - // test execution + // act Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.ThemeManager.GetType())); } @@ -66,11 +66,11 @@ public void IsIComponentManager() [InlineData(typeof(TestApplicationC), typeof(TestThemeB), "webexpress.webcore.test.testthemeb")] public void Id(Type applicationType, Type themeType, string id) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - // test execution + // act var themes = componentHub.ThemeManager.GetThemes(application, themeType); // validation @@ -95,11 +95,11 @@ public void Id(Type applicationType, Type themeType, string id) [InlineData(typeof(TestApplicationC), typeof(TestThemeB), "TestThemeB")] public void Name(Type applicationType, Type themeType, string name) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - // test execution + // act var themes = componentHub.ThemeManager.GetThemes(application, themeType); // validation @@ -124,11 +124,11 @@ public void Name(Type applicationType, Type themeType, string name) [InlineData(typeof(TestApplicationC), typeof(TestThemeB), null)] public void Description(Type applicationType, Type themeType, string expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - // test execution + // act var theme = componentHub.ThemeManager.GetThemes(application, themeType).FirstOrDefault(); // validation @@ -148,11 +148,11 @@ public void Description(Type applicationType, Type themeType, string expected) [InlineData(typeof(TestApplicationC), typeof(TestThemeB), null)] public void Image(Type applicationType, Type themeType, string expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - // test execution + // act var theme = componentHub.ThemeManager.GetThemes(application, themeType).FirstOrDefault(); // validation @@ -172,11 +172,11 @@ public void Image(Type applicationType, Type themeType, string expected) [InlineData(typeof(TestApplicationC), typeof(TestThemeB), ThemeMode.Light)] public void Mode(Type applicationType, Type themeType, ThemeMode expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - // test execution + // act var theme = componentHub.ThemeManager.GetThemes(application, themeType).FirstOrDefault(); // validation @@ -196,11 +196,11 @@ public void Mode(Type applicationType, Type themeType, ThemeMode expected) [InlineData(typeof(TestApplicationC), typeof(TestThemeB), null)] public void ThemeStyle(Type applicationType, Type themeType, string expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - // test execution + // act var theme = componentHub.ThemeManager.GetThemes(application, themeType).FirstOrDefault(); // validation diff --git a/src/WebExpress.WebCore.Test/Route/UnitTestRoute.cs b/src/WebExpress.WebCore.Test/Route/UnitTestRoute.cs index 911af39..6e07c4b 100644 --- a/src/WebExpress.WebCore.Test/Route/UnitTestRoute.cs +++ b/src/WebExpress.WebCore.Test/Route/UnitTestRoute.cs @@ -19,10 +19,10 @@ public class UnitTestRoute [InlineData("/a/b/c", "/d/e/f", "/a/b/c/d/e/f", 7)] public void ConcatString(string baseRoute, string segment, string expected, int count) { - // preconditions + // arrange var route = new RouteEndpoint(baseRoute); - // test execution + // act var concat = route.Concat(segment); Assert.Equal(expected, concat.ToString()); @@ -39,10 +39,10 @@ public void ConcatString(string baseRoute, string segment, string expected, int [InlineData("/a/b/c", "/d/e/f", "/a/b/c/d/e/f", 7)] public void ConcatSegment(string baseRoute, string segment, string expected, int count) { - // preconditions + // arrange var route = new RouteEndpoint(baseRoute); - // test execution + // act var concat = route.Concat(segment is not null ? [.. segment?.Split('/').Select(x => new UriPathSegmentConstant(x))] : null); @@ -61,7 +61,7 @@ public void ConcatSegment(string baseRoute, string segment, string expected, int [InlineData("/a/b/c", "/d/e/f", "/a/b/c/d/e/f")] public void CombinePath(string baseRoute, string pathB, string expected) { - // test execution + // act var combine = RouteEndpoint.Combine([new RouteEndpoint(baseRoute), new RouteEndpoint(pathB)]); Assert.Equal(expected, combine.ToString()); @@ -77,7 +77,7 @@ public void CombinePath(string baseRoute, string pathB, string expected) [InlineData("/a/b/c", "/d/e/f", "/a/b/c/d/e/f")] public void CombineRoute(string baseRoute, string pathB, string expected) { - // test execution + // act var combine = RouteEndpoint.Combine(new RouteEndpoint(baseRoute), [pathB]); Assert.Equal(expected, combine.ToString()); @@ -93,7 +93,7 @@ public void CombineRoute(string baseRoute, string pathB, string expected) [InlineData("/a/b/c", "/d/e/f", "/a/b/c/d/e/f")] public void CombineSegment(string baseRoute, string segment, string expected) { - // test execution + // act var combine = RouteEndpoint.Combine(new RouteEndpoint(baseRoute), segment); Assert.Equal(expected, combine.ToString()); @@ -111,7 +111,7 @@ public void CombineSegment(string baseRoute, string segment, string expected) [InlineData("/a/b/c", "/a/c", "/a/b/c")] public void RemoveSegment(string route, string segment, string expected) { - // test execution + // act var routeEndpoint = new RouteEndpoint(route); var removed = routeEndpoint.RemoveSegment(segment); diff --git a/src/WebExpress.WebCore.Test/Schedule/UnitTestClock.cs b/src/WebExpress.WebCore.Test/Schedule/UnitTestClock.cs index 004ba48..bb2a209 100644 --- a/src/WebExpress.WebCore.Test/Schedule/UnitTestClock.cs +++ b/src/WebExpress.WebCore.Test/Schedule/UnitTestClock.cs @@ -21,7 +21,7 @@ public class UnitTestClock [InlineData(-1, -10, 0, 0, (24 * 60) + (10 * 60))] public void Synchronize(int? days, int? hours, int? minutes, int? seconds, int expected) { - // preconditions + // arrange var dateTime = DateTime.Now; if (days.HasValue) @@ -46,7 +46,7 @@ public void Synchronize(int? days, int? hours, int? minutes, int? seconds, int e var clock = new Clock(dateTime); - // test execution + // act var elapsed = clock.Synchronize(); Assert.Equal(expected, elapsed.Count()); @@ -60,11 +60,11 @@ public void Synchronize(int? days, int? hours, int? minutes, int? seconds, int e [InlineData("2020-12-31 23:59:00", "2021-01-01 00:00:00", false)] public void CompareEquals(string dateTime1, string dateTime2, bool expected) { - // preconditions + // arrange var clock1 = new Clock(DateTime.ParseExact(dateTime1, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); var clock2 = new Clock(DateTime.ParseExact(dateTime2, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); - // test execution + // act Assert.Equal(expected, clock1 == clock2); } @@ -76,11 +76,11 @@ public void CompareEquals(string dateTime1, string dateTime2, bool expected) [InlineData("2020-12-31 23:59:00", "2021-01-01 00:00:00", true)] public void CompareInequality(string dateTime1, string dateTime2, bool expected) { - // preconditions + // arrange var clock1 = new Clock(DateTime.ParseExact(dateTime1, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); var clock2 = new Clock(DateTime.ParseExact(dateTime2, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); - // test execution + // act Assert.Equal(expected, clock1 != clock2); } @@ -93,11 +93,11 @@ public void CompareInequality(string dateTime1, string dateTime2, bool expected) [InlineData("2020-12-31 23:59:00", "2021-01-01 00:00:00", true)] public void CompareLess(string dateTime1, string dateTime2, bool expected) { - // preconditions + // arrange var clock1 = new Clock(DateTime.ParseExact(dateTime1, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); var clock2 = new Clock(DateTime.ParseExact(dateTime2, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); - // test execution + // act Assert.Equal(expected, clock1 < clock2); } @@ -110,11 +110,11 @@ public void CompareLess(string dateTime1, string dateTime2, bool expected) [InlineData("2020-12-31 23:59:00", "2021-01-01 00:00:00", false)] public void CompareGreater(string dateTime1, string dateTime2, bool expected) { - // preconditions + // arrange var clock1 = new Clock(DateTime.ParseExact(dateTime1, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); var clock2 = new Clock(DateTime.ParseExact(dateTime2, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); - // test execution + // act Assert.Equal(expected, clock1 > clock2); } @@ -127,11 +127,11 @@ public void CompareGreater(string dateTime1, string dateTime2, bool expected) [InlineData("2020-12-31 23:59:00", "2021-01-01 00:00:00", true)] public void CompareLessOrEqual(string dateTime1, string dateTime2, bool expected) { - // preconditions + // arrange var clock1 = new Clock(DateTime.ParseExact(dateTime1, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); var clock2 = new Clock(DateTime.ParseExact(dateTime2, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); - // test execution + // act Assert.Equal(expected, clock1 <= clock2); } @@ -144,11 +144,11 @@ public void CompareLessOrEqual(string dateTime1, string dateTime2, bool expected [InlineData("2020-12-31 23:59:00", "2021-01-01 00:00:00", false)] public void CompareGreaterOrEqual(string dateTime1, string dateTime2, bool expected) { - // preconditions + // arrange var clock1 = new Clock(DateTime.ParseExact(dateTime1, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); var clock2 = new Clock(DateTime.ParseExact(dateTime2, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); - // test execution + // act Assert.Equal(expected, clock1 >= clock2); } @@ -167,7 +167,7 @@ public void Tick(string dateTime1, string expected) var clock2 = new Clock(DateTime.ParseExact(expected, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); clock1.Tick(); - // test execution + // act Assert.Equal(clock2, clock1); } } diff --git a/src/WebExpress.WebCore.Test/Schedule/UnitTestCron.cs b/src/WebExpress.WebCore.Test/Schedule/UnitTestCron.cs index 65a1400..4053e43 100644 --- a/src/WebExpress.WebCore.Test/Schedule/UnitTestCron.cs +++ b/src/WebExpress.WebCore.Test/Schedule/UnitTestCron.cs @@ -13,45 +13,45 @@ public class UnitTestCron [Fact] public void Create_1() { - // preconditions + // arrange var context = UnitTestFixture.CreateHttpServerContextMock(); var clock = new Clock(); var cron = new Cron(context, "0-59", "*", "1-31", "1-2,3,4,5,6,7,8-10,11,12"); - // test execution + // act Assert.True(cron.Matching(clock)); } [Fact] public void Create_2() { - // preconditions + // arrange var context = UnitTestFixture.CreateHttpServerContextMock(); var dateTime = DateTime.Now; var clock = new Clock(new DateTime(dateTime.Year, 1, dateTime.Day, dateTime.Hour, dateTime.Minute, 0)); var cron = new Cron(context, "*", "*", "0-33", "2, 1-4, x"); - // test execution + // act Assert.True(cron.Matching(clock)); } [Fact] public void Create_3() { - // preconditions + // arrange var context = UnitTestFixture.CreateHttpServerContextMock(); var dateTime = DateTime.Now; var clock = new Clock(new DateTime(dateTime.Year, 12, 31, dateTime.Hour, dateTime.Minute, 0)); var cron = new Cron(context, "*", "*", "31", "12"); - // test execution + // act Assert.True(cron.Matching(clock)); } [Fact] public void Create_4() { - // preconditions + // arrange var context = UnitTestFixture.CreateHttpServerContextMock(); var dateTime = DateTime.Now; Log.Current.Clear(); @@ -59,27 +59,27 @@ public void Create_4() var clock = new Clock(new DateTime(dateTime.Year, 12, 31, dateTime.Hour, dateTime.Minute, 0)); var cron = new Cron(context, "*", "*", "*", "a"); - // test execution + // act Assert.Equal(1, context.Log.WarningCount); } [Fact] public void Create_5() { - // preconditions + // arrange var context = UnitTestFixture.CreateHttpServerContextMock(); var dateTime = DateTime.Now; var clock = new Clock(new DateTime(dateTime.Year, 12, 31, dateTime.Hour, dateTime.Minute, 0)); var cron = new Cron(context, "*", "*", "*", ""); - // test execution + // act Assert.True(cron.Matching(clock)); } [Fact] public void Create_6() { - // preconditions + // arrange var context = UnitTestFixture.CreateHttpServerContextMock(); var dateTime = DateTime.Now; Log.Current.Clear(); @@ -87,46 +87,46 @@ public void Create_6() var clock = new Clock(new DateTime(dateTime.Year, 12, 31, dateTime.Hour, dateTime.Minute, 0)); var cron = new Cron(context, "99", "*", "*", "*"); - // test execution + // act Assert.Equal(1, context.Log.WarningCount); } [Fact] public void Matching_1() { - // preconditions + // arrange var context = UnitTestFixture.CreateHttpServerContextMock(); var dateTime = DateTime.Now; var clock = new Clock(new DateTime(dateTime.Year, 12, 31, dateTime.Hour, dateTime.Minute, 0)); var cron = new Cron(context, "*", "*", "31", "1-11"); - // test execution + // act Assert.False(cron.Matching(clock)); } [Fact] public void Matching_2() { - // preconditions + // arrange var context = UnitTestFixture.CreateHttpServerContextMock(); var dateTime = DateTime.Now; var clock = new Clock(new DateTime(2020, 1, 1, dateTime.Hour, dateTime.Minute, 0)); // wednesday var cron = new Cron(context, "*", "*", "*", "*", "3"); // wednesday - // test execution + // act Assert.True(cron.Matching(clock)); } [Fact] public void Matching_3() { - // preconditions + // arrange var context = UnitTestFixture.CreateHttpServerContextMock(); var dateTime = DateTime.Now; var clock = new Clock(new DateTime(2020, 1, 1, dateTime.Hour, dateTime.Minute, 0)); // wednesday var cron = new Cron(context, "*", "*", "*", "*", "1"); // sunday - // test execution + // act Assert.False(cron.Matching(clock)); } } diff --git a/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs b/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs index 820bb21..2f588da 100644 --- a/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs +++ b/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs @@ -25,13 +25,13 @@ public class UnitTestUri [InlineData(UriScheme.Http, "example.com", "user", "80", "/abc", "a=1&b=2", "fragment", "http://user@example.com/abc?a=1&b=2#fragment")] public void UriAbsolute(UriScheme scheme, string authority, string user, string port, string path, string query, string fragment, string expected) { - // preconditions + // arrange var uriUser = user is not null ? user + "@" : ""; var uriPort = port is not null ? ":" + port : null; var uriQuery = query is not null ? "?" + query : ""; var uriFragment = fragment is not null ? "#" + fragment : null; - // test execution + // act var uri = new UriEndpoint($"{scheme}://{uriUser}{authority}{uriPort}{path}{uriQuery}{uriFragment}"); // validation @@ -63,11 +63,11 @@ public void UriAbsolute(UriScheme scheme, string authority, string user, string [InlineData("/assets/img/example.svg", null, null, "/assets/img/example.svg")] public void UriRelative(string path, string query, string fragment, string expected) { - // preconditions + // arrange var uriQuery = query is not null ? "?" + query : ""; var uriFragment = fragment is not null ? "#" + fragment : null; - // test execution + // act var uri = new UriEndpoint($"{path}{uriQuery}{uriFragment}"); // validation @@ -89,10 +89,10 @@ public void UriRelative(string path, string query, string fragment, string expec [InlineData("/a/b/c", "/d/e/f", "/a/b/c/d/e/f", 7)] public void Concat(string path, string segment, string expected, int count) { - // preconditions + // arrange var uri = new UriEndpoint(path); - // test execution + // act var concat = uri.Concat(segment); // validation @@ -112,10 +112,10 @@ public void Concat(string path, string segment, string expected, int count) [InlineData("/a/b/c", 5, null)] public void Skip(string path, int skipCount, string expected) { - // preconditions + // arrange var uri = new UriEndpoint(path); - // test execution + // act var skip = uri.Skip(skipCount); // validation @@ -139,10 +139,10 @@ public void Skip(string path, int skipCount, string expected) [InlineData("/a/b/c", -5, null)] public void Take(string path, int takeCount, string expected) { - // preconditions + // arrange var uri = new UriEndpoint(path); - // test execution + // act var take = uri.Take(takeCount); // validation @@ -159,7 +159,7 @@ public void Take(string path, int takeCount, string expected) [InlineData("http://www.example.com/a/$guid/c", "/a/$guid/c", "http://www.example.com/a/$guid/c")] public void Merge(string uri, string route, string expected) { - // preconditions + // arrange var random = Guid.NewGuid().ToString(); var uriEndpoint = new UriEndpoint(uri.Replace("$guid", random)); var routeEndpoint = new RouteEndpoint @@ -172,7 +172,7 @@ [.. route.Split('/').Select )] ); - // test execution + // act var resourceUri = new UriEndpoint(uriEndpoint, routeEndpoint.PathSegments); // validation @@ -187,7 +187,7 @@ [.. route.Split('/').Select [InlineData("http://user@example.com/a/b/c/x/y/z", "http://user@example.com/a/b/c", "http://user@example.com/a/b/c")] public void BasePath(string uri, string baseUri, string expected) { - // test execution + // act var resourceUri = new UriEndpoint(uri) { BasePath = new UriEndpoint(baseUri) @@ -208,12 +208,12 @@ public void BasePath(string uri, string baseUri, string expected) [InlineData("http://user@example.com/a/b/c", "myfragment", "http://user@example.com/a/b/c#myfragment")] public void SetFragment(string uri, string fragment, string expected) { - // preconditions + // arrange var resourceUri = (IUri)new UriEndpoint(uri) { }; - // test execution + // act resourceUri = resourceUri.SetFragment(fragment); // validation @@ -235,7 +235,7 @@ public void SetFragment(string uri, string fragment, string expected) [InlineData(typeof(TestApplicationC), typeof(Contact), null)] public void GetDisplayText(Type applicationType, Type resourceType, string expected) { - // preconditions + // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); componentHub.SitemapManager.Refresh(); @@ -243,7 +243,7 @@ public void GetDisplayText(Type applicationType, Type resourceType, string expec var endpoint = componentHub.SitemapManager.GetEndpoint(page.Route.ToUri()); var renderContext = UnitTestFixture.CrerateRenderContextMock(application); - // test execution + // act var display = endpoint.Route.ToUri().GetDisplayText(renderContext); // validation From 52e577177a805a1fb63bf7d772443587e2cd567d Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 25 Jan 2026 09:50:00 +0100 Subject: [PATCH 26/53] feat: general improvements and minor bugs --- src/WebExpress.WebCore/WebUri/IUri.cs | 2 +- src/WebExpress.WebCore/WebUri/UriEndpoint.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WebExpress.WebCore/WebUri/IUri.cs b/src/WebExpress.WebCore/WebUri/IUri.cs index 2fcadc4..ba66db1 100644 --- a/src/WebExpress.WebCore/WebUri/IUri.cs +++ b/src/WebExpress.WebCore/WebUri/IUri.cs @@ -170,6 +170,6 @@ public interface IUri /// /// A new endpoint uri with the populated parameters. /// - IUri SetParameters(params Parameter[] parameters); + IUri SetParameters(params IParameter[] parameters); } } diff --git a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs index 865922d..eb51ac2 100644 --- a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs +++ b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs @@ -391,7 +391,7 @@ public bool StartsWith(IUri uri) /// /// A new endpoint uri with the populated parameters. /// - public virtual IUri SetParameters(params Parameter[] parameters) + public virtual IUri SetParameters(params IParameter[] parameters) { var pathSegments = PathSegments.AsEnumerable(); From 792c31e23322e107232f89096f726fbfc54b63ff Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 25 Jan 2026 14:07:02 +0100 Subject: [PATCH 27/53] feat: general improvements and minor bugs --- .../Manager/UnitTestRestApiManager.cs | 16 ++++++++-------- .../Manager/UnitTestSitemapManager.cs | 12 ++++++------ .../WWW/Api/{1 => _1_}/TestRestApiA.cs | 2 +- .../WWW/Api/{2 => _2}/TestRestApiB.cs | 0 .../WWW/Api/{3 => _3_}/TestRestApiC.cs | 2 +- .../WebRestApi/RestApiManager.cs | 2 +- 6 files changed, 17 insertions(+), 17 deletions(-) rename src/WebExpress.WebCore.Test/WWW/Api/{1 => _1_}/TestRestApiA.cs (98%) rename src/WebExpress.WebCore.Test/WWW/Api/{2 => _2}/TestRestApiB.cs (100%) rename src/WebExpress.WebCore.Test/WWW/Api/{3 => _3_}/TestRestApiC.cs (98%) diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs index 65e1e29..973215a 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs @@ -1,7 +1,7 @@ using WebExpress.WebCore.Test.Fixture; -using WebExpress.WebCore.Test.WWW.Api._1; +using WebExpress.WebCore.Test.WWW.Api._1_; using WebExpress.WebCore.Test.WWW.Api._2; -using WebExpress.WebCore.Test.WWW.Api._3; +using WebExpress.WebCore.Test.WWW.Api.V3; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebParameter; @@ -50,15 +50,15 @@ public void Remove() /// Test the id property of the rest api. ///
[Theory] - [InlineData(typeof(TestApplicationA), typeof(TestRestApiA), "webexpress.webcore.test.www.api._1.testrestapia")] + [InlineData(typeof(TestApplicationA), typeof(TestRestApiA), "webexpress.webcore.test.www.api._1_.testrestapia")] [InlineData(typeof(TestApplicationA), typeof(TestRestApiB), "webexpress.webcore.test.www.api._2.testrestapib")] - [InlineData(typeof(TestApplicationA), typeof(TestRestApiC), "webexpress.webcore.test.www.api._3.testrestapic")] - [InlineData(typeof(TestApplicationB), typeof(TestRestApiA), "webexpress.webcore.test.www.api._1.testrestapia")] + [InlineData(typeof(TestApplicationA), typeof(TestRestApiC), "webexpress.webcore.test.www.api.v3.testrestapic")] + [InlineData(typeof(TestApplicationB), typeof(TestRestApiA), "webexpress.webcore.test.www.api._1_.testrestapia")] [InlineData(typeof(TestApplicationB), typeof(TestRestApiB), "webexpress.webcore.test.www.api._2.testrestapib")] - [InlineData(typeof(TestApplicationB), typeof(TestRestApiC), "webexpress.webcore.test.www.api._3.testrestapic")] - [InlineData(typeof(TestApplicationC), typeof(TestRestApiA), "webexpress.webcore.test.www.api._1.testrestapia")] + [InlineData(typeof(TestApplicationB), typeof(TestRestApiC), "webexpress.webcore.test.www.api.v3.testrestapic")] + [InlineData(typeof(TestApplicationC), typeof(TestRestApiA), "webexpress.webcore.test.www.api._1_.testrestapia")] [InlineData(typeof(TestApplicationC), typeof(TestRestApiB), "webexpress.webcore.test.www.api._2.testrestapib")] - [InlineData(typeof(TestApplicationC), typeof(TestRestApiC), "webexpress.webcore.test.www.api._3.testrestapic")] + [InlineData(typeof(TestApplicationC), typeof(TestRestApiC), "webexpress.webcore.test.www.api.v3.testrestapic")] public void Id(Type applicationType, Type resourceType, string id) { // arrange diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs index 9d4310e..e8deed8 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs @@ -1,7 +1,7 @@ using WebExpress.WebCore.Test.Fixture; -using WebExpress.WebCore.Test.WWW.Api._1; +using WebExpress.WebCore.Test.WWW.Api._1_; using WebExpress.WebCore.Test.WWW.Api._2; -using WebExpress.WebCore.Test.WWW.Api._3; +using WebExpress.WebCore.Test.WWW.Api.V3; using WebExpress.WebCore.Test.WWW.Resources; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebSitemap; @@ -57,9 +57,9 @@ public void Refresh(int expected) [InlineData("http://localhost:8080/server", "webexpress.webcore.test.www.index")] [InlineData("http://localhost:8080/server/about", "webexpress.webcore.test.www.about")] [InlineData("http://localhost:8080/server/contact", "webexpress.webcore.test.www.contact")] - [InlineData("http://localhost:8080/server/appa/api/1/testrestapia", "webexpress.webcore.test.www.api._1.testrestapia")] + [InlineData("http://localhost:8080/server/appa/api/1/testrestapia", "webexpress.webcore.test.www.api._1_.testrestapia")] [InlineData("http://localhost:8080/server/appa/api/2/testrestapib", "webexpress.webcore.test.www.api._2.testrestapib")] - [InlineData("http://localhost:8080/server/appa/api/3/testrestapic", "webexpress.webcore.test.www.api._3.testrestapic")] + [InlineData("http://localhost:8080/server/appa/api/3/testrestapic", "webexpress.webcore.test.www.api.v3.testrestapic")] [InlineData("http://localhost:8080/server/appa/assets/css/mycss.css", "webexpress.webcore.test.css.mycss.css")] [InlineData("http://localhost:8080/server/appa/assets/js/myjavascript.js", "webexpress.webcore.test.js.myjavascript.js")] [InlineData("http://localhost:8080/server/appa/assets/js/myjavascript.mini.js", "webexpress.webcore.test.js.myjavascript.mini.js")] @@ -137,9 +137,9 @@ public void GetUri(Type applicationType, Type resourceType, int? param, string e [InlineData("http://localhost:8080/server/appa/assets/css/mycss.css", "webexpress.webcore.test.css.mycss.css")] [InlineData("http://localhost:8080/server/appa/assets/js/myjavascript.js", "webexpress.webcore.test.js.myjavascript.js")] [InlineData("http://localhost:8080/server/appa/assets/js/myjavascript.mini.js", "webexpress.webcore.test.js.myjavascript.mini.js")] - [InlineData("http://localhost:8080/server/appa/api/1/testrestapia", "webexpress.webcore.test.www.api._1.testrestapia")] + [InlineData("http://localhost:8080/server/appa/api/1/testrestapia", "webexpress.webcore.test.www.api._1_.testrestapia")] [InlineData("http://localhost:8080/server/appa/api/2/TestRestApiB", "webexpress.webcore.test.www.api._2.testrestapib")] - [InlineData("http://localhost:8080/server/appa/api/3/testrestapic", "webexpress.webcore.test.www.api._3.testrestapic")] + [InlineData("http://localhost:8080/server/appa/api/3/testrestapic", "webexpress.webcore.test.www.api.v3.testrestapic")] [InlineData("http://localhost:8080/server/appa", "webexpress.webcore.test.www.index")] [InlineData("http://localhost:8080/server/appa/", "webexpress.webcore.test.www.index")] [InlineData("http://localhost:8080/server/appa/about", "webexpress.webcore.test.www.about")] diff --git a/src/WebExpress.WebCore.Test/WWW/Api/1/TestRestApiA.cs b/src/WebExpress.WebCore.Test/WWW/Api/_1_/TestRestApiA.cs similarity index 98% rename from src/WebExpress.WebCore.Test/WWW/Api/1/TestRestApiA.cs rename to src/WebExpress.WebCore.Test/WWW/Api/_1_/TestRestApiA.cs index 99c97db..4d110b4 100644 --- a/src/WebExpress.WebCore.Test/WWW/Api/1/TestRestApiA.cs +++ b/src/WebExpress.WebCore.Test/WWW/Api/_1_/TestRestApiA.cs @@ -3,7 +3,7 @@ using WebExpress.WebCore.WebRestApi; using WebExpress.WebCore.WebStatusPage; -namespace WebExpress.WebCore.Test.WWW.Api._1 +namespace WebExpress.WebCore.Test.WWW.Api._1_ { /// /// A dummy class for testing purposes. diff --git a/src/WebExpress.WebCore.Test/WWW/Api/2/TestRestApiB.cs b/src/WebExpress.WebCore.Test/WWW/Api/_2/TestRestApiB.cs similarity index 100% rename from src/WebExpress.WebCore.Test/WWW/Api/2/TestRestApiB.cs rename to src/WebExpress.WebCore.Test/WWW/Api/_2/TestRestApiB.cs diff --git a/src/WebExpress.WebCore.Test/WWW/Api/3/TestRestApiC.cs b/src/WebExpress.WebCore.Test/WWW/Api/_3_/TestRestApiC.cs similarity index 98% rename from src/WebExpress.WebCore.Test/WWW/Api/3/TestRestApiC.cs rename to src/WebExpress.WebCore.Test/WWW/Api/_3_/TestRestApiC.cs index 95425e7..67c5c75 100644 --- a/src/WebExpress.WebCore.Test/WWW/Api/3/TestRestApiC.cs +++ b/src/WebExpress.WebCore.Test/WWW/Api/_3_/TestRestApiC.cs @@ -4,7 +4,7 @@ using WebExpress.WebCore.WebRestApi; using WebExpress.WebCore.WebStatusPage; -namespace WebExpress.WebCore.Test.WWW.Api._3 +namespace WebExpress.WebCore.Test.WWW.Api.V3 { /// /// A dummy class for testing purposes. diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs b/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs index 1b5e4dc..08d458a 100644 --- a/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs +++ b/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs @@ -33,7 +33,7 @@ public partial class RestApiManager : IRestApiManager, IDisposable // instantiate the dictionary; assume RestApiDictionary is a non-thread-safe collection private readonly RestApiDictionary _dictionary = []; - [GeneratedRegex(@"\.(?:_|V|v)(\d+)\.")] + [GeneratedRegex(@"(?:_|[Vv])(\d+)_?")] private static partial Regex ApiVersionRegex(); /// From 9414181f4dc11cbf4393acbddea9010920d6977e Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sat, 31 Jan 2026 13:45:33 +0100 Subject: [PATCH 28/53] feat: general improvements and minor bugs --- src/WebExpress.WebCore/WebEx.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WebExpress.WebCore/WebEx.cs b/src/WebExpress.WebCore/WebEx.cs index 171ba0c..08a461a 100644 --- a/src/WebExpress.WebCore/WebEx.cs +++ b/src/WebExpress.WebCore/WebEx.cs @@ -178,7 +178,7 @@ private void OnCancel(object sender, ConsoleCancelEventArgs e) /// The configuration file. private void OnInitialization(string args, string configFile) { - // Config laden + // load configuration using var reader = new FileStream(configFile, FileMode.Open); var serializer = new XmlSerializer(typeof(HttpServerConfig)); var config = serializer.Deserialize(reader) as HttpServerConfig; From ee7c747a96bfd4a73c9211990d2ffcf3a104d69c Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 1 Feb 2026 17:50:26 +0100 Subject: [PATCH 29/53] feat: simplified request pipeline, improved query handling, unified filter logic, and added IQuery support --- .../WebUri/UnitTestUri.cs | 19 +++++++++++++++++++ src/WebExpress.WebCore/WebUri/IUri.cs | 13 +++++++++++++ src/WebExpress.WebCore/WebUri/UriEndpoint.cs | 19 +++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs b/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs index 2f588da..a18c392 100644 --- a/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs +++ b/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs @@ -100,6 +100,25 @@ public void Concat(string path, string segment, string expected, int count) Assert.Equal(count, concat.PathSegments.Count()); } + /// + /// Test the concat method. + /// + [Theory] + [InlineData(null, null, "/")] + [InlineData("a", null, "/?a=")] + [InlineData("b", "x", "/?b=x")] + public void ConcatQuery(string key, string value, string expected) + { + // arrange + var uri = new UriEndpoint(); + + // act + var concat = uri.Concat(key is not null ? new UriQuery(key, value) : null); + + // validation + Assert.Equal(expected, concat.ToString()); + } + /// /// Test the skip method. /// diff --git a/src/WebExpress.WebCore/WebUri/IUri.cs b/src/WebExpress.WebCore/WebUri/IUri.cs index ba66db1..019848b 100644 --- a/src/WebExpress.WebCore/WebUri/IUri.cs +++ b/src/WebExpress.WebCore/WebUri/IUri.cs @@ -98,6 +98,19 @@ public interface IUri /// A new IUri instance representing the URI after concatenation. IUri Concat(params IUriPathSegment[] segments); + /// + /// Appends one or more query segments and returns a new URI instance with the + /// combined query parameters. + /// + /// + /// An array representing the query segments to append. The order of segments + /// determines their position in the resulting query string. + /// + /// + /// A new uri instance containing the original URI with the specified query segments appended. + /// + IUri Concat(params UriQuery[] query); + /// /// Return a shortened uri containing n-elements. /// count greater than 0 count elements are included diff --git a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs index eb51ac2..37e52de 100644 --- a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs +++ b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs @@ -302,6 +302,25 @@ public virtual IUri Concat(params IUriPathSegment[] segments) return copy; } + /// + /// Appends one or more query segments and returns a new URI instance with the + /// combined query parameters. + /// + /// + /// An array representing the query segments to append. The order of segments + /// determines their position in the resulting query string. + /// + /// + /// A new uri instance containing the original URI with the specified query segments appended. + /// + public IUri Concat(params UriQuery[] query) + { + var copy = new UriEndpoint((IUri)this); + copy.Query = copy.Query.Concat(query.Where(x => x is not null)); + + return copy; + } + /// /// Return a shortened uri containing n-elements. /// count greater than 0 count elements are included From 13e8366b3619c2cad0513cf8b80184503914bd64 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Mon, 2 Feb 2026 20:36:15 +0100 Subject: [PATCH 30/53] feat: add wql prompt control, general improvements and minor bugs --- .../WebAttribute/ContextPathAttribute.cs | 7 +- .../WebAttribute/IncludeSubPathsAttribute.cs | 16 ++- .../WebAttribute/SettingSectionAttribute.cs | 7 +- .../WebAttribute/TitleAttribute.cs | 6 + src/WebExpress.WebCore/WebPage/PageManager.cs | 97 +++++++++++---- .../WebResource/ResourceManager.cs | 42 +++++-- .../WebRestApi/RestApiManager.cs | 55 +++++++-- .../WebSettingPage/SettingPageManager.cs | 116 ++++++++++++++---- .../WebSitemap/SitemapManager.cs | 37 ++++-- .../WebSocket/SocketManager.cs | 66 +++++++--- 10 files changed, 348 insertions(+), 101 deletions(-) diff --git a/src/WebExpress.WebCore/WebAttribute/ContextPathAttribute.cs b/src/WebExpress.WebCore/WebAttribute/ContextPathAttribute.cs index d8554f2..0f788ae 100644 --- a/src/WebExpress.WebCore/WebAttribute/ContextPathAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/ContextPathAttribute.cs @@ -8,13 +8,18 @@ namespace WebExpress.WebCore.WebAttribute [AttributeUsage(AttributeTargets.All, AllowMultiple = false)] public class ContextPathAttribute : Attribute, IApplicationAttribute, IEndpointAttribute { + /// + /// Returns the context path associated with the current instance. + /// + public string ContextPath { get; } + /// /// Initializes a new instance of the class. /// /// The context path. public ContextPathAttribute(string contetxPath) { - + ContextPath = contetxPath; } } } diff --git a/src/WebExpress.WebCore/WebAttribute/IncludeSubPathsAttribute.cs b/src/WebExpress.WebCore/WebAttribute/IncludeSubPathsAttribute.cs index f14ea97..025fb79 100644 --- a/src/WebExpress.WebCore/WebAttribute/IncludeSubPathsAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/IncludeSubPathsAttribute.cs @@ -1,17 +1,25 @@ -namespace WebExpress.WebCore.WebAttribute +using System; + +namespace WebExpress.WebCore.WebAttribute { /// /// Determines whether all resources below the specified path (including segment) are also processed. /// - public class IncludeSubPathsAttribute : System.Attribute, IEndpointAttribute + [AttributeUsage(AttributeTargets.Class, Inherited = true, AllowMultiple = false)] + public class IncludeSubPathsAttribute : Attribute, IEndpointAttribute { + /// + /// Returns a value indicating whether subpaths are included in the operation. + /// + public bool IncludeSubPaths { get; } + /// /// Initializes a new instance of the class. /// /// All subpaths are included. - public IncludeSubPathsAttribute(bool includeSubPaths) + public IncludeSubPathsAttribute(bool includeSubPaths = true) { - + IncludeSubPaths = includeSubPaths; } } } diff --git a/src/WebExpress.WebCore/WebAttribute/SettingSectionAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SettingSectionAttribute.cs index 7fc68fb..cb09086 100644 --- a/src/WebExpress.WebCore/WebAttribute/SettingSectionAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SettingSectionAttribute.cs @@ -9,13 +9,18 @@ namespace WebExpress.WebCore.WebAttribute [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public class SettingSectionAttribute : Attribute, IEndpointAttribute, ISettingCategoryAttribute, ISettingGroupAttribute { + /// + /// Returns the configuration section associated with the current settings. + /// + public SettingSection Section { get; } + /// /// Initializes a new instance of the class. /// /// The section where the settings page is listed. public SettingSectionAttribute(SettingSection section) { - + Section = section; } } } diff --git a/src/WebExpress.WebCore/WebAttribute/TitleAttribute.cs b/src/WebExpress.WebCore/WebAttribute/TitleAttribute.cs index 0fdf584..4fba2a7 100644 --- a/src/WebExpress.WebCore/WebAttribute/TitleAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/TitleAttribute.cs @@ -6,12 +6,18 @@ [System.AttributeUsage(System.AttributeTargets.Class)] public class TitleAttribute : System.Attribute, IPageAttribute, ISettingPageAttribute, IStatusPageAttribute { + /// + /// Returns the title associated with the current instance. + /// + public string Title { get; } + /// /// Initializes a new instance of the class. /// /// The display text. public TitleAttribute(string display) { + Title = display; } } } diff --git a/src/WebExpress.WebCore/WebPage/PageManager.cs b/src/WebExpress.WebCore/WebPage/PageManager.cs index 6ca6f47..b29285a 100644 --- a/src/WebExpress.WebCore/WebPage/PageManager.cs +++ b/src/WebExpress.WebCore/WebPage/PageManager.cs @@ -331,47 +331,102 @@ private void Register(IPluginContext pluginContext, IEnumerable !x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute)) && !x.AttributeType.GetInterfaces().Contains(typeof(IPageAttribute))); - foreach (var customAttribute in pageType.CustomAttributes - .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute)))) + foreach + ( + var attribute in pageType + .GetCustomAttributes(inherit: true) + .Where(x => x.GetType().GetInterfaces().Contains(typeof(IEndpointAttribute))) + ) { - if (customAttribute.AttributeType.GetInterfaces().Contains(typeof(ISegmentAttribute))) + var attributeType = attribute.GetType(); + + // segment attribute + if (attributeType.GetInterfaces().Contains(typeof(ISegmentAttribute))) { - segment = pageType.GetCustomAttributes(customAttribute.AttributeType, false).FirstOrDefault() as ISegmentAttribute; + segment = attribute as ISegmentAttribute; + continue; } - else if (customAttribute.AttributeType == typeof(IncludeSubPathsAttribute)) + + // include subpaths + if (attributeType == typeof(IncludeSubPathsAttribute)) { - includeSubPaths = Convert.ToBoolean(customAttribute.ConstructorArguments.FirstOrDefault().Value); + includeSubPaths = (attribute as IncludeSubPathsAttribute)?.IncludeSubPaths ?? false; + continue; } - else if (customAttribute.AttributeType.Name == typeof(ConditionAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(ConditionAttribute<>).Namespace) + + // condition attribute (generic) + if (attributeType.IsGenericType + && attributeType.GetGenericTypeDefinition().Name == typeof(ConditionAttribute<>).Name + && attributeType.Namespace == typeof(ConditionAttribute<>).Namespace) { - var condition = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); - conditions.Add(Activator.CreateInstance(condition) as ICondition); + var conditionType = attributeType.GetGenericArguments().FirstOrDefault(); + if (conditionType != null) + { + conditions.Add(Activator.CreateInstance(conditionType) as ICondition); + } + continue; } - else if (customAttribute.AttributeType == typeof(CacheAttribute)) + + // cache attribute + if (attributeType == typeof(CacheAttribute)) { cache = true; + continue; } } - foreach (var customAttribute in pageType.CustomAttributes - .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IPageAttribute)))) + foreach + ( + var attribute in pageType + .GetCustomAttributes(inherit: true) + .Where(x => x.GetType().GetInterfaces().Contains(typeof(IPageAttribute))) + ) { - if (customAttribute.AttributeType.IsGenericType && customAttribute.AttributeType.GetGenericTypeDefinition() == typeof(WebIconAttribute<>)) + var attributeType = attribute.GetType(); + + // web icon attribute (generic) + if (attributeType.IsGenericType && + attributeType.GetGenericTypeDefinition() == typeof(WebIconAttribute<>)) { - var type = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); - icon ??= Activator.CreateInstance(type) as IIcon; + var iconType = attributeType.GetGenericArguments().FirstOrDefault(); + if (iconType != null) + { + icon ??= Activator.CreateInstance(iconType) as IIcon; + } + continue; } - else if (customAttribute.AttributeType == typeof(TitleAttribute)) + + // title attribute + if (attributeType == typeof(TitleAttribute)) { - title = customAttribute.ConstructorArguments.FirstOrDefault().Value?.ToString(); + title = (attribute as TitleAttribute)?.Title; + continue; } - else if (customAttribute.AttributeType.Name == typeof(ScopeAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(ScopeAttribute<>).Namespace) + + // scope attribute (generic) + if (attributeType.IsGenericType && + attributeType.GetGenericTypeDefinition().Name == typeof(ScopeAttribute<>).Name && + attributeType.Namespace == typeof(ScopeAttribute<>).Namespace) { - scopes.Add(customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault()); + var scopeType = attributeType.GetGenericArguments().FirstOrDefault(); + if (scopeType != null) + { + scopes.Add(scopeType); + } + continue; } - else if (customAttribute.AttributeType.Name == typeof(DomainAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(DomainAttribute<>).Namespace) + + // domain attribute (generic) + if (attributeType.IsGenericType && + attributeType.GetGenericTypeDefinition().Name == typeof(DomainAttribute<>).Name && + attributeType.Namespace == typeof(DomainAttribute<>).Namespace) { - domains.Add(customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault()); + var domainType = attributeType.GetGenericArguments().FirstOrDefault(); + if (domainType != null) + { + domains.Add(domainType); + } + continue; } } diff --git a/src/WebExpress.WebCore/WebResource/ResourceManager.cs b/src/WebExpress.WebCore/WebResource/ResourceManager.cs index 035ebd0..931b734 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceManager.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceManager.cs @@ -192,25 +192,47 @@ private void Register(IPluginContext pluginContext, IEnumerable !x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute)) && !x.AttributeType.GetInterfaces().Contains(typeof(IPageAttribute))); - foreach (var customAttribute in resourceType.CustomAttributes - .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute)))) + foreach + ( + var attribute in resourceType + .GetCustomAttributes(inherit: true) + .Where(x => x.GetType().GetInterfaces().Contains(typeof(IEndpointAttribute))) + ) { - if (customAttribute.AttributeType.GetInterfaces().Contains(typeof(ISegmentAttribute))) + var attributeType = attribute.GetType(); + + // segment attribute + if (attributeType.GetInterfaces().Contains(typeof(ISegmentAttribute))) { - segment = resourceType.GetCustomAttributes(customAttribute.AttributeType, false).FirstOrDefault() as ISegmentAttribute; + segment = attribute as ISegmentAttribute; + continue; } - else if (customAttribute.AttributeType == typeof(IncludeSubPathsAttribute)) + + // include subpaths + if (attributeType == typeof(IncludeSubPathsAttribute)) { - includeSubPaths = Convert.ToBoolean(customAttribute.ConstructorArguments.FirstOrDefault().Value); + includeSubPaths = (attribute as IncludeSubPathsAttribute)?.IncludeSubPaths ?? false; + continue; } - else if (customAttribute.AttributeType.Name == typeof(ConditionAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(ConditionAttribute<>).Namespace) + + // condition attribute (generic) + if (attributeType.IsGenericType + && attributeType.GetGenericTypeDefinition().Name == typeof(ConditionAttribute<>).Name + && attributeType.Namespace == typeof(ConditionAttribute<>).Namespace) { - var condition = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); - conditions.Add(Activator.CreateInstance(condition) as ICondition); + var conditionType = attributeType.GetGenericArguments().FirstOrDefault(); + if (conditionType != null) + { + conditions.Add(Activator.CreateInstance(conditionType) as ICondition); + } + continue; } - else if (customAttribute.AttributeType == typeof(CacheAttribute)) + + // cache attribute + if (attributeType == typeof(CacheAttribute)) { cache = true; + continue; } } diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs b/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs index 08d458a..8af6f0b 100644 --- a/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs +++ b/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs @@ -459,30 +459,59 @@ private void Register(IPluginContext pluginContext, IEnumerable x.Item1 is not null) .Select(x => x.Item2); - foreach (var customAttribute in restApiType.CustomAttributes - .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute)))) + foreach + ( + var attribute in restApiType + .GetCustomAttributes(inherit: true) + .Where(x => x.GetType().GetInterfaces().Contains(typeof(IEndpointAttribute)))) { - if (customAttribute.AttributeType.GetInterfaces().Contains(typeof(ISegmentAttribute))) + var attributeType = attribute.GetType(); + + // segment attribute + if (attributeType.GetInterfaces().Contains(typeof(ISegmentAttribute))) { - segment = restApiType.GetCustomAttributes(customAttribute.AttributeType, false).FirstOrDefault() as ISegmentAttribute; + segment = attribute as ISegmentAttribute; + + continue; } - else if (customAttribute.AttributeType == typeof(ContextPathAttribute)) + + // context path + if (attributeType == typeof(ContextPathAttribute)) { - contextPath = customAttribute.ConstructorArguments.FirstOrDefault().Value?.ToString(); + contextPath = (attribute as ContextPathAttribute)?.ContextPath; + + continue; } - else if (customAttribute.AttributeType == typeof(IncludeSubPathsAttribute)) + + // include subpaths + if (attributeType == typeof(IncludeSubPathsAttribute)) { - includeSubPaths = Convert.ToBoolean(customAttribute.ConstructorArguments.FirstOrDefault().Value); + includeSubPaths = (attribute as IncludeSubPathsAttribute)?.IncludeSubPaths + ?? false; + + continue; } - else if (customAttribute.AttributeType.Name == typeof(ConditionAttribute<>).Name - && customAttribute.AttributeType.Namespace == typeof(ConditionAttribute<>).Namespace) + + // condition attribute (generic) + if (attributeType.IsGenericType + && attributeType.GetGenericTypeDefinition().Name == typeof(ConditionAttribute<>).Name + && attributeType.Namespace == typeof(ConditionAttribute<>).Namespace) { - var condition = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); - conditions.Add(Activator.CreateInstance(condition) as ICondition); + var conditionType = attributeType.GetGenericArguments().FirstOrDefault(); + if (conditionType != null) + { + conditions.Add(Activator.CreateInstance(conditionType) as ICondition); + } + + continue; } - else if (customAttribute.AttributeType == typeof(CacheAttribute)) + + // cache attribute + if (attributeType == typeof(CacheAttribute)) { cache = true; + + continue; } } diff --git a/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs b/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs index 3166ec3..d8b9b4c 100644 --- a/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs +++ b/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs @@ -453,42 +453,81 @@ private void RegisterPage(IPluginContext pluginContext, IEnumerable x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute)))) + foreach + ( + var attribute in settingPageType + .GetCustomAttributes(inherit: true) + .Where(x => x.GetType().GetInterfaces().Contains(typeof(IEndpointAttribute))) + ) { - if (customAttribute.AttributeType.GetInterfaces().Contains(typeof(ISegmentAttribute))) + var attributeType = attribute.GetType(); + + // segment attribute + if (attributeType.GetInterfaces().Contains(typeof(ISegmentAttribute))) { - segment = settingPageType.GetCustomAttributes(customAttribute.AttributeType, false).FirstOrDefault() as ISegmentAttribute; + segment = attribute as ISegmentAttribute; + continue; } - else if (customAttribute.AttributeType.IsGenericType && customAttribute.AttributeType.GetGenericTypeDefinition() == typeof(SettingGroupAttribute<>)) + + // setting group (generic) + if (attributeType.IsGenericType && + attributeType.GetGenericTypeDefinition() == typeof(SettingGroupAttribute<>)) { - group = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); + group = attributeType.GetGenericArguments().FirstOrDefault(); + continue; } - else if (customAttribute.AttributeType == typeof(SettingSectionAttribute)) + + // setting section + if (attributeType == typeof(SettingSectionAttribute)) { - section = Enum.Parse(customAttribute.ConstructorArguments.FirstOrDefault().Value?.ToString()); + section = (attribute as SettingSectionAttribute)?.Section ?? default; + continue; } - else if (customAttribute.AttributeType == typeof(SettingHideAttribute)) + + // setting hide + if (attributeType == typeof(SettingHideAttribute)) { hide = true; + continue; } - else if (customAttribute.AttributeType.IsGenericType && customAttribute.AttributeType.GetGenericTypeDefinition() == typeof(WebIconAttribute<>)) + + // web icon attribute (generic) + if (attributeType.IsGenericType && + attributeType.GetGenericTypeDefinition() == typeof(WebIconAttribute<>)) { - var type = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); - icon ??= Activator.CreateInstance(type) as IIcon; + var iconType = attributeType.GetGenericArguments().FirstOrDefault(); + if (iconType != null) + { + icon ??= Activator.CreateInstance(iconType) as IIcon; + } + continue; } - else if (customAttribute.AttributeType == typeof(CacheAttribute)) + + // cache attribute + if (attributeType == typeof(CacheAttribute)) { cache = true; + continue; } - else if (customAttribute.AttributeType == typeof(IncludeSubPathsAttribute)) + + // include subpaths + if (attributeType == typeof(IncludeSubPathsAttribute)) { - includeSubPaths = Convert.ToBoolean(customAttribute.ConstructorArguments.FirstOrDefault().Value); + includeSubPaths = (attribute as IncludeSubPathsAttribute)?.IncludeSubPaths ?? false; + continue; } - else if (customAttribute.AttributeType.Name == typeof(ConditionAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(ConditionAttribute<>).Namespace) + + // condition attribute (generic) + if (attributeType.IsGenericType && + attributeType.GetGenericTypeDefinition().Name == typeof(ConditionAttribute<>).Name && + attributeType.Namespace == typeof(ConditionAttribute<>).Namespace) { - var condition = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); - conditions.Add(Activator.CreateInstance(condition) as ICondition); + var conditionType = attributeType.GetGenericArguments().FirstOrDefault(); + if (conditionType != null) + { + conditions.Add(Activator.CreateInstance(conditionType) as ICondition); + } + continue; } } @@ -497,19 +536,46 @@ private void RegisterPage(IPluginContext pluginContext, IEnumerable x.AttributeType.GetInterfaces().Contains(typeof(ISettingPageAttribute)))) + foreach + ( + var attribute in settingPageType + .GetCustomAttributes(inherit: true) + .Where(x => x.GetType().GetInterfaces().Contains(typeof(ISettingPageAttribute))) + ) { - if (customAttribute.AttributeType == typeof(TitleAttribute)) + var attributeType = attribute.GetType(); + + // title attribute + if (attributeType == typeof(TitleAttribute)) { - title = customAttribute.ConstructorArguments.FirstOrDefault().Value?.ToString(); + title = (attribute as TitleAttribute)?.Title; + continue; } - else if (customAttribute.AttributeType.Name == typeof(ScopeAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(ScopeAttribute<>).Namespace) + + // scope attribute (generic) + if (attributeType.IsGenericType && + attributeType.GetGenericTypeDefinition().Name == typeof(ScopeAttribute<>).Name && + attributeType.Namespace == typeof(ScopeAttribute<>).Namespace) { - scopes.Add(customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault()); + var scopeType = attributeType.GetGenericArguments().FirstOrDefault(); + if (scopeType != null) + { + scopes.Add(scopeType); + } + continue; } - else if (customAttribute.AttributeType.Name == typeof(DomainAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(DomainAttribute<>).Namespace) + + // domain attribute (generic) + if (attributeType.IsGenericType && + attributeType.GetGenericTypeDefinition().Name == typeof(DomainAttribute<>).Name && + attributeType.Namespace == typeof(DomainAttribute<>).Namespace) { - domains.Add(customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault()); + var domainType = attributeType.GetGenericArguments().FirstOrDefault(); + if (domainType != null) + { + domains.Add(domainType); + } + continue; } } diff --git a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs index 078767a..1e75d52 100644 --- a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs +++ b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs @@ -133,13 +133,22 @@ public SearchResult SearchResource(Uri requestUri, SearchContext searchContext) } /// - /// Returns the URI for this type based on the sitemap configuration, taking into account the specific context - /// in which the URI is valid. + /// Returns the URI for this type based on the sitemap configuration, taking into account + /// the specific context in which the URI is valid. /// - /// The class from which the URI is to be determined. URI route must not have any dynamic components (such as '/a/guid/b'). - /// The application context. - /// The parameters to be considered for the uri. - /// Returns the URI taking into account the context, or null if no valid URI is found. + /// + /// The class from which the URI is to be determined. URI route must not have any dynamic + /// components (such as '/a/guid/b'). + /// + /// + /// The application context. + /// + /// + /// The parameters to be considered for the uri. + /// + /// + /// Returns the URI taking into account the context, or null if no valid URI is found. + /// public IUri GetUri(IApplicationContext applicationContext, params Parameter[] parameters) where TEndpoint : IEndpoint { @@ -165,11 +174,19 @@ public IUri GetUri(Type endpointType, IApplicationContext applicationContext, pa } /// - /// Returns the URI for this type based on the sitemap configuration, taking into account the specific context in which the URI is valid. + /// Returns the URI for this type based on the sitemap configuration, taking into account + /// the specific context in which the URI is valid. /// - /// The class from which the URI is to be determined. URI route must not have any dynamic components (such as '/a/guid/b'). - /// The endpoint context. - /// Returns the URI taking into account the context, or null if no valid URI is found. + /// + /// The class from which the URI is to be determined. URI route must not have any dynamic + /// components (such as '/a/guid/b'). + /// + /// + /// The endpoint context. + /// + /// + /// Returns the URI taking into account the context, or null if no valid URI is found. + /// public IUri GetUri(IEndpointContext endpointContext) where TEnpoint : IEndpoint { diff --git a/src/WebExpress.WebCore/WebSocket/SocketManager.cs b/src/WebExpress.WebCore/WebSocket/SocketManager.cs index 2d5a6ce..9232cc2 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketManager.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketManager.cs @@ -312,38 +312,72 @@ private void Register(IPluginContext pluginContext, IEnumerable !x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute))); - foreach (var customAttribute in socketType.CustomAttributes - .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute)))) + foreach + ( + var attribute in socketType + .GetCustomAttributes(inherit: true) + .Where(x => x.GetType().GetInterfaces().Contains(typeof(IEndpointAttribute))) + ) { - if (customAttribute.AttributeType == typeof(IncludeSubPathsAttribute)) + var attributeType = attribute.GetType(); + + // include subpaths + if (attributeType == typeof(IncludeSubPathsAttribute)) { - includeSubPaths = Convert.ToBoolean(customAttribute.ConstructorArguments.FirstOrDefault().Value); + includeSubPaths = (attribute as IncludeSubPathsAttribute)?.IncludeSubPaths + ?? false; + continue; } - else if (customAttribute.AttributeType.Name == typeof(ConditionAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(ConditionAttribute<>).Namespace) + + // condition attribute (generic) + if (attributeType.IsGenericType && + attributeType.GetGenericTypeDefinition().Name == typeof(ConditionAttribute<>).Name && + attributeType.Namespace == typeof(ConditionAttribute<>).Namespace) { - var condition = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); - conditions.Add(Activator.CreateInstance(condition) as ICondition); + var conditionType = attributeType.GetGenericArguments().FirstOrDefault(); + if (conditionType != null) + { + conditions.Add(Activator.CreateInstance(conditionType) as ICondition); + } + continue; } - else if (customAttribute.AttributeType == typeof(CacheAttribute)) + + // cache attribute + if (attributeType == typeof(CacheAttribute)) { cache = true; + continue; } } - foreach (var customAttribute in socketType.CustomAttributes - .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(ISocketAttribute)))) + foreach + ( + var attribute in socketType + .GetCustomAttributes(inherit: true) + .Where(x => x.GetType().GetInterfaces().Contains(typeof(ISocketAttribute))) + ) { - if (customAttribute.AttributeType == typeof(MessageTypeAttribute)) + var attributeType = attribute.GetType(); + + // MESSAGE TYPE + if (attributeType == typeof(MessageTypeAttribute)) { - messageType = Enum.Parse(customAttribute.ConstructorArguments.FirstOrDefault().Value.ToString()); + messageType = (attribute as MessageTypeAttribute)?.MessageType ?? default; + continue; } - else if (customAttribute.AttributeType == typeof(SubProtocolAttribute)) + + // SUB PROTOCOL + if (attributeType == typeof(SubProtocolAttribute)) { - subProtocol = customAttribute.ConstructorArguments.FirstOrDefault().Value.ToString(); + subProtocol = (attribute as SubProtocolAttribute)?.SubProtocol; + continue; } - else if (customAttribute.AttributeType == typeof(MaxMessageSizeAttribute)) + + // MAX MESSAGE SIZE + if (attributeType == typeof(MaxMessageSizeAttribute)) { - maxMessageSize = Convert.ToUInt64(customAttribute.ConstructorArguments.FirstOrDefault().Value.ToString()); + maxMessageSize = (attribute as MaxMessageSizeAttribute)?.MaxMessageSize ?? 0; + continue; } } From 862328c772211da8006baf7af0b15496d8701977 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Tue, 3 Feb 2026 20:54:59 +0100 Subject: [PATCH 31/53] feat: general improvements and minor bugs --- .../WebUri/UnitTestUri.cs | 23 ++++++++++ src/WebExpress.WebCore/WebUri/UriEndpoint.cs | 42 ++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs b/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs index a18c392..40b1274 100644 --- a/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs +++ b/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs @@ -168,6 +168,29 @@ public void Take(string path, int takeCount, string expected) Assert.Equal(expected, take?.ToString()); } + /// + /// Test the take last method. + /// + [Theory] + [InlineData("/a/b/c", 0, "/a/b/c")] + [InlineData("/a/b/c", 1, "/c")] + [InlineData("/a/b/c", 2, "/b/c")] + [InlineData("/a/b/c", 3, "/a/b/c")] + [InlineData("/a/b/c", 4, "/a/b/c")] + [InlineData("/a/b/c", 5, "/a/b/c")] + [InlineData("/a/b/c", -5, "/a/b/c")] + public void TakeLast(string path, int takeCount, string expected) + { + // arrange + var uri = new UriEndpoint(path); + + // act + var take = uri.TakeLast(takeCount); + + // validation + Assert.Equal(expected, take?.ToString()); + } + /// /// Test the merge method. /// diff --git a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs index 37e52de..470a8c3 100644 --- a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs +++ b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; @@ -355,6 +355,46 @@ public virtual IUri Take(int count) return copy; } + /// + /// Returns a new URI containing the last path segments. + /// + /// + /// The number of trailing path segments to include. + /// + /// + /// • If is 0, the full URI is returned. + /// • If is positive, the last segments are returned. + /// • If exceeds the number of segments, the full URI is returned. + /// • Negative values are not allowed and result in null. + /// + /// + /// + /// A new URI containing the selected trailing segments, or null if + /// is negative. + /// + public virtual IUri TakeLast(int count) + { + var copy = new UriEndpoint((IUri)this); + var path = copy.PathSegments.ToList(); + + // negative values → return full URI + if (count < 0) + { + return copy; + } + + // 0 or count >= total → full URI + if (count is 0 || count >= path.Count) + { + return copy; + } + + // take last n segments + copy.PathSegments = [.. path.Skip(path.Count - count)]; + + return copy; + } + /// /// Return a shortened uri by not including the first n elements. /// count greater than 0 count elements are skipped From 89dfa28f65679200c554581b3534e3d86fcd77f0 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Mon, 9 Feb 2026 22:21:26 +0100 Subject: [PATCH 32/53] feat: general improvements and minor bugs --- .../Manager/UnitTestFragmentManager.cs | 28 +++++++++++++++++++ .../WebFragment/FragmentManager.cs | 3 +- .../WebFragment/Model/FragmentDictionary.cs | 18 ++++++------ 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentManager.cs index 4a9b406..66859b6 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentManager.cs @@ -40,6 +40,7 @@ public void Remove() // act fragmentManager.Remove(plugin); + // validation Assert.Empty(componentHub.FragmentManager.Fragments); } @@ -84,6 +85,7 @@ public void Id(Type applicationType, Type fragmentType, string id) return; } + // validation Assert.Contains(id, fragment.Select(x => x.FragmentId?.ToString())); } @@ -106,6 +108,31 @@ public void GetFragments(Type applicationType, Type scopeType, int count) // act var fragments = componentHub.FragmentManager.GetFragments(application, renderContext?.PageContext?.Scopes).ToList(); + // validation + Assert.NotNull(fragments); + Assert.Equal(count, fragments.Count); + } + + /// + /// Test the get fragment function of the fragment. + /// + [Theory] + [InlineData(typeof(TestApplicationA), typeof(IScope), 0)] + [InlineData(typeof(TestApplicationA), typeof(TestScopeA), 1)] + [InlineData(typeof(TestApplicationA), typeof(About), 1)] + [InlineData(typeof(TestApplicationB), typeof(IScope), 0)] + [InlineData(typeof(TestApplicationB), typeof(About), 1)] + public void GetFragmentsBase(Type applicationType, Type scopeType, int count) + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); + var renderContext = UnitTestFixture.CrerateRenderContextMock(application, [scopeType]); + + // act + var fragments = componentHub.FragmentManager.GetFragments(application, renderContext?.PageContext?.Scopes).ToList(); + + // validation Assert.NotNull(fragments); Assert.Equal(count, fragments.Count); } @@ -130,6 +157,7 @@ public void Render(Type applicationType, Type sectionType, Type scopeType, bool // act var html = componentHub.FragmentManager.Render(renderContext, visualTree, sectionType); + // validation Assert.NotNull(html); if (!empty) diff --git a/src/WebExpress.WebCore/WebFragment/FragmentManager.cs b/src/WebExpress.WebCore/WebFragment/FragmentManager.cs index 51bef5c..b2b93ae 100644 --- a/src/WebExpress.WebCore/WebFragment/FragmentManager.cs +++ b/src/WebExpress.WebCore/WebFragment/FragmentManager.cs @@ -352,7 +352,8 @@ public IEnumerable GetFragments(Type fragmentType) /// The fragment type. /// The application context. /// An enumeration of the filtered fragment contexts. - public IEnumerable GetFragments(IApplicationContext applicationContext) where TFragment : IFragmentBase + public IEnumerable GetFragments(IApplicationContext applicationContext) + where TFragment : IFragmentBase { return GetFragments(applicationContext, typeof(TFragment)); } diff --git a/src/WebExpress.WebCore/WebFragment/Model/FragmentDictionary.cs b/src/WebExpress.WebCore/WebFragment/Model/FragmentDictionary.cs index c1a5ebd..51631b2 100644 --- a/src/WebExpress.WebCore/WebFragment/Model/FragmentDictionary.cs +++ b/src/WebExpress.WebCore/WebFragment/Model/FragmentDictionary.cs @@ -150,15 +150,15 @@ public IEnumerable GetFragmentItems(IApplicationContext applicatio public IEnumerable GetFragmentItems(IApplicationContext applicationContext, Type fragment, Type section, IEnumerable scopes) { return _dict.Values - .SelectMany(x => x) - .Where(x => x.Key == applicationContext) - .SelectMany(x => x.Value) - .Where(x => x.Key == section || section.IsAssignableFrom(x.Key)) - .SelectMany(x => x.Value) - .Where(x => scopes.Any(y => x.Key == y)) - .SelectMany(x => x.Value) - .Where(x => x.FragmentClass == fragment || fragment.IsAssignableFrom(x.FragmentClass)) - .OrderBy(x => x.Order); + .SelectMany(x => x) + .Where(x => x.Key == applicationContext) + .SelectMany(x => x.Value) + .Where(x => x.Key == section || section.IsAssignableFrom(x.Key)) + .SelectMany(x => x.Value) + .Where(x => scopes.Any(y => x.Key == y)) + .SelectMany(x => x.Value) + .Where(x => x.FragmentClass == fragment || fragment.IsAssignableFrom(x.FragmentClass)) + .OrderBy(x => x.Order); } /// From 6b7fc5d4703af2884ff9b4577f64bdfc442cb6b5 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Wed, 11 Feb 2026 22:09:38 +0100 Subject: [PATCH 33/53] feat: replace modal properties with primary/secondary action model --- src/WebExpress.WebCore/WebHtml/HtmlElementTextSemanticsA.cs | 2 +- src/WebExpress.WebCore/WebHtml/TypeTarget.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementTextSemanticsA.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementTextSemanticsA.cs index 025bb3e..5be976d 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementTextSemanticsA.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementTextSemanticsA.cs @@ -56,7 +56,7 @@ public string Href public TypeTarget Target { get => (TypeTarget)Enum.Parse(typeof(TypeTarget), GetAttribute("target")); - set => SetAttribute("target", value.ToStringValue()); + set => SetAttribute("target", value.ToValue()); } /// diff --git a/src/WebExpress.WebCore/WebHtml/TypeTarget.cs b/src/WebExpress.WebCore/WebHtml/TypeTarget.cs index ae56f13..cb70051 100644 --- a/src/WebExpress.WebCore/WebHtml/TypeTarget.cs +++ b/src/WebExpress.WebCore/WebHtml/TypeTarget.cs @@ -46,7 +46,7 @@ public static class TypeTargetExtensions /// /// The call target. /// The plain text of the target. - public static string ToStringValue(this TypeTarget target) + public static string ToValue(this TypeTarget target) { return target switch { From ff532e1e97388df8a3d01528947f1311769a55f9 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 15 Feb 2026 07:42:50 +0100 Subject: [PATCH 34/53] feat: id generation to deterministic or random id providers --- .../Html/UnitTestDeterministicId.cs | 83 ++++++++++++ .../Html/UnitTestRandomId.cs | 100 ++++++++++++++ .../WebHtml/DeterministicId.cs | 125 ++++++++++++++++++ src/WebExpress.WebCore/WebHtml/RandomId.cs | 43 ++++++ 4 files changed, 351 insertions(+) create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestDeterministicId.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestRandomId.cs create mode 100644 src/WebExpress.WebCore/WebHtml/DeterministicId.cs create mode 100644 src/WebExpress.WebCore/WebHtml/RandomId.cs diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestDeterministicId.cs b/src/WebExpress.WebCore.Test/Html/UnitTestDeterministicId.cs new file mode 100644 index 0000000..d2aec90 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestDeterministicId.cs @@ -0,0 +1,83 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the DeterministicId class. + /// + [Collection("NonParallelTests")] + public class UnitTestDeterministicId + { + /// + /// Tests the create method. + /// + [Fact] + public void CreateSameCallsite() + { + // act + var id1 = CallCreate(); + var id2 = CallCreate(); + + // validation + Assert.NotEqual(id1, id2); + } + + /// + /// Tests the create method. + /// + [Fact] + public void CreateIndexes() + { + // act + var id1 = CallCreate(0); + var id2 = CallCreate(1); + + // validation + Assert.NotEqual(id1, id2); + } + + /// + /// Tests the create method. + /// + [Fact] + public void CreateDifferentCallsites() + { + // arrange + var CallsiteA = new Func(() => CallCreate()); + var CallsiteB = new Func(() => CallCreate()); + + // act + var id1 = CallsiteA(); + var id2 = CallsiteB(); + + // validation + Assert.NotEqual(id1, id2); + } + + + /// + /// Generates a deterministic identifier. + /// + /// A string that represents the generated deterministic identifier. + /// + private string CallCreate() + { + return DeterministicId.Create(); + } + + /// + /// Generates a deterministic identifier based on the specified index. + /// + /// + /// The optional content used to influence the generated identifier. If + /// null, a default identifier is created. + /// + /// + /// A string that represents the generated deterministic identifier. + /// + private string CallCreate(object contet = null) + { + return DeterministicId.Create(contet); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestRandomId.cs b/src/WebExpress.WebCore.Test/Html/UnitTestRandomId.cs new file mode 100644 index 0000000..7b9554b --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestRandomId.cs @@ -0,0 +1,100 @@ +using System.Collections.Concurrent; +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the RandomId class. + /// + [Collection("NonParallelTests")] + public class UnitTestRandomId + { + /// + /// Tests the create method. + /// + [Fact] + public void CreateDifferent() + { + // act + var id1 = RandomId.Create(); + var id2 = RandomId.Create(); + + // validation + Assert.NotEqual(id1, id2); + } + + /// + /// Tests the create method. + /// + [Fact] + public void Prefix() + { + // act + var id = RandomId.Create(); + + // validation + Assert.StartsWith("id_", id); + } + + /// + /// Tests the create method. + /// + [Fact] + public void Length() + { + // act + var id = RandomId.Create(); + + // validation + Assert.Equal("id_".Length + 32, id.Length); + } + + /// + /// Tests the create method. + /// + [Fact] + public void HexCharacters() + { + // act + var id = RandomId.Create(); + + // validation + var hex = id.Substring(4); + Assert.Matches("^[0-9A-F]+$", hex); + } + + /// + /// Tests the create method. + /// + [Fact] + public void ThreadSafe() + { + // arrange + var results = new ConcurrentBag(); + + Parallel.For(0, 5000, _ => + { + // act + results.Add(RandomId.Create()); + }); + + // validation + var distinct = results.Distinct().Count(); + + Assert.Equal(results.Count, distinct); + } + + /// + /// Tests the create method. + /// + [Fact] + public void NotNullOrEmpty() + { + // act + var id = RandomId.Create(); + + // validation + Assert.False(string.IsNullOrWhiteSpace(id)); + } + } +} diff --git a/src/WebExpress.WebCore/WebHtml/DeterministicId.cs b/src/WebExpress.WebCore/WebHtml/DeterministicId.cs new file mode 100644 index 0000000..7764b69 --- /dev/null +++ b/src/WebExpress.WebCore/WebHtml/DeterministicId.cs @@ -0,0 +1,125 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; + +namespace WebExpress.WebCore.WebHtml +{ + /// + /// Provides methods for generating deterministic unique identifiers + /// based on the caller's file path, line number, and an optional + /// index value. + /// + /// + /// This class utilizes a caching mechanism to ensure that repeated + /// calls with the same parameters return the same identifier, + /// enhancing performance and consistency. + /// + public static class DeterministicId + { + private static readonly ConcurrentDictionary Cache = new(); + + /// + /// Generates a deterministic unique identifier based on the caller's file + /// path, line number, and an optional index value. + /// + /// + /// This method uses a caching mechanism to ensure that repeated calls with + /// the same parameters return the same identifier. The generated identifier + /// is based on a FNV-1a hash of the signature formed from the file path, + /// line number, and index. + /// + /// + /// An optional object that provides additional context for generating the + /// identifier. If specified, its hash code is included in the identifier + /// to further distinguish it. + /// + /// + /// The full path of the source file where the method is called. This + /// value is automatically supplied by the compiler. + /// + /// + /// The line number in the source file where the method is called. This + /// value is automatically supplied by the compiler. + /// + /// + /// A unique identifier string that represents the combination of the file path, + /// line number, and optional index. + /// + public static string Create + ( + object context = null, + [CallerFilePath] string file = "", + [CallerLineNumber] int line = 0 + ) + { + var stack = new StackTrace(skipFrames: 1, fNeedFileInfo: false); + var frames = stack.GetFrames(); + + var sb = new StringBuilder(128); + + sb.Append(file); + sb.Append(':'); + sb.Append(line); + + if (context is not null) + { + sb.Append(':'); + sb.Append(context.GetHashCode()); + } + + foreach (var f in frames) + { + var m = f.GetMethod(); + sb.Append(m.Name); + sb.Append(f.GetILOffset()); + } + + var signature = sb.ToString(); + + if (Cache.TryGetValue(signature, out var cached)) + { + return cached; + } + + // fnv-1a hash + var hash = Fnv1a(signature); + + var id = "id_" + hash.ToString("X"); + + Cache[signature] = id; + + return id; + } + + /// + /// Calculates the 32-bit FNV-1a hash value for the specified string. + /// + /// + /// The FNV-1a algorithm is a non-cryptographic hash function known + /// for its simplicity and speed. It is commonly used for hash tables + /// and checksums, but should not be used for cryptographic purposes. + /// + /// + /// The input string for which to compute the hash. This parameter + /// cannot be null. + /// + /// + /// A 32-bit unsigned integer representing the FNV-1a hash of the + /// input string. + /// + private static uint Fnv1a(string text) + { + unchecked + { + uint hash = 2166136261; + for (int i = 0; i < text.Length; i++) + { + hash = (hash ^ text[i]) * 16777619; + } + + return hash; + } + } + } +} diff --git a/src/WebExpress.WebCore/WebHtml/RandomId.cs b/src/WebExpress.WebCore/WebHtml/RandomId.cs new file mode 100644 index 0000000..95a09f6 --- /dev/null +++ b/src/WebExpress.WebCore/WebHtml/RandomId.cs @@ -0,0 +1,43 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace WebExpress.WebCore.WebHtml +{ + /// + /// Provides methods for generating non-deterministic unique identifiers. + /// + /// + /// Unlike , this class does not use caching + /// and does not attempt to produce stable identifiers. Each call produces + /// a new random identifier. + /// + public static class RandomId + { + private static readonly RandomNumberGenerator Rng = RandomNumberGenerator.Create(); + + /// + /// Generates a random unique identifier. The identifier is not deterministic + /// and will differ on every call. + /// + /// + /// A unique, non-deterministic identifier string. + /// + public static string Create() + { + Span buffer = stackalloc byte[16]; // 128-bit random + RandomNumberGenerator.Fill(buffer); + + // Convert to hex + var sb = new StringBuilder(35); + sb.Append("id_"); + + foreach (var b in buffer) + { + sb.Append(b.ToString("X2")); + } + + return sb.ToString(); + } + } +} \ No newline at end of file From 51692c1b63c75252f8693cafed5551644b64c0a9 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sat, 21 Feb 2026 18:15:23 +0100 Subject: [PATCH 35/53] feat: improved wql parrser and minor bugs --- src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs b/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs index d8b9b4c..011beff 100644 --- a/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs +++ b/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs @@ -204,7 +204,7 @@ private SettingPageManager(IComponentHub componentHub, IHttpServerContext httpSe _componentHub.EndpointManager.Register(endpointtRegistration); - _httpServerContext.Log.Debug(I18N.Translate("webexpress.webapp:settingpagemanager.initialization")); + _httpServerContext.Log.Debug(I18N.Translate("webexpress.webcore:settingpagemanager.initialization")); } /// From b90ac7e06f2acacbcac4ae5e342fab1c2d4dd883 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Wed, 25 Feb 2026 19:38:24 +0100 Subject: [PATCH 36/53] feat: general improvements and minor bugs --- src/WebExpress.WebCore/WebEx.cs | 8 ++++---- src/WebExpress.WebCore/WebLog/ILog.cs | 4 ++-- src/WebExpress.WebCore/WebLog/Log.cs | 12 ++++++------ src/WebExpress.WebCore/WebLog/LogFrame.cs | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/WebExpress.WebCore/WebEx.cs b/src/WebExpress.WebCore/WebEx.cs index 08a461a..247ca72 100644 --- a/src/WebExpress.WebCore/WebEx.cs +++ b/src/WebExpress.WebCore/WebEx.cs @@ -236,7 +236,7 @@ private void OnInitialization(string args, string configFile) _httpServer.HttpServerContext.Log.Begin(config.Log); // log program start - _httpServer.HttpServerContext.Log.Seperator('/'); + _httpServer.HttpServerContext.Log.Separator('/'); _httpServer.HttpServerContext.Log.Info(message: I18N.Translate("webexpress.webcore:app.startup")); _httpServer.HttpServerContext.Log.Info(message: "".PadRight(80, '-')); _httpServer.HttpServerContext.Log.Info(message: I18N.Translate("webexpress.webcore:app.version"), args: Version); @@ -254,7 +254,7 @@ private void OnInitialization(string args, string configFile) _httpServer.HttpServerContext.Log.Info(message: I18N.Translate("webexpress.webcore:app.uri"), args: v.Uri); } - _httpServer.HttpServerContext.Log.Seperator('='); + _httpServer.HttpServerContext.Log.Separator('='); if (!Directory.Exists(config.PackageBase)) { @@ -298,11 +298,11 @@ private void OnExit() Exit?.Invoke(this, EventArgs.Empty); // end of program log - _httpServer.HttpServerContext.Log.Seperator('='); + _httpServer.HttpServerContext.Log.Separator('='); _httpServer.HttpServerContext.Log.Info(message: I18N.Translate("webexpress.webcore:app.errors"), args: _httpServer.HttpServerContext.Log.ErrorCount); _httpServer.HttpServerContext.Log.Info(message: I18N.Translate("webexpress.webcore:app.warnings"), args: _httpServer.HttpServerContext.Log.WarningCount); _httpServer.HttpServerContext.Log.Info(message: I18N.Translate("webexpress.webcore:app.done")); - _httpServer.HttpServerContext.Log.Seperator('/'); + _httpServer.HttpServerContext.Log.Separator('/'); // Stop running (_componentHub as ComponentHub).ShutDown(); diff --git a/src/WebExpress.WebCore/WebLog/ILog.cs b/src/WebExpress.WebCore/WebLog/ILog.cs index fdda1f8..448d8e3 100644 --- a/src/WebExpress.WebCore/WebLog/ILog.cs +++ b/src/WebExpress.WebCore/WebLog/ILog.cs @@ -106,13 +106,13 @@ public interface ILog : ILogger /// /// A dividing line with * characters /// - public void Seperator(); + public void Separator(); /// /// A separator with custom characters /// /// The separator. - public void Seperator(char sepChar); + public void Separator(char sepChar); /// /// Logs an info message. diff --git a/src/WebExpress.WebCore/WebLog/Log.cs b/src/WebExpress.WebCore/WebLog/Log.cs index 14fc7c0..60218d9 100644 --- a/src/WebExpress.WebCore/WebLog/Log.cs +++ b/src/WebExpress.WebCore/WebLog/Log.cs @@ -46,7 +46,7 @@ public class Log : ILog /// /// Constant that determines the further of the separator rows. /// - private const int _seperatorWidth = 260; + private const int _SeparatorWidth = 260; /// /// End worker thread lifecycle. @@ -222,7 +222,7 @@ protected virtual void Add(LogLevel level, string message, [CallerMemberName] st break; } - Console.WriteLine(item.ToString().Length > _seperatorWidth ? string.Concat(item.ToString().AsSpan(0, _seperatorWidth - 3), "...") : item.ToString().PadRight(_width, ' ')); + Console.WriteLine(item.ToString().Length > _SeparatorWidth ? string.Concat(item.ToString().AsSpan(0, _SeparatorWidth - 3), "...") : item.ToString().PadRight(_width, ' ')); Console.ResetColor(); _queue.Enqueue(item); @@ -233,18 +233,18 @@ protected virtual void Add(LogLevel level, string message, [CallerMemberName] st /// /// A dividing line with * characters /// - public void Seperator() + public void Separator() { - Seperator('*'); + Separator('*'); } /// /// A separator with custom characters /// /// The separator. - public void Seperator(char sepChar) + public void Separator(char sepChar) { - Add(LogLevel.Seperartor, "".PadRight(_seperatorWidth, sepChar)); + Add(LogLevel.Seperartor, "".PadRight(_SeparatorWidth, sepChar)); } /// diff --git a/src/WebExpress.WebCore/WebLog/LogFrame.cs b/src/WebExpress.WebCore/WebLog/LogFrame.cs index d548c03..ff6133c 100644 --- a/src/WebExpress.WebCore/WebLog/LogFrame.cs +++ b/src/WebExpress.WebCore/WebLog/LogFrame.cs @@ -48,7 +48,7 @@ public LogFrame(ILog log, string name, string additionalHeading = null, [CallerM Status = string.Format("{0} completed. ", name); Log = log; - Log.Seperator(); + Log.Separator(); Log.Info(string.Format("Starting {0}", name) + (!string.IsNullOrWhiteSpace(additionalHeading) ? " " + additionalHeading : ""), instance, line, file); Log.Info("".PadRight(80, '-'), instance, line, file); } From bfb46cf0e0cbbda6bbf5b6ad329e771950c2454d Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 1 Mar 2026 15:27:09 +0100 Subject: [PATCH 37/53] feat: general improvements and minor bugs --- .../WebEndpoint/RouteEndpoint.cs | 2 +- .../WebSitemap/SitemapManager.cs | 2 +- src/WebExpress.WebCore/WebUri/IUri.cs | 25 +++++++++++++- src/WebExpress.WebCore/WebUri/UriEndpoint.cs | 33 +++++++++++++++++-- 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs b/src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs index 57277c7..a0d5c32 100644 --- a/src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs +++ b/src/WebExpress.WebCore/WebEndpoint/RouteEndpoint.cs @@ -208,7 +208,7 @@ public virtual IRoute RemoveSegment(string segments) /// An instance of IUri representing the route as a URI. public IUri ToUri(params Parameter[] parameters) { - return new UriEndpoint([.. PathSegments]).SetParameters(parameters); + return new UriEndpoint([.. PathSegments]).BindParameters(parameters); } /// diff --git a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs index 1e75d52..448acc6 100644 --- a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs +++ b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs @@ -170,7 +170,7 @@ public IUri GetUri(Type endpointType, IApplicationContext applicationContext, pa .Where(x => endpointContexts.Contains(x.EndpointContext)) .FirstOrDefault(); - return new UriEndpoint(_serverUri, node?.EndpointContext?.Route.PathSegments, null).SetParameters(parameters); + return new UriEndpoint(_serverUri, node?.EndpointContext?.Route.PathSegments, null).BindParameters(parameters); } /// diff --git a/src/WebExpress.WebCore/WebUri/IUri.cs b/src/WebExpress.WebCore/WebUri/IUri.cs index 019848b..4c1d7d0 100644 --- a/src/WebExpress.WebCore/WebUri/IUri.cs +++ b/src/WebExpress.WebCore/WebUri/IUri.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using WebExpress.WebCore.WebIcon; +using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPage; using WebExpress.WebCore.WebParameter; @@ -183,6 +184,28 @@ public interface IUri /// /// A new endpoint uri with the populated parameters. /// - IUri SetParameters(params IParameter[] parameters); + IUri BindParameters(params IParameter[] parameters); + + /// + /// Creates a new endpoint uri and fills it with the given parameters. + /// + /// + /// The parameters that fill in the variable parts of the uri. + /// + /// + /// A new endpoint uri with the populated parameters. + /// + IUri BindParameters(IEnumerable parameters); + + /// + /// Binds the parameters from the specified request to a URI instance. + /// + /// + /// The request object containing the parameters to be bound to the URI. Cannot be null. + /// + /// + /// An new IUri instance that represents the URI with parameters bound from the request. + /// + IUri BindParameters(IRequest request); } } diff --git a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs index 470a8c3..424bd95 100644 --- a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs +++ b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Text.RegularExpressions; using WebExpress.WebCore.WebIcon; +using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPage; using WebExpress.WebCore.WebParameter; @@ -450,11 +451,11 @@ public bool StartsWith(IUri uri) /// /// A new endpoint uri with the populated parameters. /// - public virtual IUri SetParameters(params IParameter[] parameters) + public virtual IUri BindParameters(params IParameter[] parameters) { var pathSegments = PathSegments.AsEnumerable(); - foreach (var parameter in parameters) + foreach (var parameter in parameters ?? []) { pathSegments = pathSegments.Select(x => { @@ -474,6 +475,34 @@ public virtual IUri SetParameters(params IParameter[] parameters) return new UriEndpoint(this, pathSegments); } + /// + /// Creates a new endpoint uri and fills it with the given parameters. + /// + /// + /// The parameters that fill in the variable parts of the uri. + /// + /// + /// A new endpoint uri with the populated parameters. + /// + public virtual IUri BindParameters(IEnumerable parameters) + { + return BindParameters([.. parameters]); + } + + /// + /// Binds the parameters from the specified request to a URI instance. + /// + /// + /// The request object containing the parameters to be bound to the URI. Cannot be null. + /// + /// + /// An new IUri instance that represents the URI with parameters bound from the request. + /// + public virtual IUri BindParameters(IRequest request) + { + return BindParameters([.. request?.Parameters]); + } + /// /// Combines the specified uris into a compound uri. /// From 245368a1c79ad202787ad9cd58f4654b0efc8969 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Tue, 3 Mar 2026 21:22:10 +0100 Subject: [PATCH 38/53] feat: refactor parameter, general improvements and minor bugs --- .../Manager/UnitTestSitemapManager.cs | 12 +- .../Route/UnitTestRoute.cs | 9 +- .../Route/UnitTestSegmentAttribute.cs | 131 ++++++++++++++ .../Route/UnitTestUriPathSegment.cs | 163 ++++++++++++++++++ src/WebExpress.WebCore.Test/TestParameterA.cs | 69 +++++++- src/WebExpress.WebCore.Test/TestParameterB.cs | 92 ++++++++++ .../WebUri/UnitTestUri.cs | 99 +++++++++++ .../WebAttribute/SegmentDoubleAttribute.cs | 13 +- .../WebAttribute/SegmentGuidAttribute.cs | 10 +- .../WebAttribute/SegmentIntAttribute.cs | 13 +- .../WebAttribute/SegmentRegexAttribute.cs | 12 +- .../WebAttribute/SegmentStringAttribute.cs | 12 +- .../WebAttribute/SegmentUIntAttribute.cs | 13 +- src/WebExpress.WebCore/WebMessage/IRequest.cs | 3 +- .../WebMessage/RequestBase.cs | 24 +-- .../WebParameter/IParameter.cs | 9 +- .../WebParameter/IParameterDynamic.cs | 13 ++ .../WebParameter/IParameterStatic.cs | 22 +++ .../WebParameter/Parameter.cs | 24 +-- .../WebParameter/ParameterApiVersion.cs | 56 +++++- .../WebParameter/ParameterGuid.cs | 36 ---- .../WebParameter/ParameterId.cs | 97 +++++++++++ .../WebRestApi/RestApiManager.cs | 5 +- .../WebSitemap/ISitemapManager.cs | 4 +- .../WebSitemap/SitemapManager.cs | 7 +- src/WebExpress.WebCore/WebUri/IUri.cs | 6 +- src/WebExpress.WebCore/WebUri/IUriQuery.cs | 18 ++ src/WebExpress.WebCore/WebUri/UriEndpoint.cs | 51 +++++- .../WebUri/UriPathSegmentVariable.cs | 13 +- .../UriPathSegmentVariableApiVersion.cs | 10 +- .../WebUri/UriPathSegmentVariableDouble.cs | 11 +- .../WebUri/UriPathSegmentVariableGuid.cs | 20 ++- .../WebUri/UriPathSegmentVariableInt.cs | 11 +- .../WebUri/UriPathSegmentVariableRegex.cs | 11 +- .../WebUri/UriPathSegmentVariableString.cs | 11 +- .../WebUri/UriPathSegmentVariableUInt.cs | 11 +- src/WebExpress.WebCore/WebUri/UriQuery.cs | 44 ++++- 37 files changed, 943 insertions(+), 222 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/Route/UnitTestSegmentAttribute.cs create mode 100644 src/WebExpress.WebCore.Test/Route/UnitTestUriPathSegment.cs create mode 100644 src/WebExpress.WebCore.Test/TestParameterB.cs create mode 100644 src/WebExpress.WebCore/WebParameter/IParameterDynamic.cs create mode 100644 src/WebExpress.WebCore/WebParameter/IParameterStatic.cs delete mode 100644 src/WebExpress.WebCore/WebParameter/ParameterGuid.cs create mode 100644 src/WebExpress.WebCore/WebParameter/ParameterId.cs create mode 100644 src/WebExpress.WebCore/WebUri/IUriQuery.cs diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs index e8deed8..a6c2950 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs @@ -117,9 +117,19 @@ public void GetUri(Type applicationType, Type resourceType, int? param, string e var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); componentHub.SitemapManager.Refresh(); + var parameter = param.HasValue + ? new TestParameterA(param ?? 0) + : new TestParameterA(); // act - var uri = componentHub.SitemapManager.GetUri(resourceType, application, [param.HasValue ? new TestParameterA(param.Value) : null]); + var uri = componentHub.SitemapManager.GetUri + ( + resourceType, + application, + [ + parameter + ] + ); // validation Assert.Equal(expected, uri?.ToString()); diff --git a/src/WebExpress.WebCore.Test/Route/UnitTestRoute.cs b/src/WebExpress.WebCore.Test/Route/UnitTestRoute.cs index 6e07c4b..9d8760b 100644 --- a/src/WebExpress.WebCore.Test/Route/UnitTestRoute.cs +++ b/src/WebExpress.WebCore.Test/Route/UnitTestRoute.cs @@ -25,6 +25,7 @@ public void ConcatString(string baseRoute, string segment, string expected, int // act var concat = route.Concat(segment); + // validation Assert.Equal(expected, concat.ToString()); Assert.Equal(count, concat.PathSegments.Count()); } @@ -47,6 +48,7 @@ public void ConcatSegment(string baseRoute, string segment, string expected, int ? [.. segment?.Split('/').Select(x => new UriPathSegmentConstant(x))] : null); + // validation Assert.Equal(expected, concat.ToString()); Assert.Equal(count, concat.PathSegments.Count()); } @@ -64,6 +66,7 @@ public void CombinePath(string baseRoute, string pathB, string expected) // act var combine = RouteEndpoint.Combine([new RouteEndpoint(baseRoute), new RouteEndpoint(pathB)]); + // validation Assert.Equal(expected, combine.ToString()); } @@ -80,6 +83,7 @@ public void CombineRoute(string baseRoute, string pathB, string expected) // act var combine = RouteEndpoint.Combine(new RouteEndpoint(baseRoute), [pathB]); + // validation Assert.Equal(expected, combine.ToString()); } @@ -96,6 +100,7 @@ public void CombineSegment(string baseRoute, string segment, string expected) // act var combine = RouteEndpoint.Combine(new RouteEndpoint(baseRoute), segment); + // validation Assert.Equal(expected, combine.ToString()); } @@ -111,11 +116,13 @@ public void CombineSegment(string baseRoute, string segment, string expected) [InlineData("/a/b/c", "/a/c", "/a/b/c")] public void RemoveSegment(string route, string segment, string expected) { - // act + // arrange var routeEndpoint = new RouteEndpoint(route); + // act var removed = routeEndpoint.RemoveSegment(segment); + // validation Assert.Equal(expected, removed.ToString()); } } diff --git a/src/WebExpress.WebCore.Test/Route/UnitTestSegmentAttribute.cs b/src/WebExpress.WebCore.Test/Route/UnitTestSegmentAttribute.cs new file mode 100644 index 0000000..9db7140 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Route/UnitTestSegmentAttribute.cs @@ -0,0 +1,131 @@ +using WebExpress.WebCore.WebAttribute; + +namespace WebExpress.WebCore.Test.Route +{ + /// + /// Tests the segment attribute. + /// + [Collection("NonParallelTests")] + public class UnitTestSegmentAttribute + { + /// + /// Test the constant segment. + /// + [Theory] + [InlineData(null, "")] + [InlineData("abc", "abc")] + public void Constant(string name, string expected) + { + // arrange + var attribute = new SegmentAttribute(name); + + // act + var segment = attribute.ToPathSegment(); + + // validation + Assert.Equal(expected, segment.ToString()); + } + + /// + /// Test the double segment attribute. + /// + [Theory] + [InlineData("${testparametera}")] + public void Double(string expected) + { + // arrange + var attribute = new SegmentDoubleAttribute(""); + + // act + var segment = attribute.ToPathSegment(); + + // validation + Assert.Equal(expected, segment.ToString()); + } + + /// + /// Test the guid segment attribute. + /// + [Theory] + [InlineData("${testparametera}")] + public void Guid(string expected) + { + // arrange + var attribute = new SegmentGuidAttribute(); + + // act + var segment = attribute.ToPathSegment(); + + // validation + Assert.Equal(expected, segment.ToString()); + } + + /// + /// Test the int segment attribute. + /// + [Theory] + [InlineData("${testparametera}")] + public void Int(string expected) + { + // arrange + var attribute = new SegmentIntAttribute(); + + // act + var segment = attribute.ToPathSegment(); + + // validation + Assert.Equal(expected, segment.ToString()); + } + + /// + /// Test the regex segment attribute. + /// + [Theory] + [InlineData(".*", "${testparametera}")] + public void Regex(string regex, string expected) + { + // arrange + var attribute = new SegmentRegexAttribute(regex); + + // act + var segment = attribute.ToPathSegment(); + + // validation + Assert.Equal(expected, segment.ToString()); + } + + /// + /// Test the string segment attribute. + /// + [Theory] + [InlineData("${testparametera}")] + public void String(string expected) + { + // arrange + var attribute = new SegmentStringAttribute(); + + // act + var segment = attribute.ToPathSegment(); + + // validation + Assert.Equal(expected, segment.ToString()); + } + + /// + /// Test the uint segment attribute. + /// + [Theory] + [InlineData("${testparametera}")] + public void UInt(string expected) + { + // arrange + var attribute = new SegmentUIntAttribute(); + + // act + var segment = attribute.ToPathSegment(); + + // validation + Assert.Equal(expected, segment.ToString()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Route/UnitTestUriPathSegment.cs b/src/WebExpress.WebCore.Test/Route/UnitTestUriPathSegment.cs new file mode 100644 index 0000000..b39b251 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Route/UnitTestUriPathSegment.cs @@ -0,0 +1,163 @@ +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebUri; + +namespace WebExpress.WebCore.Test.Route +{ + /// + /// Tests the path segment. + /// + [Collection("NonParallelTests")] + public class UnitTestUriPathSegment + { + /// + /// Test the constant segment. + /// + [Theory] + [InlineData(null, "", null)] + [InlineData("abc", "abc", null)] + public void Constant(string value, string expected, string displayText) + { + // arrange + var renderContet = UnitTestFixture.CrerateRenderContextMock(); + + // act + var segment = new UriPathSegmentConstant(value); + + // validation + Assert.Equal(expected, segment.ToString()); + Assert.Equal(displayText, segment.GetDisplayText(renderContet)); + } + + /// + /// Test the int segment. + /// + [Theory] + [InlineData(null, "${testparametera}", "")] + [InlineData("123", "123", "")] + public void Int(string value, string expected, string displayText) + { + // arrange + var renderContet = UnitTestFixture.CrerateRenderContextMock(); + + // act + var segment = new UriPathSegmentVariableInt() + { + Value = value + }; + + // validation + Assert.Equal(expected, segment.ToString()); + Assert.Equal(displayText, segment.GetDisplayText(renderContet)); + } + + /// + /// Test the uint segment. + /// + [Theory] + [InlineData(null, "${testparametera}", "")] + [InlineData("123", "123", "")] + public void UInt(string value, string expected, string displayText) + { + // arrange + var renderContet = UnitTestFixture.CrerateRenderContextMock(); + + // act + var segment = new UriPathSegmentVariableUInt() + { + Value = value + }; + + // validation + Assert.Equal(expected, segment.ToString()); + Assert.Equal(displayText, segment.GetDisplayText(renderContet)); + } + + /// + /// Test the double segment. + /// + [Theory] + [InlineData(null, "${testparametera}", "")] + [InlineData("123", "123", "")] + public void Double(string value, string expected, string displayText) + { + // arrange + var renderContet = UnitTestFixture.CrerateRenderContextMock(); + + // act + var segment = new UriPathSegmentVariableDouble() + { + Value = value + }; + + // validation + Assert.Equal(expected, segment.ToString()); + Assert.Equal(displayText, segment.GetDisplayText(renderContet)); + } + + /// + /// Test the guid segment. + /// + [Theory] + [InlineData(null, "${testparametera}", "")] + [InlineData("123", "123", "")] + public void Guid(string value, string expected, string displayText) + { + // arrange + var renderContet = UnitTestFixture.CrerateRenderContextMock(); + + // act + var segment = new UriPathSegmentVariableGuid() + { + Value = value + }; + + // validation + Assert.Equal(expected, segment.ToString()); + Assert.Equal(displayText, segment.GetDisplayText(renderContet)); + } + + /// + /// Test the regex segment. + /// + [Theory] + [InlineData(null, "${testparametera}", "")] + [InlineData("123", "123", "")] + public void Regex(string value, string expected, string displayText) + { + // arrange + var renderContet = UnitTestFixture.CrerateRenderContextMock(); + + // act + var segment = new UriPathSegmentVariableRegex(".*") + { + Value = value + }; + + // validation + Assert.Equal(expected, segment.ToString()); + Assert.Equal(displayText, segment.GetDisplayText(renderContet)); + } + + /// + /// Test the regex segment. + /// + [Theory] + [InlineData(null, "${testparametera}", "")] + [InlineData("123", "123", "")] + public void String(string value, string expected, string displayText) + { + // arrange + var renderContet = UnitTestFixture.CrerateRenderContextMock(); + + // act + var segment = new UriPathSegmentVariableString(".*") + { + Value = value + }; + + // validation + Assert.Equal(expected, segment.ToString()); + Assert.Equal(displayText, segment.GetDisplayText(renderContet)); + } + } +} diff --git a/src/WebExpress.WebCore.Test/TestParameterA.cs b/src/WebExpress.WebCore.Test/TestParameterA.cs index 993ae27..d9b0a49 100644 --- a/src/WebExpress.WebCore.Test/TestParameterA.cs +++ b/src/WebExpress.WebCore.Test/TestParameterA.cs @@ -1,19 +1,35 @@ -using WebExpress.WebCore.WebParameter; +using WebExpress.WebCore.WebIcon; +using WebExpress.WebCore.WebPage; +using WebExpress.WebCore.WebParameter; namespace WebExpress.WebCore.Test { /// /// Represents a test parameter. /// - internal class TestParameterA : Parameter + internal class TestParameterA : IParameterStatic { + /// + /// Returns the key that uniquely identifies the parameter in configuration or + /// settings contexts. + /// + public static string Key => "testparametera"; + + /// + /// Returns or sets the scope of the parameter. + /// + public ParameterScope Scope { get; set; } + + /// + /// Returns the value of the parameter. + /// + public string Value { get; set; } + /// /// Initializes a new instance of the class. /// public TestParameterA() - : base("TestParameterA", null, ParameterScope.Url) { - } /// @@ -21,9 +37,8 @@ public TestParameterA() /// /// The value of the parameter. public TestParameterA(int value) - : base("TestParameterA", value, ParameterScope.Url) { - + Value = value.ToString(); } /// @@ -31,9 +46,49 @@ public TestParameterA(int value) /// /// The value of the parameter. public TestParameterA(Guid value) - : base("TestParameterA", value.ToString(), ParameterScope.Url) { + Value = value.ToString(); + } + /// + /// Returns a string that represents the display text for the current instance. + /// + /// The render context. + /// + /// A string containing the display text associated with the instance. The + /// value may be empty if no display text is available. + /// + public string GetDisplayText(IRenderContext renderContext) + { + return Value; + } + + /// + /// Retrieves the icon that corresponds to the specified render context. + /// + /// + /// The context in which the icon will be rendered. This parameter determines + /// the appearance and behavior of the + /// returned icon. + /// + /// + /// An icon instance that represents the icon for the given render context. + /// + public IIcon GetIcon(IRenderContext renderContext) + { + return null; + } + + /// + /// Retrieves the unique key associated with the current instance. + /// + /// + /// A string representing the unique key. This key is used for identifying + /// the instance in various operations. + /// + public string GetKey() + { + return Key; } } } diff --git a/src/WebExpress.WebCore.Test/TestParameterB.cs b/src/WebExpress.WebCore.Test/TestParameterB.cs new file mode 100644 index 0000000..c4c3cbc --- /dev/null +++ b/src/WebExpress.WebCore.Test/TestParameterB.cs @@ -0,0 +1,92 @@ +using WebExpress.WebCore.WebIcon; +using WebExpress.WebCore.WebPage; +using WebExpress.WebCore.WebParameter; + +namespace WebExpress.WebCore.Test +{ + /// + /// Represents a test parameter. + /// + internal class TestParameterB : IParameterStatic + { + // + /// Returns the key that uniquely identifies the parameter in configuration or + /// settings contexts. + /// + public static string Key => "testparameterb"; + + /// + /// Returns or sets the scope of the parameter. + /// + public ParameterScope Scope { get; set; } + + /// + /// Returns the value of the parameter. + /// + public string Value { get; set; } + + /// + /// Initializes a new instance of the class. + /// + public TestParameterB() + { + } + + /// + /// Initializes a new instance of the class with a specified value. + /// + /// The value of the parameter. + public TestParameterB(int value) + { + } + + /// + /// Initializes a new instance of the class with a specified value. + /// + /// The value of the parameter. + public TestParameterB(Guid value) + { + } + + /// + /// Returns a string that represents the display text for the current instance. + /// + /// The render context. + /// + /// A string containing the display text associated with the instance. The + /// value may be empty if no display text is available. + /// + public string GetDisplayText(IRenderContext renderContext) + { + return Value; + } + + /// + /// Retrieves the icon that corresponds to the specified render context. + /// + /// + /// The context in which the icon will be rendered. This parameter determines + /// the appearance and behavior of the + /// returned icon. + /// + /// + /// An icon instance that represents the icon for the given render context. + /// + public IIcon GetIcon(IRenderContext renderContext) + { + return null; + } + + /// + /// Retrieves the unique key associated with the current instance. + /// + /// + /// A string representing the unique key. This key is used for identifying + /// the instance in various operations. + /// + public string GetKey() + { + return Key; + } + } +} diff --git a/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs b/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs index 40b1274..24cd78a 100644 --- a/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs +++ b/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs @@ -291,5 +291,104 @@ public void GetDisplayText(Type applicationType, Type resourceType, string expec // validation Assert.Equal(expected, display); } + + /// + /// Tests the BindParameters method to verify that parameters are correctly + /// bound to the URI endpoint. + /// + [Fact] + public void BindParametersSingle() + { + // arrange + var pathSegments = new IUriPathSegment[] + { + new UriPathSegmentConstant("a"), + new UriPathSegmentVariableGuid("testparametera"), + new UriPathSegmentConstant("b") + }; + + var uri = new UriEndpoint(pathSegments); + var parameter = new TestParameterA() + { + Value = "CFABEA8C-4223-4E17-AB31-C8FA4454B745" + }; + + // act + var bind = uri.BindParameters(parameter); + + // validation + Assert.Equal("/a/${testparametera}/b", uri.ToString()); + Assert.Equal("/a/CFABEA8C-4223-4E17-AB31-C8FA4454B745/b", bind.ToString()); + } + + /// + /// Tests the BindParameters method to verify that parameters are correctly + /// bound to the URI endpoint. + /// + [Fact] + public void BindParametersMultible() + { + // arrange + var pathSegments = new IUriPathSegment[] + { + new UriPathSegmentConstant("a"), + new UriPathSegmentVariableInt(), + new UriPathSegmentConstant("b"), + new UriPathSegmentVariableInt() + }; + + var uri = new UriEndpoint(pathSegments); + var parameter1 = new TestParameterA() + { + Value = "10" + }; + + var parameter2 = new TestParameterB() + { + Value = "20" + }; + + // act + var bind = uri.BindParameters(parameter1, parameter2); + + // validation + Assert.Equal("/a/${testparametera}/b/${testparameterb}", uri.ToString()); + Assert.Equal("/a/10/b/20", bind.ToString()); + } + + /// + /// Tests the BindParameters method to verify that parameters are correctly + /// bound to the URI endpoint. + /// + [Fact] + public void BindParametersQuery() + { + // arrange + var pathSegments = new IUriPathSegment[] + { + new UriPathSegmentConstant("a"), + new UriPathSegmentVariableInt(), + new UriPathSegmentConstant("b") + }; + + var uri = new UriEndpoint(pathSegments) + .Add(new UriQuery()); + var parameter1 = new TestParameterA() + { + Value = "10" + }; + + var parameter2 = new TestParameterB() + { + Value = "20" + }; + + // act + var bind = uri.BindParameters(parameter1, parameter2); + + // validation + Assert.Equal("/a/${testparametera}/b?testparameterb=", uri.ToString()); + Assert.Equal("/a/10/b?testparameterb=20", bind.ToString()); + } } } diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentDoubleAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentDoubleAttribute.cs index bbac82b..763dcc1 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentDoubleAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentDoubleAttribute.cs @@ -12,13 +12,8 @@ namespace WebExpress.WebCore.WebAttribute /// [AttributeUsage(AttributeTargets.Class)] public class SegmentDoubleAttribute : Attribute, IEndpointAttribute, ISegmentAttribute - where TParameter : IParameter + where TParameter : IParameterStatic, new() { - /// - /// Returns or sets the name of the variable. - /// - private string VariableName { get; set; } - /// /// Returns or sets the display string. /// @@ -27,11 +22,9 @@ public class SegmentDoubleAttribute : Attribute, IEndpointAttribute, /// /// Initializes a new instance of the class. /// - /// The name of the variable. /// The display string. - public SegmentDoubleAttribute(string variableName, string display) + public SegmentDoubleAttribute(string display = null) { - VariableName = variableName; Display = display; } @@ -41,7 +34,7 @@ public SegmentDoubleAttribute(string variableName, string display) /// The path segment. public IUriPathSegment ToPathSegment() { - return new UriPathSegmentVariableDouble(VariableName, Display); + return new UriPathSegmentVariableDouble(Display); } } } diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentGuidAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentGuidAttribute.cs index 52eb1db..01be48b 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentGuidAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentGuidAttribute.cs @@ -12,13 +12,8 @@ namespace WebExpress.WebCore.WebAttribute /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public class SegmentGuidAttribute : Attribute, IEndpointAttribute, ISegmentAttribute - where TParameter : IParameter + where TParameter : IParameterStatic, new() { - /// - /// Returns or sets the name of the variable. - /// - private string VariableName { get; set; } - /// /// Returns or sets the display format. /// @@ -30,7 +25,6 @@ public class SegmentGuidAttribute : Attribute, IEndpointAttribute, I /// The display format. public SegmentGuidAttribute(UriPathSegmentVariableGuid.Format displayFormat = UriPathSegmentVariableGuid.Format.Simple) { - VariableName = (Activator.CreateInstance() as Parameter)?.Key?.ToLower(); DisplayFormat = displayFormat; } @@ -40,7 +34,7 @@ public SegmentGuidAttribute(UriPathSegmentVariableGuid.Format displa /// The path segment. public IUriPathSegment ToPathSegment() { - return new UriPathSegmentVariableGuid(VariableName, DisplayFormat); + return new UriPathSegmentVariableGuid(DisplayFormat); } } } diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentIntAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentIntAttribute.cs index 8dbfb42..5c3c898 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentIntAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentIntAttribute.cs @@ -12,13 +12,8 @@ namespace WebExpress.WebCore.WebAttribute /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public class SegmentIntAttribute : Attribute, IEndpointAttribute, ISegmentAttribute - where TParameter : IParameter + where TParameter : IParameterStatic, new() { - /// - /// Returns or sets the name of the variable. - /// - private string VariableName { get; set; } - /// /// Returns or sets the display string. /// @@ -27,11 +22,9 @@ public class SegmentIntAttribute : Attribute, IEndpointAttribute, IS /// /// Initializes a new instance of the class. /// - /// The name of the variable. /// The display string. - public SegmentIntAttribute(string variableName, string display) + public SegmentIntAttribute(string display = null) { - VariableName = variableName; Display = display; } @@ -41,7 +34,7 @@ public SegmentIntAttribute(string variableName, string display) /// The path segment. public IUriPathSegment ToPathSegment() { - return new UriPathSegmentVariableInt(VariableName, Display); + return new UriPathSegmentVariableInt(Display); } } } diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentRegexAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentRegexAttribute.cs index 6a9659b..9544cd4 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentRegexAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentRegexAttribute.cs @@ -12,13 +12,8 @@ namespace WebExpress.WebCore.WebAttribute /// [AttributeUsage(AttributeTargets.Class)] public class SegmentRegexAttribute : Attribute, IEndpointAttribute, ISegmentAttribute - where TParameter : IParameter + where TParameter : IParameterStatic, new() { - /// - /// Returns or sets the name of the variable. - /// - private string VariableName { get; set; } - /// /// Reurns or sets the string representation of the expression. /// @@ -34,9 +29,8 @@ public class SegmentRegexAttribute : Attribute, IEndpointAttribute, /// /// The regular expression. /// The display string. - public SegmentRegexAttribute(string expression, string display) + public SegmentRegexAttribute(string expression, string display = null) { - VariableName = (Activator.CreateInstance() as Parameter)?.Key?.ToLower(); Expression = expression; Display = display; } @@ -47,7 +41,7 @@ public SegmentRegexAttribute(string expression, string display) /// The path segment. public IUriPathSegment ToPathSegment() { - return new UriPathSegmentVariableRegex(VariableName, Expression, Display); + return new UriPathSegmentVariableRegex(Expression, Display); } } } diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentStringAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentStringAttribute.cs index 765e1aa..ad98978 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentStringAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentStringAttribute.cs @@ -12,13 +12,8 @@ namespace WebExpress.WebCore.WebAttribute /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public class SegmentStringAttribute : Attribute, IEndpointAttribute, ISegmentAttribute - where TParameter : IParameter + where TParameter : IParameterStatic, new() { - /// - /// Returns or sets the name of the variable. - /// - private string VariableName { get; set; } - /// /// Returns or sets the display string. /// @@ -28,9 +23,8 @@ public class SegmentStringAttribute : Attribute, IEndpointAttribute, /// Initializes a new instance of the class. /// /// The display string. - public SegmentStringAttribute(string display) + public SegmentStringAttribute(string display = null) { - VariableName = (Activator.CreateInstance() as Parameter)?.Key?.ToLower(); Display = display; } @@ -40,7 +34,7 @@ public SegmentStringAttribute(string display) /// The path segment. public IUriPathSegment ToPathSegment() { - return new UriPathSegmentVariableString(VariableName, Display); + return new UriPathSegmentVariableString(Display); } } } diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentUIntAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentUIntAttribute.cs index 3d90a73..fe2fb27 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentUIntAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentUIntAttribute.cs @@ -15,13 +15,8 @@ namespace WebExpress.WebCore.WebAttribute /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public class SegmentUIntAttribute : Attribute, IEndpointAttribute, ISegmentAttribute - where TParameter : IParameter + where TParameter : IParameterStatic, new() { - /// - /// Returns or sets the name of the variable. - /// - private string VariableName { get; set; } - /// /// Returns or sets the display string. /// @@ -30,11 +25,9 @@ public class SegmentUIntAttribute : Attribute, IEndpointAttribute, I /// /// Initializes a new instance of the class. /// - /// The name of the variable. /// The display string. - public SegmentUIntAttribute(string variableName, string display) + public SegmentUIntAttribute(string display = null) { - VariableName = variableName; Display = display; } @@ -44,7 +37,7 @@ public SegmentUIntAttribute(string variableName, string display) /// The path segment. public IUriPathSegment ToPathSegment() { - return new UriPathSegmentVariableUInt(VariableName, Display); + return new UriPathSegmentVariableUInt(Display); } } } diff --git a/src/WebExpress.WebCore/WebMessage/IRequest.cs b/src/WebExpress.WebCore/WebMessage/IRequest.cs index 8ce6325..cae0ca2 100644 --- a/src/WebExpress.WebCore/WebMessage/IRequest.cs +++ b/src/WebExpress.WebCore/WebMessage/IRequest.cs @@ -101,7 +101,8 @@ public interface IRequest /// /// The parameter. /// The value. - IParameter GetParameter() where TParameter : IParameter; + TParameter GetParameter() + where TParameter : IParameterStatic, new(); /// /// Checks whether a parameter exists. diff --git a/src/WebExpress.WebCore/WebMessage/RequestBase.cs b/src/WebExpress.WebCore/WebMessage/RequestBase.cs index 9a1c066..5536fff 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestBase.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestBase.cs @@ -253,23 +253,25 @@ public IParameter GetParameter(string name) /// /// The parameter. /// The value. - public IParameter GetParameter() - where TParameter : IParameter + public TParameter GetParameter() + where TParameter : IParameterStatic, new() { - var parameter = Parameter.GetParameter(); + var key = TParameter.Key; - if (parameter is not null - && !string.IsNullOrWhiteSpace(parameter.Key) - && HasParameter(parameter.Key)) + if (!string.IsNullOrWhiteSpace(key) && HasParameter(key)) { - var p = _param[parameter.Key.ToLower()]; - parameter.Value = p.Value; - parameter.Scope = p.Scope; + var p = _param[key.ToLower()]; + + var parameter = new TParameter + { + Value = p.Value, + Scope = p.Scope + }; return parameter; } - return null; + return default; } /// @@ -279,7 +281,7 @@ public IParameter GetParameter() /// True if parameters are present, false otherwise. public bool HasParameter(string name) { - if (name is null) + if (string.IsNullOrWhiteSpace(name)) { return false; } diff --git a/src/WebExpress.WebCore/WebParameter/IParameter.cs b/src/WebExpress.WebCore/WebParameter/IParameter.cs index bf5f04d..b76e5c1 100644 --- a/src/WebExpress.WebCore/WebParameter/IParameter.cs +++ b/src/WebExpress.WebCore/WebParameter/IParameter.cs @@ -8,20 +8,15 @@ namespace WebExpress.WebCore.WebParameter /// public interface IParameter { - /// - /// Returns the key of the parameter. - /// - string Key { get; } - /// /// Returns or sets the scope of the parameter. /// - ParameterScope Scope { get; internal set; } + ParameterScope Scope { get; set; } /// /// Returns the value of the parameter. /// - string Value { get; internal set; } + string Value { get; set; } /// /// Returns a string that represents the display text for the current instance. diff --git a/src/WebExpress.WebCore/WebParameter/IParameterDynamic.cs b/src/WebExpress.WebCore/WebParameter/IParameterDynamic.cs new file mode 100644 index 0000000..dcb678a --- /dev/null +++ b/src/WebExpress.WebCore/WebParameter/IParameterDynamic.cs @@ -0,0 +1,13 @@ +namespace WebExpress.WebCore.WebParameter +{ + /// + /// Represents a parameter with a key, value, and scope. + /// + public interface IParameterDynamic : IParameter + { + /// + /// Returns the key of the parameter. + /// + string Key { get; } + } +} diff --git a/src/WebExpress.WebCore/WebParameter/IParameterStatic.cs b/src/WebExpress.WebCore/WebParameter/IParameterStatic.cs new file mode 100644 index 0000000..6d5aabb --- /dev/null +++ b/src/WebExpress.WebCore/WebParameter/IParameterStatic.cs @@ -0,0 +1,22 @@ +namespace WebExpress.WebCore.WebParameter +{ + /// + /// Represents a parameter with a key, value, and scope. + /// + public interface IParameterStatic : IParameter + { + /// + /// Returns the key of the parameter. + /// + static abstract string Key { get; } + + /// + /// Retrieves the unique key associated with the current instance. + /// + /// + /// A string representing the unique key. This key is used for identifying + /// the instance in various operations. + /// + string GetKey(); + } +} diff --git a/src/WebExpress.WebCore/WebParameter/Parameter.cs b/src/WebExpress.WebCore/WebParameter/Parameter.cs index ef3afe2..a64d164 100644 --- a/src/WebExpress.WebCore/WebParameter/Parameter.cs +++ b/src/WebExpress.WebCore/WebParameter/Parameter.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebCore.WebParameter /// /// Represents a parameter with a key, value, and scope. /// - public class Parameter : IParameter + public class Parameter : IParameterDynamic { /// /// Returns the key of the parameter. @@ -135,28 +135,6 @@ public static List Create(params Parameter[] param) return [.. param]; } - /// - /// Returns the key. - /// - /// The type. - /// The key. - public static TParameter GetParameter() - where TParameter : IParameter - { - return Activator.CreateInstance(); - } - - /// - /// Returns the key. - /// - /// The type. - /// The key. - public static string GetKey() - where TParameter : IParameter - { - return Activator.CreateInstance()?.Key; - } - /// /// Conversion to string form. /// diff --git a/src/WebExpress.WebCore/WebParameter/ParameterApiVersion.cs b/src/WebExpress.WebCore/WebParameter/ParameterApiVersion.cs index f0eda26..c9afb41 100644 --- a/src/WebExpress.WebCore/WebParameter/ParameterApiVersion.cs +++ b/src/WebExpress.WebCore/WebParameter/ParameterApiVersion.cs @@ -1,18 +1,35 @@ -using WebExpress.WebCore.WebPage; +using WebExpress.WebCore.WebIcon; +using WebExpress.WebCore.WebPage; namespace WebExpress.WebCore.WebParameter { /// /// Represents a api version parameter with a key, value, and scope. /// - public class ParameterApiVersion : Parameter + public sealed class ParameterApiVersion : IParameterStatic { + /// + /// Returns the key that uniquely identifies the parameter in configuration or + /// settings contexts. + /// + public static string Key => "_apiVersion"; + + /// + /// Returns or sets the scope of the parameter. + /// + public ParameterScope Scope { get; set; } + + /// + /// Returns the value of the parameter. + /// + public string Value { get; set; } + /// /// Initializes a new instance of the class. /// public ParameterApiVersion() - : base("_apiVersion", null, ParameterScope.Url) { + Scope = ParameterScope.Url; } /// @@ -20,8 +37,9 @@ public ParameterApiVersion() /// /// The value of the parameter. public ParameterApiVersion(string value) - : base("_apiVersion", value, ParameterScope.Url) + : this() { + Value = value; } /// @@ -32,9 +50,37 @@ public ParameterApiVersion(string value) /// A string containing the display text associated with the instance. The /// value may be empty if no display text is available. /// - public override string GetDisplayText(IRenderContext renderContext) + public string GetDisplayText(IRenderContext renderContext) { return Value; } + + /// + /// Retrieves the icon that corresponds to the specified render context. + /// + /// + /// The context in which the icon will be rendered. This parameter determines + /// the appearance and behavior of the + /// returned icon. + /// + /// + /// An icon instance that represents the icon for the given render context. + /// + public IIcon GetIcon(IRenderContext renderContext) + { + return null; + } + + /// + /// Retrieves the unique key associated with the current instance. + /// + /// + /// A string representing the unique key. This key is used for identifying + /// the instance in various operations. + /// + public string GetKey() + { + return Key; + } } } diff --git a/src/WebExpress.WebCore/WebParameter/ParameterGuid.cs b/src/WebExpress.WebCore/WebParameter/ParameterGuid.cs deleted file mode 100644 index 28e36a2..0000000 --- a/src/WebExpress.WebCore/WebParameter/ParameterGuid.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; - -namespace WebExpress.WebCore.WebParameter -{ - /// - /// Represents a guid parameter. - /// - public class ParameterGuid : Parameter - { - /// - /// Initializes a new instance of the class. - /// - public ParameterGuid() - : base("Id", null, ParameterScope.Url) - { - } - - /// - /// Initializes a new instance of the class with a specified value. - /// - /// The value of the parameter. - public ParameterGuid(string value) - : base("Id", value, ParameterScope.Url) - { - } - - /// - /// Initializes a new instance of the class with a specified value. - /// - /// The value of the parameter. - public ParameterGuid(Guid value) - : base("Id", value.ToString(), ParameterScope.Url) - { - } - } -} diff --git a/src/WebExpress.WebCore/WebParameter/ParameterId.cs b/src/WebExpress.WebCore/WebParameter/ParameterId.cs new file mode 100644 index 0000000..79a64cc --- /dev/null +++ b/src/WebExpress.WebCore/WebParameter/ParameterId.cs @@ -0,0 +1,97 @@ +using System; +using WebExpress.WebCore.WebIcon; +using WebExpress.WebCore.WebPage; + +namespace WebExpress.WebCore.WebParameter +{ + /// + /// Represents a id parameter with a key, value, and scope. + /// + public sealed class ParameterId : IParameterStatic + { + /// + /// Returns the key that uniquely identifies the parameter in configuration or + /// settings contexts. + /// + public static string Key => "id"; + + /// + /// Returns or sets the scope of the parameter. + /// + public ParameterScope Scope { get; set; } + + /// + /// Returns the value of the parameter. + /// + public string Value { get; set; } + + /// + /// Initializes a new instance of the class. + /// + public ParameterId() + { + Scope = ParameterScope.Url; + } + + /// + /// Initializes a new instance of the class with a specified value. + /// + /// The value of the parameter. + public ParameterId(string value) + : this() + { + Value = value; + } + + /// + /// Initializes a new instance of the class with a specified value. + /// + /// The value of the parameter. + public ParameterId(Guid value) + : this() + { + Value = value.ToString(); + } + + /// + /// Returns a string that represents the display text for the current instance. + /// + /// The render context. + /// + /// A string containing the display text associated with the instance. The + /// value may be empty if no display text is available. + /// + public string GetDisplayText(IRenderContext renderContext) + { + return Value; + } + + /// + /// Retrieves the icon that corresponds to the specified render context. + /// + /// + /// The context in which the icon will be rendered. This parameter determines + /// the appearance and behavior of the + /// returned icon. + /// + /// + /// An icon instance that represents the icon for the given render context. + /// + public IIcon GetIcon(IRenderContext renderContext) + { + return null; + } + + /// + /// Retrieves the unique key associated with the current instance. + /// + /// + /// A string representing the unique key. This key is used for identifying + /// the instance in various operations. + /// + public string GetKey() + { + return Key; + } + } +} diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs b/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs index 8af6f0b..39ba764 100644 --- a/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs +++ b/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs @@ -530,7 +530,10 @@ var attribute in restApiType restApiType, prefix, segment, - [new UriPathSegmentConstant("api"), new UriPathSegmentVariableApiVersion("_apiVersion", $"{version}")], + [ + new UriPathSegmentConstant("api"), + new UriPathSegmentVariableApiVersion($"{version}") + ], ["api", "restapi", "rest"] ).RemoveSegment(versionSegment); diff --git a/src/WebExpress.WebCore/WebSitemap/ISitemapManager.cs b/src/WebExpress.WebCore/WebSitemap/ISitemapManager.cs index 725c3e1..f3557cd 100644 --- a/src/WebExpress.WebCore/WebSitemap/ISitemapManager.cs +++ b/src/WebExpress.WebCore/WebSitemap/ISitemapManager.cs @@ -38,7 +38,7 @@ public interface ISitemapManager : IComponentManager /// The application context. /// The parameters to be considered for the uri. /// Returns the URI taking into account the context, or null if no valid URI is found. - IUri GetUri(IApplicationContext applicationContext, params Parameter[] parameters) + IUri GetUri(IApplicationContext applicationContext, params IParameter[] parameters) where TEndpoint : IEndpoint; /// @@ -48,7 +48,7 @@ IUri GetUri(IApplicationContext applicationContext, params Parameter[ /// The application context. /// The parameters to be considered for the uri. /// Returns the URI taking into account the context, or null if no valid URI is found. - IUri GetUri(Type endpointType, IApplicationContext applicationContext, params Parameter[] parameters); + IUri GetUri(Type endpointType, IApplicationContext applicationContext, params IParameter[] parameters); /// /// Returns the URI for this type based on the sitemap configuration, taking into account the specific context in which the URI is valid. diff --git a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs index 448acc6..7ee56db 100644 --- a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs +++ b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs @@ -149,7 +149,7 @@ public SearchResult SearchResource(Uri requestUri, SearchContext searchContext) /// /// Returns the URI taking into account the context, or null if no valid URI is found. /// - public IUri GetUri(IApplicationContext applicationContext, params Parameter[] parameters) + public IUri GetUri(IApplicationContext applicationContext, params IParameter[] parameters) where TEndpoint : IEndpoint { return GetUri(typeof(TEndpoint), applicationContext, parameters); @@ -162,13 +162,12 @@ public IUri GetUri(IApplicationContext applicationContext, params Par /// The application context. /// The parameters to be considered for the uri. /// Returns the URI taking into account the context, or null if no valid URI is found. - public IUri GetUri(Type endpointType, IApplicationContext applicationContext, params Parameter[] parameters) + public IUri GetUri(Type endpointType, IApplicationContext applicationContext, params IParameter[] parameters) { var endpointContexts = _componentHub.EndpointManager.GetEndpoints(endpointType, applicationContext); var node = _root.GetPreOrder() - .Where(x => endpointContexts.Contains(x.EndpointContext)) - .FirstOrDefault(); + .FirstOrDefault(x => endpointContexts.Contains(x.EndpointContext)); return new UriEndpoint(_serverUri, node?.EndpointContext?.Route.PathSegments, null).BindParameters(parameters); } diff --git a/src/WebExpress.WebCore/WebUri/IUri.cs b/src/WebExpress.WebCore/WebUri/IUri.cs index 4c1d7d0..0adcebd 100644 --- a/src/WebExpress.WebCore/WebUri/IUri.cs +++ b/src/WebExpress.WebCore/WebUri/IUri.cs @@ -46,7 +46,7 @@ public interface IUri /// /// The query part (e.g. ?title=Uniform_Resource_Identifier). /// - IEnumerable Query { get; } + IEnumerable Query { get; } /// /// References a position within a resource (e.g. #Anchor). @@ -83,7 +83,7 @@ public interface IUri /// /// An uri instance containing the original URI with the specified query parameters appended. /// - IUri Add(params UriQuery[] query); + IUri Add(params IUriQuery[] query); /// /// Concatenates the given path segment to the current URI and returns a new instance of IUri with the updated path. @@ -110,7 +110,7 @@ public interface IUri /// /// A new uri instance containing the original URI with the specified query segments appended. /// - IUri Concat(params UriQuery[] query); + IUri Concat(params IUriQuery[] query); /// /// Return a shortened uri containing n-elements. diff --git a/src/WebExpress.WebCore/WebUri/IUriQuery.cs b/src/WebExpress.WebCore/WebUri/IUriQuery.cs new file mode 100644 index 0000000..0951bc0 --- /dev/null +++ b/src/WebExpress.WebCore/WebUri/IUriQuery.cs @@ -0,0 +1,18 @@ +namespace WebExpress.WebCore.WebUri +{ + /// + /// The query part (e.g. ?title=Uniform_Resource_Identifier). + /// + public interface IUriQuery + { + /// + /// Returns the key. + /// + string Key { get; } + + /// + /// Returns the value. + /// + string Value { get; } + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs index 424bd95..1ffd909 100644 --- a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs +++ b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs @@ -63,7 +63,7 @@ public partial class UriEndpoint : IUri /// /// The query part (e.g. ?title=Uniform_Resource_Identifier). /// - public IEnumerable Query { get; private set; } = []; + public IEnumerable Query { get; private set; } = []; /// /// References a position within a resource (e.g. #Anchor). @@ -240,7 +240,7 @@ public UriEndpoint(IUri uri, IEnumerable segments, IEnumerable< /// References a position within a resource (e.g. #Anchor). /// The query part (e.g. ?title=Uniform_Resource_Identifier). /// The path segments. - public UriEndpoint(UriScheme scheme, UriAuthority authority, string fragment, IEnumerable query, IEnumerable segments) + public UriEndpoint(UriScheme scheme, UriAuthority authority, string fragment, IEnumerable query, IEnumerable segments) { Scheme = scheme; Authority = authority; @@ -257,7 +257,7 @@ public UriEndpoint(UriScheme scheme, UriAuthority authority, string fragment, IE /// An array of objects representing the query parameters to add. Each /// parameter must not be null. /// The current instance for method chaining. - public virtual IUri Add(params UriQuery[] query) + public virtual IUri Add(params IUriQuery[] query) { Query = Query.Concat(query.Where(x => x is not null)); @@ -314,7 +314,7 @@ public virtual IUri Concat(params IUriPathSegment[] segments) /// /// A new uri instance containing the original URI with the specified query segments appended. /// - public IUri Concat(params UriQuery[] query) + public IUri Concat(params IUriQuery[] query) { var copy = new UriEndpoint((IUri)this); copy.Query = copy.Query.Concat(query.Where(x => x is not null)); @@ -457,10 +457,17 @@ public virtual IUri BindParameters(params IParameter[] parameters) foreach (var parameter in parameters ?? []) { + var key = parameter switch + { + IParameterStatic staticParam => staticParam.GetKey(), + IParameterDynamic dynamicParam => dynamicParam.Key, + _ => null + }; + pathSegments = pathSegments.Select(x => { if (x is IUriPathSegmentVariable variable && - variable.VariableName.Equals(parameter?.Key, StringComparison.OrdinalIgnoreCase)) + variable.VariableName.Equals(key, StringComparison.OrdinalIgnoreCase)) { var copy = variable.Copy() as IUriPathSegmentVariable; copy.Value = parameter.Value; @@ -472,7 +479,39 @@ public virtual IUri BindParameters(params IParameter[] parameters) }); } - return new UriEndpoint(this, pathSegments); + // copy query collection and bind matching parameters by query keys + var boundQuery = new List(); + foreach (var query in Query) + { + var parameter = parameters + .Select(x => + { + var key = x switch + { + IParameterStatic staticParam => staticParam.GetKey(), + IParameterDynamic dynamicParam => dynamicParam.Key, + _ => null + }; + return (key, x.Value); + }) + .FirstOrDefault(x => x.key.Equals(query?.Key, StringComparison.InvariantCultureIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(parameter.key)) + { + // if parameter found for query key, set value accordingly + boundQuery.Add(new UriQuery(parameter.key, parameter.Value)); + } + else + { + // otherwise keep unchanged + boundQuery.Add(new UriQuery(query.Key, query.Value)); + } + } + + return new UriEndpoint(this, pathSegments) + { + Query = boundQuery + }; } /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs index f96b703..955a7c6 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs @@ -13,7 +13,7 @@ namespace WebExpress.WebCore.WebUri /// /// The parameter type. public abstract class UriPathSegmentVariable : IUriPathSegmentVariable - where TParameter : IParameter + where TParameter : IParameterStatic, new() { /// /// Returns or sets the id. @@ -48,11 +48,10 @@ public abstract class UriPathSegmentVariable : IUriPathSegmentVariab /// /// Initializes a new instance of the class. /// - /// The name. /// The tag or null - public UriPathSegmentVariable(string name, object tag = null) + public UriPathSegmentVariable(object tag = null) { - VariableName = name; + VariableName = TParameter.Key; Tag = tag; } @@ -146,9 +145,9 @@ public IUriPathSegment Copy(string value) public virtual string GetDisplayText(IRenderContext renderContext) { var parameter = renderContext.Request.GetParameter(); - var displayText = parameter.GetDisplayText(renderContext); + var displayText = parameter?.GetDisplayText(renderContext); - return string.Format(I18N.Translate(renderContext, displayText), Value); + return string.Format(I18N.Translate(renderContext, displayText ?? ""), Value); } /// @@ -164,7 +163,7 @@ public virtual string GetDisplayText(IRenderContext renderContext) public virtual IIcon GetIcon(IRenderContext renderContext) { var parameter = renderContext.Request.GetParameter(); - var icon = parameter.GetIcon(renderContext); + var icon = parameter?.GetIcon(renderContext); return icon; } diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableApiVersion.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableApiVersion.cs index acaa9f0..afcd563 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableApiVersion.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableApiVersion.cs @@ -9,17 +9,15 @@ namespace WebExpress.WebCore.WebUri /// /// The parameter type. internal class UriPathSegmentVariableApiVersion : UriPathSegmentVariable - where TParameter : IParameter + where TParameter : IParameterStatic, new() { /// /// Initializes a new instance of the class. /// - /// The name. /// The value. - public UriPathSegmentVariableApiVersion(string name, string value) - : base(name) + public UriPathSegmentVariableApiVersion(string value) + : base() { - VariableName = name; Value = value; } @@ -28,7 +26,7 @@ public UriPathSegmentVariableApiVersion(string name, string value) /// /// The path segment to copy. public UriPathSegmentVariableApiVersion(UriPathSegmentVariableApiVersion segment) - : base(segment.VariableName, segment.Tag) + : base(segment.Tag) { } diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs index 2b8a465..ad52c94 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs @@ -8,18 +8,15 @@ namespace WebExpress.WebCore.WebUri /// /// The parameter type. public class UriPathSegmentVariableDouble : UriPathSegmentVariable - where TParameter : IParameter + where TParameter : IParameterStatic, new() { /// /// Initializes a new instance of the class. /// - /// The name. /// The tag or null - public UriPathSegmentVariableDouble(string name, object tag = null) - : base(name, tag) + public UriPathSegmentVariableDouble(object tag = null) + : base(tag) { - VariableName = name; - Value = name; Expression = @"^[+-]?(\d*,\d+|\d+(,\d*)?)( +[eE][+-]?\d+)?$"; Tag = tag; } @@ -29,7 +26,7 @@ public UriPathSegmentVariableDouble(string name, object tag = null) /// /// The path segment to copy. public UriPathSegmentVariableDouble(UriPathSegmentVariableDouble segment) - : base(segment.VariableName, segment.Tag) + : base(segment.Tag) { Expression = segment.Expression; } diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableGuid.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableGuid.cs index ffd0e64..4076ad6 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableGuid.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableGuid.cs @@ -11,7 +11,7 @@ namespace WebExpress.WebCore.WebUri /// /// The parameter type. public class UriPathSegmentVariableGuid : UriPathSegmentVariable - where TParameter : IParameter + where TParameter : IParameterStatic, new() { /// /// The display formats of the guid. @@ -37,23 +37,20 @@ public enum Format /// /// Initializes a new instance of the class. /// - /// The path text. /// The tag or null - public UriPathSegmentVariableGuid(string name, object tag = null) - : this(name, Format.Simple, tag) + public UriPathSegmentVariableGuid(object tag = null) + : this(Format.Simple, tag) { } /// /// Initializes a new instance of the class. /// - /// The name. /// The display format. /// The tag or null - public UriPathSegmentVariableGuid(string name, Format displayFormat, object tag = null) - : base(name, tag) + public UriPathSegmentVariableGuid(Format displayFormat, object tag = null) + : base(tag) { - VariableName = name; DisplayFormat = displayFormat; Expression = @"^(\{){0,1}(([0-9a-fA-F]{8})\-([0-9a-fA-F]{4})\-([0-9a-fA-F]{4})\-([0-9a-fA-F]{4})\-([0-9a-fA-F]{12}))(\}){0,1}$"; } @@ -86,7 +83,7 @@ public override IDictionary GetVariable(string value) /// The copy. public override IUriPathSegment Copy() { - return new UriPathSegmentVariableGuid(VariableName, DisplayFormat) + return new UriPathSegmentVariableGuid(DisplayFormat) { Expression = Expression, Value = Value @@ -103,6 +100,11 @@ public override IUriPathSegment Copy() /// public override string GetDisplayText(IRenderContext renderContext) { + if (Value is null) + { + return base.GetDisplayText(renderContext); + } + var match = Regex.Match(Value, Expression, RegexOptions.IgnoreCase | RegexOptions.Compiled); var guid = DisplayFormat == Format.Simple ? match.Groups[7].ToString() : match.Groups[2].ToString(); diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs index 8e76448..d6364c6 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs @@ -8,18 +8,15 @@ namespace WebExpress.WebCore.WebUri /// /// The parameter type. public class UriPathSegmentVariableInt : UriPathSegmentVariable - where TParameter : IParameter + where TParameter : IParameterStatic, new() { /// /// Initializes a new instance of the class. /// - /// The name. /// The tag or null - public UriPathSegmentVariableInt(string name, object tag = null) - : base(name, tag) + public UriPathSegmentVariableInt(object tag = null) + : base(tag) { - VariableName = name; - Value = name; Expression = @"^[+-]*\d$"; Tag = tag; } @@ -29,7 +26,7 @@ public UriPathSegmentVariableInt(string name, object tag = null) /// /// The path segment to copy. public UriPathSegmentVariableInt(UriPathSegmentVariableInt segment) - : base(segment.VariableName, segment.Tag) + : base(segment.Tag) { Expression = segment.Expression; } diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs index b8686be..fd93296 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs @@ -8,19 +8,16 @@ namespace WebExpress.WebCore.WebUri /// /// The parameter type. public class UriPathSegmentVariableRegex : UriPathSegmentVariable - where TParameter : IParameter + where TParameter : IParameterStatic, new() { /// /// Initializes a new instance of the class. /// - /// The name. /// The regular expression. /// The tag or null - public UriPathSegmentVariableRegex(string name, string regex, object tag = null) - : base(name, tag) + public UriPathSegmentVariableRegex(string regex, object tag = null) + : base(tag) { - VariableName = name; - Value = name; Expression = regex; Tag = tag; } @@ -30,7 +27,7 @@ public UriPathSegmentVariableRegex(string name, string regex, object tag = null) /// /// The path segment to copy. public UriPathSegmentVariableRegex(UriPathSegmentVariableRegex segment) - : base(segment.VariableName, segment.Tag) + : base(segment.Tag) { Expression = segment.Expression; } diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableString.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableString.cs index b4c9fec..f4c19a6 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableString.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableString.cs @@ -8,18 +8,15 @@ namespace WebExpress.WebCore.WebUri /// /// The parameter type. public class UriPathSegmentVariableString : UriPathSegmentVariable - where TParameter : IParameter + where TParameter : IParameterStatic, new() { /// /// Initializes a new instance of the class. /// - /// The name. /// The tag or null - public UriPathSegmentVariableString(string name, object tag = null) - : base(name, tag) + public UriPathSegmentVariableString(object tag = null) + : base(tag) { - VariableName = name; - Value = name; Expression = "^[^\"]*$"; Tag = tag; } @@ -29,7 +26,7 @@ public UriPathSegmentVariableString(string name, object tag = null) /// /// The path segment to copy. public UriPathSegmentVariableString(UriPathSegmentVariableString segment) - : base(segment.VariableName, segment.Tag) + : base(segment.Tag) { Expression = segment.Expression; } diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableUInt.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableUInt.cs index 721612c..46472b6 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableUInt.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableUInt.cs @@ -8,18 +8,15 @@ namespace WebExpress.WebCore.WebUri /// /// The parameter type. public class UriPathSegmentVariableUInt : UriPathSegmentVariable - where TParameter : IParameter + where TParameter : IParameterStatic, new() { /// /// Initializes a new instance of the class. /// - /// The name. /// The tag or null - public UriPathSegmentVariableUInt(string name, object tag = null) - : base(name, tag) + public UriPathSegmentVariableUInt(object tag = null) + : base(tag) { - VariableName = name; - Value = name; Expression = @"^\d$"; Tag = tag; } @@ -29,7 +26,7 @@ public UriPathSegmentVariableUInt(string name, object tag = null) /// /// The path segment to copy. public UriPathSegmentVariableUInt(UriPathSegmentVariableUInt segment) - : base(segment.VariableName, segment.Tag) + : base(segment.Tag) { Expression = segment.Expression; } diff --git a/src/WebExpress.WebCore/WebUri/UriQuery.cs b/src/WebExpress.WebCore/WebUri/UriQuery.cs index 54f6982..ebf5b07 100644 --- a/src/WebExpress.WebCore/WebUri/UriQuery.cs +++ b/src/WebExpress.WebCore/WebUri/UriQuery.cs @@ -1,9 +1,11 @@ +using WebExpress.WebCore.WebParameter; + namespace WebExpress.WebCore.WebUri { /// /// The query part (e.g. ?title=Uniform_Resource_Identifier). /// - public class UriQuery + public class UriQuery : IUriQuery { /// /// Returns the key. @@ -35,4 +37,44 @@ public override string ToString() return $"{Key}={Value}"; } } + + /// + /// Represents a key-value pair used as a query parameter in a URI. + /// + /// + /// The type that implements the IParameterStatic interface and defines the structure + /// of the query parameter. + /// + public class UriQuery : IUriQuery + where TParamerer : IParameterStatic + { + /// + /// Returns the key. + /// + public string Key { get; protected set; } + + /// + /// Returns the value. + /// + public string Value { get; protected set; } + + /// + /// Initializes a new instance of the class. + /// + /// The value. + public UriQuery(string value = null) + { + Key = TParamerer.Key; + Value = value; + } + + /// + /// Converts the query to a string. + /// + /// A string that represents the current query. + public override string ToString() + { + return $"{Key}={Value}"; + } + } } \ No newline at end of file From aad31e8dc28a10ce2d5557b3539e1cdecc291451 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Wed, 4 Mar 2026 21:51:31 +0100 Subject: [PATCH 39/53] feat: general improvements and minor bugs --- .../Route/UnitTestUriPathSegment.cs | 22 ++++++------ src/WebExpress.WebCore.Test/TestParameterA.cs | 33 +----------------- src/WebExpress.WebCore.Test/TestParameterB.cs | 33 +----------------- .../WebParameter/IParameter.cs | 27 +-------------- .../WebParameter/Parameter.cs | 30 ---------------- .../WebParameter/ParameterApiVersion.cs | 34 +------------------ .../WebParameter/ParameterId.cs | 31 ----------------- .../WebUri/UriPathSegmentVariable.cs | 11 ++---- 8 files changed, 17 insertions(+), 204 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Route/UnitTestUriPathSegment.cs b/src/WebExpress.WebCore.Test/Route/UnitTestUriPathSegment.cs index b39b251..343198e 100644 --- a/src/WebExpress.WebCore.Test/Route/UnitTestUriPathSegment.cs +++ b/src/WebExpress.WebCore.Test/Route/UnitTestUriPathSegment.cs @@ -32,8 +32,8 @@ public void Constant(string value, string expected, string displayText) /// Test the int segment. /// [Theory] - [InlineData(null, "${testparametera}", "")] - [InlineData("123", "123", "")] + [InlineData(null, "${testparametera}", null)] + [InlineData("123", "123", "123")] public void Int(string value, string expected, string displayText) { // arrange @@ -54,8 +54,8 @@ public void Int(string value, string expected, string displayText) /// Test the uint segment. /// [Theory] - [InlineData(null, "${testparametera}", "")] - [InlineData("123", "123", "")] + [InlineData(null, "${testparametera}", null)] + [InlineData("123", "123", "123")] public void UInt(string value, string expected, string displayText) { // arrange @@ -76,8 +76,8 @@ public void UInt(string value, string expected, string displayText) /// Test the double segment. /// [Theory] - [InlineData(null, "${testparametera}", "")] - [InlineData("123", "123", "")] + [InlineData(null, "${testparametera}", null)] + [InlineData("123", "123", "123")] public void Double(string value, string expected, string displayText) { // arrange @@ -98,7 +98,7 @@ public void Double(string value, string expected, string displayText) /// Test the guid segment. /// [Theory] - [InlineData(null, "${testparametera}", "")] + [InlineData(null, "${testparametera}", null)] [InlineData("123", "123", "")] public void Guid(string value, string expected, string displayText) { @@ -120,8 +120,8 @@ public void Guid(string value, string expected, string displayText) /// Test the regex segment. /// [Theory] - [InlineData(null, "${testparametera}", "")] - [InlineData("123", "123", "")] + [InlineData(null, "${testparametera}", null)] + [InlineData("123", "123", "123")] public void Regex(string value, string expected, string displayText) { // arrange @@ -142,8 +142,8 @@ public void Regex(string value, string expected, string displayText) /// Test the regex segment. /// [Theory] - [InlineData(null, "${testparametera}", "")] - [InlineData("123", "123", "")] + [InlineData(null, "${testparametera}", null)] + [InlineData("123", "123", "123")] public void String(string value, string expected, string displayText) { // arrange diff --git a/src/WebExpress.WebCore.Test/TestParameterA.cs b/src/WebExpress.WebCore.Test/TestParameterA.cs index d9b0a49..9896cf7 100644 --- a/src/WebExpress.WebCore.Test/TestParameterA.cs +++ b/src/WebExpress.WebCore.Test/TestParameterA.cs @@ -1,6 +1,4 @@ -using WebExpress.WebCore.WebIcon; -using WebExpress.WebCore.WebPage; -using WebExpress.WebCore.WebParameter; +using WebExpress.WebCore.WebParameter; namespace WebExpress.WebCore.Test { @@ -50,35 +48,6 @@ public TestParameterA(Guid value) Value = value.ToString(); } - /// - /// Returns a string that represents the display text for the current instance. - /// - /// The render context. - /// - /// A string containing the display text associated with the instance. The - /// value may be empty if no display text is available. - /// - public string GetDisplayText(IRenderContext renderContext) - { - return Value; - } - - /// - /// Retrieves the icon that corresponds to the specified render context. - /// - /// - /// The context in which the icon will be rendered. This parameter determines - /// the appearance and behavior of the - /// returned icon. - /// - /// - /// An icon instance that represents the icon for the given render context. - /// - public IIcon GetIcon(IRenderContext renderContext) - { - return null; - } - /// /// Retrieves the unique key associated with the current instance. /// diff --git a/src/WebExpress.WebCore.Test/TestParameterB.cs b/src/WebExpress.WebCore.Test/TestParameterB.cs index c4c3cbc..a28e0da 100644 --- a/src/WebExpress.WebCore.Test/TestParameterB.cs +++ b/src/WebExpress.WebCore.Test/TestParameterB.cs @@ -1,6 +1,4 @@ -using WebExpress.WebCore.WebIcon; -using WebExpress.WebCore.WebPage; -using WebExpress.WebCore.WebParameter; +using WebExpress.WebCore.WebParameter; namespace WebExpress.WebCore.Test { @@ -48,35 +46,6 @@ public TestParameterB(Guid value) { } - /// - /// Returns a string that represents the display text for the current instance. - /// - /// The render context. - /// - /// A string containing the display text associated with the instance. The - /// value may be empty if no display text is available. - /// - public string GetDisplayText(IRenderContext renderContext) - { - return Value; - } - - /// - /// Retrieves the icon that corresponds to the specified render context. - /// - /// - /// The context in which the icon will be rendered. This parameter determines - /// the appearance and behavior of the - /// returned icon. - /// - /// - /// An icon instance that represents the icon for the given render context. - /// - public IIcon GetIcon(IRenderContext renderContext) - { - return null; - } - /// /// Retrieves the unique key associated with the current instance. /// diff --git a/src/WebExpress.WebCore/WebParameter/IParameter.cs b/src/WebExpress.WebCore/WebParameter/IParameter.cs index b76e5c1..6db9f5c 100644 --- a/src/WebExpress.WebCore/WebParameter/IParameter.cs +++ b/src/WebExpress.WebCore/WebParameter/IParameter.cs @@ -1,7 +1,4 @@ -using WebExpress.WebCore.WebIcon; -using WebExpress.WebCore.WebPage; - -namespace WebExpress.WebCore.WebParameter +namespace WebExpress.WebCore.WebParameter { /// /// Represents a parameter with a key, value, and scope. @@ -17,27 +14,5 @@ public interface IParameter /// Returns the value of the parameter. /// string Value { get; set; } - - /// - /// Returns a string that represents the display text for the current instance. - /// - /// The render context. - /// - /// A string containing the display text associated with the instance. The - /// value may be empty if no display text is available. - /// - string GetDisplayText(IRenderContext renderContext); - - /// - /// Returns an icon that visually represents the parameter within the given render context. - /// - /// - /// The rendering context that provides information required to determine the appropriate icon. - /// - /// - /// An icon associated with the current instance. The value may be null or empty - /// if no icon is available. - /// - IIcon GetIcon(IRenderContext renderContext); } } diff --git a/src/WebExpress.WebCore/WebParameter/Parameter.cs b/src/WebExpress.WebCore/WebParameter/Parameter.cs index a64d164..d35e89c 100644 --- a/src/WebExpress.WebCore/WebParameter/Parameter.cs +++ b/src/WebExpress.WebCore/WebParameter/Parameter.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; using System.Text; -using WebExpress.WebCore.WebIcon; -using WebExpress.WebCore.WebPage; namespace WebExpress.WebCore.WebParameter { @@ -97,34 +95,6 @@ public Parameter(string key, char value, ParameterScope scope) Scope = scope; } - /// - /// Returns a string that represents the display text for the current instance. - /// - /// The render context. - /// - /// A string containing the display text associated with the instance. The - /// value may be empty if no display text is available. - /// - public virtual string GetDisplayText(IRenderContext renderContext) - { - return Value; - } - - /// - /// Returns an icon that visually represents the parameter within the given render context. - /// - /// - /// The rendering context that provides information required to determine the appropriate icon. - /// - /// - /// An icon associated with the current instance. The value may be null or empty - /// if no icon is available. - /// - public virtual IIcon GetIcon(IRenderContext renderContext) - { - return null; - } - /// /// Creates a parameter list. /// diff --git a/src/WebExpress.WebCore/WebParameter/ParameterApiVersion.cs b/src/WebExpress.WebCore/WebParameter/ParameterApiVersion.cs index c9afb41..804e36a 100644 --- a/src/WebExpress.WebCore/WebParameter/ParameterApiVersion.cs +++ b/src/WebExpress.WebCore/WebParameter/ParameterApiVersion.cs @@ -1,7 +1,4 @@ -using WebExpress.WebCore.WebIcon; -using WebExpress.WebCore.WebPage; - -namespace WebExpress.WebCore.WebParameter +namespace WebExpress.WebCore.WebParameter { /// /// Represents a api version parameter with a key, value, and scope. @@ -42,35 +39,6 @@ public ParameterApiVersion(string value) Value = value; } - /// - /// Returns a string that represents the display text for the current instance. - /// - /// The render context. - /// - /// A string containing the display text associated with the instance. The - /// value may be empty if no display text is available. - /// - public string GetDisplayText(IRenderContext renderContext) - { - return Value; - } - - /// - /// Retrieves the icon that corresponds to the specified render context. - /// - /// - /// The context in which the icon will be rendered. This parameter determines - /// the appearance and behavior of the - /// returned icon. - /// - /// - /// An icon instance that represents the icon for the given render context. - /// - public IIcon GetIcon(IRenderContext renderContext) - { - return null; - } - /// /// Retrieves the unique key associated with the current instance. /// diff --git a/src/WebExpress.WebCore/WebParameter/ParameterId.cs b/src/WebExpress.WebCore/WebParameter/ParameterId.cs index 79a64cc..a24552c 100644 --- a/src/WebExpress.WebCore/WebParameter/ParameterId.cs +++ b/src/WebExpress.WebCore/WebParameter/ParameterId.cs @@ -1,6 +1,4 @@ using System; -using WebExpress.WebCore.WebIcon; -using WebExpress.WebCore.WebPage; namespace WebExpress.WebCore.WebParameter { @@ -53,35 +51,6 @@ public ParameterId(Guid value) Value = value.ToString(); } - /// - /// Returns a string that represents the display text for the current instance. - /// - /// The render context. - /// - /// A string containing the display text associated with the instance. The - /// value may be empty if no display text is available. - /// - public string GetDisplayText(IRenderContext renderContext) - { - return Value; - } - - /// - /// Retrieves the icon that corresponds to the specified render context. - /// - /// - /// The context in which the icon will be rendered. This parameter determines - /// the appearance and behavior of the - /// returned icon. - /// - /// - /// An icon instance that represents the icon for the given render context. - /// - public IIcon GetIcon(IRenderContext renderContext) - { - return null; - } - /// /// Retrieves the unique key associated with the current instance. /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs index 955a7c6..a2a55ef 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Text.RegularExpressions; -using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebIcon; using WebExpress.WebCore.WebPage; using WebExpress.WebCore.WebParameter; @@ -144,10 +143,7 @@ public IUriPathSegment Copy(string value) /// public virtual string GetDisplayText(IRenderContext renderContext) { - var parameter = renderContext.Request.GetParameter(); - var displayText = parameter?.GetDisplayText(renderContext); - - return string.Format(I18N.Translate(renderContext, displayText ?? ""), Value); + return Value; } /// @@ -162,10 +158,7 @@ public virtual string GetDisplayText(IRenderContext renderContext) /// public virtual IIcon GetIcon(IRenderContext renderContext) { - var parameter = renderContext.Request.GetParameter(); - var icon = parameter?.GetIcon(renderContext); - - return icon; + return null; } /// From 4b73ccf1f5ea516d5af30b0615d19ef23c7b4a17 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Thu, 5 Mar 2026 21:43:45 +0100 Subject: [PATCH 40/53] feat: general improvements and minor bugs --- src/WebExpress.WebCore/HttpServer.cs | 100 +++++++++++++++++- .../HttpServerStatisticItem.cs | 55 ++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 src/WebExpress.WebCore/HttpServerStatisticItem.cs diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index cc16a58..8311c38 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; +using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Linq; @@ -73,6 +74,21 @@ public class HttpServer : IHost, IHttpApplication /// public long RequestNumber { get; private set; } + /// + /// Returns the statistics history. + /// + public static List Statistics { get; } = []; + + /// + /// Synchronization object for statistics. + /// + private static readonly Lock _statLock = new(); + + // Variables for CPU usage calculation + private static DateTime _lastCpuTime = DateTime.UtcNow; + private static TimeSpan _lastProcessorTime = Process.GetCurrentProcess().TotalProcessorTime; + private static readonly Process _currentProcess = Process.GetCurrentProcess(); + /// /// Initializes a new instance of the class. /// @@ -357,6 +373,8 @@ private IResponse HandleClient(IHttpContext context, SearchResult searchResult) stopwatch.Stop(); + UpdateStatistics(response, stopwatch.ElapsedMilliseconds); + HttpServerContext.Log.Info(I18N.Translate ( "webexpress.webcore:httpserver.request.done", @@ -369,6 +387,86 @@ private IResponse HandleClient(IHttpContext context, SearchResult searchResult) return response; } + /// + /// Updates the request statistics with ring buffer logic (max 24h). + /// + /// The response containing the status code. + /// The duration of the request in milliseconds. + private static void UpdateStatistics(IResponse response, long duration) + { + var now = DateTime.Now; + var minute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0); + var isError = response != null && response.Status >= 400; + + // calculate memory usage in MB + var memUsage = _currentProcess.WorkingSet64 / (1024.0 * 1024.0); + + // calculate cpu usage + var currentCpuTime = _currentProcess.TotalProcessorTime; + var currentWallTime = DateTime.UtcNow; + var cpuUsedMs = (currentCpuTime - _lastProcessorTime).TotalMilliseconds; + var totalMsPassed = (currentWallTime - _lastCpuTime).TotalMilliseconds; + var cpuUsage = 0.0; + + if (totalMsPassed > 0) + { + cpuUsage = (cpuUsedMs / (totalMsPassed * Environment.ProcessorCount)) * 100.0; + } + + // update pointers for next calculation + _lastProcessorTime = currentCpuTime; + _lastCpuTime = currentWallTime; + + lock (_statLock) + { + // remove entries older than 24 hours (1440 minutes) + while (Statistics.Count >= 1440) + { + Statistics.RemoveAt(0); + } + + var current = Statistics.LastOrDefault(); + + if (current != null && current.Timestamp == minute) + { + current.Requests++; + if (isError) + { + current.Errors++; + } + + // update min, max and total duration + if (duration < current.MinDuration) + { + current.MinDuration = duration; + } + if (duration > current.MaxDuration) + { + current.MaxDuration = duration; + } + current.TotalDuration += duration; + + // calculate moving average for system metrics within this minute + current.CpuUsage += (cpuUsage - current.CpuUsage) / current.Requests; + current.MemoryUsage += (memUsage - current.MemoryUsage) / current.Requests; + } + else + { + Statistics.Add(new HttpServerStatisticItem() + { + Timestamp = minute, + Requests = 1, + Errors = isError ? 1 : 0, + MinDuration = duration, + MaxDuration = duration, + TotalDuration = duration, + CpuUsage = cpuUsage, + MemoryUsage = memUsage + }); + } + } + } + /// /// Creates a status page. /// @@ -589,7 +687,7 @@ public void DisposeContext(IHttpContext context, Exception exception) /// /// The HTTP request feature instance. /// True if it is a WebSocket connection; otherwise, false. - private bool IsWebSocketRequest(IHttpRequestFeature requestFeature) + private static bool IsWebSocketRequest(IHttpRequestFeature requestFeature) { // check scheme and "Upgrade" header for websocket protocol if (requestFeature == null) diff --git a/src/WebExpress.WebCore/HttpServerStatisticItem.cs b/src/WebExpress.WebCore/HttpServerStatisticItem.cs new file mode 100644 index 0000000..c2d8719 --- /dev/null +++ b/src/WebExpress.WebCore/HttpServerStatisticItem.cs @@ -0,0 +1,55 @@ +using System; + +namespace WebExpress.WebCore +{ + /// + /// Represents a single data point for server statistics. + /// + public class HttpServerStatisticItem + { + /// + /// Returns or sets the timestamp (grouped by minute). + /// + public DateTime Timestamp { get; set; } + + /// + /// Returns or sets the total number of requests. + /// + public int Requests { get; set; } + + /// + /// Returns or sets the number of erroneous requests (status code >= 400). + /// + public int Errors { get; set; } + + /// + /// Returns or sets the minimum response time in milliseconds. + /// + public long MinDuration { get; set; } + + /// + /// Returns or sets the maximum response time in milliseconds. + /// + public long MaxDuration { get; set; } + + /// + /// Returns or sets the total duration of all requests in this interval in milliseconds. + /// + public long TotalDuration { get; set; } + + /// + /// Returns the average response time in milliseconds. + /// + public double AverageDuration => Requests > 0 ? (double)TotalDuration / Requests : 0; + + /// + /// Returns or sets the average CPU usage in percentage (0-100) during this interval. + /// + public double CpuUsage { get; set; } + + /// + /// Returns or sets the average memory usage in MB during this interval. + /// + public double MemoryUsage { get; set; } + } +} From bff4f1e785fc209dcf6d858e12476f2fea85047a Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Fri, 6 Mar 2026 22:13:08 +0100 Subject: [PATCH 41/53] feat: general improvements and minor bugs --- .../WWW/Products/Index.cs | 1 + src/WebExpress.WebCore/HttpServer.cs | 32 +++++++++++------ .../WebAttribute/ApplicationAttribute.cs | 3 +- .../WebAttribute/SegmentHiddenAttribute.cs | 17 +++++++++ .../WebEndpoint/EndpointManager.cs | 35 +++++++++++++------ .../WebUri/IUriPathSegment.cs | 14 ++++++++ .../WebUri/UriPathSegmentConstant.cs | 20 ++++++++++- .../WebUri/UriPathSegmentRoot.cs | 20 ++++++++++- .../WebUri/UriPathSegmentVariable.cs | 14 ++++++++ .../UriPathSegmentVariableApiVersion.cs | 15 +++----- .../WebUri/UriPathSegmentVariableDouble.cs | 7 +++- .../WebUri/UriPathSegmentVariableGuid.cs | 4 ++- .../WebUri/UriPathSegmentVariableInt.cs | 7 +++- .../WebUri/UriPathSegmentVariableRegex.cs | 7 +++- .../WebUri/UriPathSegmentVariableString.cs | 7 +++- .../WebUri/UriPathSegmentVariableUInt.cs | 7 +++- 16 files changed, 169 insertions(+), 41 deletions(-) create mode 100644 src/WebExpress.WebCore/WebAttribute/SegmentHiddenAttribute.cs diff --git a/src/WebExpress.WebCore.Test/WWW/Products/Index.cs b/src/WebExpress.WebCore.Test/WWW/Products/Index.cs index d105b4b..e5e0d65 100644 --- a/src/WebExpress.WebCore.Test/WWW/Products/Index.cs +++ b/src/WebExpress.WebCore.Test/WWW/Products/Index.cs @@ -6,6 +6,7 @@ namespace WebExpress.WebCore.Test.WWW.Products /// /// A dummy class for testing purposes. /// + [SegmentHidden] [Title("webindex:index.label")] public sealed class Index : IPage { diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index 8311c38..e5fbcf5 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -13,6 +13,7 @@ using System.Linq; using System.Net; using System.Net.Sockets; +using System.Reflection; using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; @@ -356,19 +357,28 @@ private IResponse HandleClient(IHttpContext context, SearchResult searchResult) } catch (Exception ex) { - HttpServerContext.Log.Exception(ex); + if (ex is TargetInvocationException tie && tie.InnerException is RedirectException rex) + { + response = rex.Permanet + ? new ResponseMovedPermanently(rex.Uri) + : new ResponseMovedTemporarily(rex.Uri); + } + else + { + HttpServerContext.Log.Exception(ex); - var message = $"

Message

{ex.Message}

" + - $"
Source
{ex.Source}

" + - $"
StackTrace
{ex.StackTrace.Replace("\n", "
\n")}

" + - $"
InnerException
{ex.InnerException?.ToString().Replace("\n", "
\n")}"; + var message = $"

Message

{ex.Message}

" + + $"
Source
{ex.Source}

" + + $"
StackTrace
{ex.StackTrace.Replace("\n", "
\n")}

" + + $"
InnerException
{ex.InnerException?.ToString().Replace("\n", "
\n")}"; - response = CreateStatusPage - ( - message, - request, - searchResult - ); + response = CreateStatusPage + ( + message, + request, + searchResult + ); + } } stopwatch.Stop(); diff --git a/src/WebExpress.WebCore/WebAttribute/ApplicationAttribute.cs b/src/WebExpress.WebCore/WebAttribute/ApplicationAttribute.cs index adb5ee8..28d3f17 100644 --- a/src/WebExpress.WebCore/WebAttribute/ApplicationAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/ApplicationAttribute.cs @@ -8,7 +8,8 @@ namespace WebExpress.WebCore.WebAttribute ///
/// The type of the application. [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] - public class ApplicationAttribute : Attribute, IPluginAttribute where TApplication : class, IApplication + public class ApplicationAttribute : Attribute, IPluginAttribute + where TApplication : class, IApplication { } diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentHiddenAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentHiddenAttribute.cs new file mode 100644 index 0000000..586845e --- /dev/null +++ b/src/WebExpress.WebCore/WebAttribute/SegmentHiddenAttribute.cs @@ -0,0 +1,17 @@ +using System; + +namespace WebExpress.WebCore.WebAttribute +{ + /// + /// Indicates that a segment is hidden. + /// + /// + /// This attribute can be used to determine if the segment should not be displayed in user + /// interfaces. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class SegmentHiddenAttribute : Attribute + { + + } +} diff --git a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs index cdb3d03..5bec36d 100644 --- a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs +++ b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs @@ -182,9 +182,23 @@ public static IRoute CreateEndpointRoute var name = default(string); var description = default(string); var icon = default(IIcon); + var hidden = false; - var typeName = $"{s.FullNamespace}.Index"; - var segmentInfoType = classType.Assembly.GetType(typeName, throwOnError: false, ignoreCase: true); + var segmentInfoType = classType.Assembly + .GetTypes() + .Where(t => t.IsClass) + .Where(t => t.Namespace?.ToLowerInvariant() == s.FullNamespace.ToLowerInvariant()) + .FirstOrDefault(t => t.Name.StartsWith("Index", StringComparison.OrdinalIgnoreCase)); + + var nameAttr = segmentInfoType?.CustomAttributes + .FirstOrDefault(x => x.AttributeType == typeof(TitleAttribute)); + var descAttr = segmentInfoType?.CustomAttributes + .FirstOrDefault(x => x.AttributeType == typeof(DescriptionAttribute)); + var iconAttr = segmentInfoType?.CustomAttributes + .FirstOrDefault(x => x.AttributeType.IsGenericType && + x.AttributeType.GetGenericTypeDefinition() == typeof(WebIconAttribute<>)); + var hiddenAttr = segmentInfoType?.CustomAttributes + .FirstOrDefault(x => x.AttributeType == typeof(SegmentHiddenAttribute)); if (segmentInfoType is not null) { @@ -196,28 +210,27 @@ public static IRoute CreateEndpointRoute var segInstance = segAttrType is not null ? segmentInfoType.GetCustomAttribute(segAttrType, false) as ISegmentAttribute : null; - var nameAttr = segmentInfoType.CustomAttributes - .FirstOrDefault(x => x.AttributeType == typeof(TitleAttribute)); - var descAttr = segmentInfoType.CustomAttributes - .FirstOrDefault(x => x.AttributeType == typeof(DescriptionAttribute)); - var iconAttr = segmentInfoType.CustomAttributes - .FirstOrDefault(x => x.AttributeType.IsGenericType && - x.AttributeType.GetGenericTypeDefinition() == typeof(WebIconAttribute<>)); segmentResult = segInstance?.ToPathSegment(); + segmentResult?.IsHidden = hiddenAttr is not null; name = nameAttr?.ConstructorArguments.FirstOrDefault().Value?.ToString(); description = descAttr?.ConstructorArguments.FirstOrDefault().Value?.ToString(); icon = iconAttr is not null ? Activator.CreateInstance(iconAttr.AttributeType.GenericTypeArguments.FirstOrDefault()) as IIcon : null; + hidden = hiddenAttr is not null; } return new { - Segment = segmentResult ?? new UriPathSegmentConstant(s.Segment), + Segment = segmentResult ?? new UriPathSegmentConstant(s.Segment) + { + IsHidden = segmentInfoType is null || hiddenAttr is not null + }, Name = name, Description = description, - Icon = icon + Icon = icon, + Hidden = segmentResult is null || hidden }; }); diff --git a/src/WebExpress.WebCore/WebUri/IUriPathSegment.cs b/src/WebExpress.WebCore/WebUri/IUriPathSegment.cs index 4dc32b5..a876c35 100644 --- a/src/WebExpress.WebCore/WebUri/IUriPathSegment.cs +++ b/src/WebExpress.WebCore/WebUri/IUriPathSegment.cs @@ -25,6 +25,20 @@ public interface IUriPathSegment ///
bool IsEmpty { get; } + /// + /// Returns a value indicating whether the item is hidden. + /// + /// + /// This property can be used to determine if the item should be displayed in user + /// interfaces or lists. + /// + bool IsHidden { get; internal set; } + + /// + /// Returns the URI to which the user is redirected. + /// + IUri Uri { get; internal set; } + /// /// Checks whether the node matches the path element. /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentConstant.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentConstant.cs index 1583246..cc1eee4 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentConstant.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentConstant.cs @@ -28,6 +28,20 @@ public class UriPathSegmentConstant : IUriPathSegmentConstant ///
public bool IsEmpty => string.IsNullOrWhiteSpace(Value) || Value.Equals("/"); + /// + /// Returns or sets a value indicating whether the item is hidden. + /// + /// + /// This property can be used to determine if the item should be displayed in user + /// interfaces or lists. + /// + public bool IsHidden { get; set; } + + /// + /// Returns or sets the URI to which the user is redirected. + /// + public IUri Uri { get; set; } + /// /// Initializes a new instance of the class. /// @@ -61,7 +75,11 @@ public bool IsMatched(string value) /// The copy. public virtual IUriPathSegment Copy() { - return new UriPathSegmentConstant(Value, Tag); + return new UriPathSegmentConstant(Value, Tag) + { + IsHidden = IsHidden, + Uri = Uri + }; } /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentRoot.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentRoot.cs index 976ce05..02fedff 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentRoot.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentRoot.cs @@ -34,6 +34,20 @@ public class UriPathSegmentRoot : IUriPathSegment /// public bool IsEmpty => false; + /// + /// Returns or sets a value indicating whether the item is hidden. + /// + /// + /// This property can be used to determine if the item should be displayed in user + /// interfaces or lists. + /// + public bool IsHidden { get; set; } + + /// + /// Returns or sets the URI to which the user is redirected. + /// + public IUri Uri { get; set; } + /// /// Initializes a new instance of the class. /// @@ -68,7 +82,11 @@ public bool IsMatched(string value) /// A copy of the current segment. public virtual IUriPathSegment Copy() { - return new UriPathSegmentRoot(Display, Tag); + return new UriPathSegmentRoot(Display, Tag) + { + IsHidden = IsHidden, + Uri = Uri + }; } /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs index a2a55ef..626f6a6 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs @@ -44,6 +44,20 @@ public abstract class UriPathSegmentVariable : IUriPathSegmentVariab /// public bool IsEmpty => string.IsNullOrWhiteSpace(VariableName) || VariableName.Equals("/"); + /// + /// Returns or sets a value indicating whether the item is hidden. + /// + /// + /// This property can be used to determine if the item should be displayed in user + /// interfaces or lists. + /// + public bool IsHidden { get; set; } + + /// + /// Returns or sets the URI to which the user is redirected. + /// + public IUri Uri { get; set; } + /// /// Initializes a new instance of the class. /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableApiVersion.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableApiVersion.cs index afcd563..0be147e 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableApiVersion.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableApiVersion.cs @@ -21,15 +21,6 @@ public UriPathSegmentVariableApiVersion(string value) Value = value; } - /// - /// Initializes a new instance of the class. - /// - /// The path segment to copy. - public UriPathSegmentVariableApiVersion(UriPathSegmentVariableApiVersion segment) - : base(segment.Tag) - { - } - /// /// Returns the variable. /// @@ -65,7 +56,11 @@ public override bool IsMatched(string value) /// The copy. public override IUriPathSegment Copy() { - return new UriPathSegmentVariableApiVersion(this) { Value = Value }; + return new UriPathSegmentVariableApiVersion(Value) + { + IsHidden = IsHidden, + Uri = Uri + }; } /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs index ad52c94..7d4f32d 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs @@ -47,7 +47,12 @@ public override IDictionary GetVariable(string value) /// The copy. public override IUriPathSegment Copy() { - return new UriPathSegmentVariableDouble(this) { Value = Value }; + return new UriPathSegmentVariableDouble(this) + { + Value = Value, + IsHidden = IsHidden, + Uri = Uri + }; } /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableGuid.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableGuid.cs index 4076ad6..5f207a5 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableGuid.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableGuid.cs @@ -86,7 +86,9 @@ public override IUriPathSegment Copy() return new UriPathSegmentVariableGuid(DisplayFormat) { Expression = Expression, - Value = Value + Value = Value, + IsHidden = IsHidden, + Uri = Uri }; } diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs index d6364c6..f44bed3 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs @@ -47,7 +47,12 @@ public override IDictionary GetVariable(string value) /// The copy. public override IUriPathSegment Copy() { - return new UriPathSegmentVariableInt(this) { Value = Value }; + return new UriPathSegmentVariableInt(this) + { + Value = Value, + IsHidden = IsHidden, + Uri = Uri + }; } /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs index fd93296..f584bbc 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs @@ -48,7 +48,12 @@ public override IDictionary GetVariable(string value) /// The copy. public override IUriPathSegment Copy() { - return new UriPathSegmentVariableRegex(this) { Value = Value }; + return new UriPathSegmentVariableRegex(this) + { + Value = Value, + IsHidden = IsHidden, + Uri = Uri + }; } /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableString.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableString.cs index f4c19a6..b7fd3ba 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableString.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableString.cs @@ -47,7 +47,12 @@ public override IDictionary GetVariable(string value) /// The copy. public override IUriPathSegment Copy() { - return new UriPathSegmentVariableString(this) { Value = Value }; + return new UriPathSegmentVariableString(this) + { + Value = Value, + IsHidden = IsHidden, + Uri = Uri + }; } /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableUInt.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableUInt.cs index 46472b6..fdde726 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableUInt.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableUInt.cs @@ -47,7 +47,12 @@ public override IDictionary GetVariable(string value) /// The copy. public override IUriPathSegment Copy() { - return new UriPathSegmentVariableUInt(this) { Value = Value }; + return new UriPathSegmentVariableUInt(this) + { + Value = Value, + IsHidden = IsHidden, + Uri = Uri + }; } /// From 2223025257d7099a7f3a03273e6c9353e37bcc50 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Wed, 25 Mar 2026 21:44:18 +0100 Subject: [PATCH 42/53] feat: add tab control, general improvements and minor bugs --- src/WebExpress.WebCore/WebUri/UriEndpoint.cs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs index 1ffd909..3654142 100644 --- a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs +++ b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs @@ -439,7 +439,24 @@ public virtual bool Contains(string segment) /// true if part of the uri, false otherwise. public bool StartsWith(IUri uri) { - return ToString().StartsWith(uri.ToString()); + var a = uri.PathSegments; + var b = PathSegments; + + if (a.Count() > b.Count()) + { + return false; + } + + for (int i = 0; i < a.Count(); i++) + { + if (!a.ElementAt(i).Value.Equals(b.ElementAt(i).Value, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } /// From 93f489df64e7bb10f7d22bb0bf1a8137202b5ad0 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Fri, 27 Mar 2026 22:20:31 +0100 Subject: [PATCH 43/53] feat: general improvements and minor bugs --- .../WebTask/ITaskManager.cs | 5 ++ src/WebExpress.WebCore/WebTask/Task.cs | 58 +++++++++++++++++-- .../WebTask/TaskEventArgs.cs | 27 +++++++++ src/WebExpress.WebCore/WebTask/TaskManager.cs | 29 ++++++++++ 4 files changed, 114 insertions(+), 5 deletions(-) diff --git a/src/WebExpress.WebCore/WebTask/ITaskManager.cs b/src/WebExpress.WebCore/WebTask/ITaskManager.cs index 105ce16..0607e15 100644 --- a/src/WebExpress.WebCore/WebTask/ITaskManager.cs +++ b/src/WebExpress.WebCore/WebTask/ITaskManager.cs @@ -9,6 +9,11 @@ namespace WebExpress.WebCore.WebTask /// public interface ITaskManager : IComponentManager { + /// + /// Event is triggered when a task's changes. + /// + event EventHandler TaskChanged; + /// /// Returns the collection of tasks. /// diff --git a/src/WebExpress.WebCore/WebTask/Task.cs b/src/WebExpress.WebCore/WebTask/Task.cs index 17cf6e9..67432da 100644 --- a/src/WebExpress.WebCore/WebTask/Task.cs +++ b/src/WebExpress.WebCore/WebTask/Task.cs @@ -11,6 +11,7 @@ namespace WebExpress.WebCore.WebTask public class Task : ITask { private int _progress; + private string _message; /// /// Event is triggered when the task is executed. @@ -22,6 +23,16 @@ public class Task : ITask /// public event EventHandler Finish; + /// + /// Event is triggered when the progress changes. + /// + public event EventHandler ProgressChanged; + + /// + /// Event is triggered when the message changes. + /// + public event EventHandler MessageChanged; + /// /// Returns the id of the task. /// @@ -47,14 +58,35 @@ public class Task : ITask /// public int Progress { - get => _progress; - set => _progress = Math.Min(value, 100); + get { return _progress; } + set + { + var newValue = Math.Min(value, 100); + if (_progress != newValue) + { + _progress = newValue; + // trigger progress changed event + OnProgressChanged(); + } + } } /// /// Returns or sets a message that provides information about the processing. /// - public string Message { get; set; } + public string Message + { + get { return _message; } + set + { + if (_message != value) + { + _message = value; + // trigger message changed event + OnMessageChanged(); + } + } + } /// /// Initializes a new instance of the class. @@ -72,7 +104,7 @@ public Task(string id, params object[] args) /// protected virtual void OnProcess() { - Process?.Invoke(this, new TaskEventArgs()); + Process?.Invoke(this, new TaskEventArgs(this, 0)); } /// @@ -80,7 +112,23 @@ protected virtual void OnProcess() /// protected virtual void OnFinish() { - Finish?.Invoke(this, new TaskEventArgs()); + Finish?.Invoke(this, new TaskEventArgs(this, 100)); + } + + /// + /// Triggered when the progress changes. + /// + protected virtual void OnProgressChanged() + { + ProgressChanged?.Invoke(this, new TaskEventArgs(this, Progress)); + } + + /// + /// Triggered when the message changes. + /// + protected virtual void OnMessageChanged() + { + MessageChanged?.Invoke(this, new TaskEventArgs(this, Progress, Message)); } /// diff --git a/src/WebExpress.WebCore/WebTask/TaskEventArgs.cs b/src/WebExpress.WebCore/WebTask/TaskEventArgs.cs index 6d9fd3a..6347705 100644 --- a/src/WebExpress.WebCore/WebTask/TaskEventArgs.cs +++ b/src/WebExpress.WebCore/WebTask/TaskEventArgs.cs @@ -7,5 +7,32 @@ namespace WebExpress.WebCore.WebTask /// public class TaskEventArgs : EventArgs { + /// + /// Returns the related task. + /// + public ITask Task { get; } + + /// + /// Returns the current progress (if relevant). + /// + public int Progress { get; } + + /// + /// Returns the current or new message (if relevant). + /// + public string Message { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The related task. + /// The current progress. + /// The current or new message. + public TaskEventArgs(ITask task, int progress = 0, string message = null) + { + Task = task; + Progress = progress; + Message = message; + } } } diff --git a/src/WebExpress.WebCore/WebTask/TaskManager.cs b/src/WebExpress.WebCore/WebTask/TaskManager.cs index 50c2065..0859cd7 100644 --- a/src/WebExpress.WebCore/WebTask/TaskManager.cs +++ b/src/WebExpress.WebCore/WebTask/TaskManager.cs @@ -16,6 +16,11 @@ public class TaskManager : ITaskManager, ISystemComponent private readonly IHttpServerContext _httpServerContext; private readonly TaskDictionary _dictionary = []; + /// + /// Event is triggered when a task's changes. + /// + public event EventHandler TaskChanged; + /// /// Returns the collection of tasks. /// @@ -79,6 +84,10 @@ public ITask CreateTask(string id, params object[] args) } var task = ComponentActivator.CreateInstance(_httpServerContext, _componentHub, [id, args]); + + // register events for the newly created task + task.ProgressChanged += OnTaskChanged; + _dictionary.Add(key, task); return task; @@ -115,6 +124,10 @@ public ITask CreateTask(string id, EventHandler handler, p } var task = ComponentActivator.CreateInstance(_httpServerContext, _componentHub, [id, args]); + + // register events for the newly created task + task.ProgressChanged += OnTaskChanged; + _dictionary.Add(key, task); task.Process += handler; @@ -130,6 +143,12 @@ public void RemoveTask(ITask task) { var key = task?.Id.ToLower(); + if (_dictionary.TryGetValue(key, out var storedTask) && storedTask is Task t) + { + // unregister events to prevent memory leaks + t.ProgressChanged -= OnTaskChanged; + } + _dictionary.Remove(key); } @@ -140,5 +159,15 @@ public void Dispose() { GC.SuppressFinalize(this); } + + /// + /// Handles the changed event from a task. + /// + /// The sender. + /// The event arguments. + private void OnTaskChanged(object sender, TaskEventArgs e) + { + TaskChanged?.Invoke(sender, e); + } } } From 5dd98484a56fb502cadfe276951b8359043d1caa Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sat, 28 Mar 2026 20:24:26 +0100 Subject: [PATCH 44/53] feat: add dashboard control, general improvements and minor bugs --- src/WebExpress.WebCore/WebSitemap/SitemapManager.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs index 7ee56db..e6c897e 100644 --- a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs +++ b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs @@ -193,8 +193,13 @@ public IUri GetUri(IEndpointContext endpointContext) .Where(x => x.EndpointId.Equals(endpointContext.EndpointId)); var node = _root.GetPreOrder() - .Where(x => endpointContexts.Contains(x.EndpointContext)) - .FirstOrDefault(); + .FirstOrDefault(x => endpointContexts.Contains(x.EndpointContext)); + + if (node is null) + { + // fallback to the search by application context + return GetUri(endpointContext.ApplicationContext); + } return new UriEndpoint(_serverUri, node?.EndpointContext?.Route.PathSegments, null); } From f068d0c6312ad15e66c4863429e0c699bb52e3c1 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Wed, 1 Apr 2026 23:18:32 +0200 Subject: [PATCH 45/53] feat: general improvements and minor bugs --- .../Fixture/UnitTestFixture.cs | 6 ++-- .../Manager/UnitTestAssetManager.cs | 2 +- .../Manager/UnitTestIdentityManager.cs | 6 ++-- .../Manager/UnitTestRestApiManager.cs | 32 +++++++++---------- .../Manager/UnitTestSessionManager.cs | 6 ++-- .../Manager/UnitTestSitemapManager.cs | 2 +- .../Message/UnitTestGetRequest.cs | 10 +++--- src/WebExpress.WebCore/WebAsset/Asset.cs | 7 ++-- src/WebExpress.WebCore/WebAsset/IAsset.cs | 2 +- 9 files changed, 35 insertions(+), 38 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs index 8b52f2b..aa472fd 100644 --- a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs +++ b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs @@ -98,7 +98,7 @@ public static ComponentHub CreateAndRegisterComponentHubMock() /// The content of the request. /// The URI of the request. /// A fake request for testing. - public static IRequest CrerateRequestMock(string content = "", string uri = "") + public static IRequest CreateRequestMock(string content = "", string uri = "") { var context = CreateHttpContextMock(content); @@ -117,7 +117,7 @@ public static IRequest CrerateRequestMock(string content = "", string uri = "") /// /// The URI of the request. /// A fake request for testing. - public static IRequest CrerateRequestMock(IUri uri) + public static IRequest CreateRequestMock(IUri uri) { var context = CreateHttpContextMock(); @@ -210,7 +210,7 @@ public static WebMessage.HttpContext CreateHttpContextMock(string content = "") /// A mock render context for testing. public static RenderContext CrerateRenderContextMock(IApplicationContext applicationContext = null, IEnumerable scopes = null) { - var request = CrerateRequestMock(); + var request = CreateRequestMock(); return new RenderContext(null, CreratePageContextMock(applicationContext, scopes), request); } diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs index d7edb9f..b150178 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs @@ -127,7 +127,7 @@ public void Request(string uri, string resource) var response = componentHub .EndpointManager - .HandleRequest(UnitTestFixture.CrerateRequestMock("", uri), searchResult.EndpointContext); + .HandleRequest(UnitTestFixture.CreateRequestMock("", uri), searchResult.EndpointContext); Assert.Equal($"webexpress.webcore.test.{resource.Replace('/', '.')}", searchResult?.EndpointContext?.EndpointId.ToString()); Assert.IsNotType(response); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs index 6a98c86..6e1b337 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs @@ -145,7 +145,7 @@ public void Login(string identityName, string password, bool expected) // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var identityManager = componentHub.IdentityManager as IdentityManager; - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); var identity = MockIdentityFactory.GetIdentity(identityName); var securePassword = new SecureString(); password.ToList().ForEach(x => securePassword.AppendChar(x)); @@ -169,7 +169,7 @@ public void Logout(string identityName, string password) // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var identityManager = componentHub.IdentityManager as IdentityManager; - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); var identity = MockIdentityFactory.GetIdentity(identityName); var securePassword = new SecureString(); password.ToList().ForEach(x => securePassword.AppendChar(x)); @@ -195,7 +195,7 @@ public void GetCurrentIdentity(string identityName, string password) // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var identityManager = componentHub.IdentityManager as IdentityManager; - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); var identity = MockIdentityFactory.GetIdentity(identityName); var securePassword = new SecureString(); password.ToList().ForEach(x => securePassword.AppendChar(x)); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs index 973215a..c4fdff7 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs @@ -182,7 +182,7 @@ public void IsIContext() public void ValidateRequire(string input) { // arrange - var request = UnitTestFixture.CrerateRequestMock($"name={input}"); + var request = UnitTestFixture.CreateRequestMock($"name={input}"); request.AddParameter(new Parameter("name", input, ParameterScope.Parameter)); // act @@ -204,7 +204,7 @@ public void ValidateRequire(string input) public void ValidateMinLength(string input) { // arrange - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); request.AddParameter(new Parameter("code", input, ParameterScope.Parameter)); // act @@ -225,7 +225,7 @@ public void ValidateMaxLength(int length) { // arrange var input = new string('x', length); - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); request.AddParameter(new Parameter("bio", input, ParameterScope.Parameter)); // act @@ -247,7 +247,7 @@ public void ValidateMaxLength(int length) public void ValidateEmail(string email) { // arrange - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); request.AddParameter(new Parameter("email", email, ParameterScope.Parameter)); // act @@ -269,7 +269,7 @@ public void ValidateEmail(string email) public void ValidateIsInt(string input) { // arrange - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); request.AddParameter(new Parameter("age", input, ParameterScope.Parameter)); // act @@ -290,7 +290,7 @@ public void ValidateIsInt(string input) public void ValidateEqualTo(string input) { // arrange - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); request.AddParameter(new Parameter("role", input, ParameterScope.Parameter)); // act @@ -310,7 +310,7 @@ public void ValidateEqualTo(string input) [InlineData("101")] public void ValidateRange(string input) { - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); request.AddParameter(new Parameter("level", input, ParameterScope.Parameter)); var validator = new RestApiValidator(request) @@ -328,7 +328,7 @@ public void ValidateRange(string input) [InlineData("xyz-start")] public void ValidateStartsWith(string input) { - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); request.AddParameter(new Parameter("code", input, ParameterScope.Parameter)); var validator = new RestApiValidator(request) @@ -346,7 +346,7 @@ public void ValidateStartsWith(string input) [InlineData("document.pdf")] public void ValidateEndsWith(string input) { - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); request.AddParameter(new Parameter("filename", input, ParameterScope.Parameter)); var validator = new RestApiValidator(request) @@ -364,7 +364,7 @@ public void ValidateEndsWith(string input) [InlineData("anonymous")] public void ValidateIn(string input) { - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); request.AddParameter(new Parameter("role", input, ParameterScope.Parameter)); var validator = new RestApiValidator(request) @@ -382,7 +382,7 @@ public void ValidateIn(string input) [InlineData("foo bar")] public void ValidateContains(string input) { - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); request.AddParameter(new Parameter("description", input, ParameterScope.Parameter)); var validator = new RestApiValidator(request) @@ -402,7 +402,7 @@ public enum Difficulty { Easy, Medium, Hard } [InlineData("easy-peasy")] public void ValidateMatchesEnum(string value) { - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); request.AddParameter(new Parameter("difficulty", value, ParameterScope.Parameter)); var validator = new RestApiValidator(request) @@ -420,7 +420,7 @@ public void ValidateMatchesEnum(string value) [InlineData("31/31/2020")] public void ValidateIsDate(string input) { - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); request.AddParameter(new Parameter("date", input, ParameterScope.Parameter)); var validator = new RestApiValidator(request) @@ -439,7 +439,7 @@ public void ValidateIsDate(string input) [InlineData("nonpirate")] public void ValidateCustom(string input) { - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); request.AddParameter(new Parameter("nickname", input, ParameterScope.Parameter)); var validator = new RestApiValidator(request) @@ -462,7 +462,7 @@ public void ValidateCustom(string input) [InlineData("true", "")] public void ValidateWhen_ConditionalRequire(string subscribe, string email) { - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); request.AddParameter(new Parameter("subscribe", subscribe, ParameterScope.Parameter)); request.AddParameter(new Parameter("email", email, ParameterScope.Parameter)); @@ -482,7 +482,7 @@ public void ValidateWhen_ConditionalRequire(string subscribe, string email) [InlineData(null, "")] public void ValidateWhen_ConditionFalse(string subscribe, string email) { - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); request.AddParameter(new Parameter("subscribe", subscribe, ParameterScope.Parameter)); request.AddParameter(new Parameter("email", email, ParameterScope.Parameter)); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs index c1d630a..afd01d4 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs @@ -45,7 +45,7 @@ public void GetSession() { // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); // act var session = componentHub.SessionManager.GetSession(request); @@ -61,7 +61,7 @@ public void AddPropertyToSession() { // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); var session = componentHub.SessionManager.GetSession(request); // act @@ -83,7 +83,7 @@ public void RemovePropertyFromSession() { // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - var request = UnitTestFixture.CrerateRequestMock(); + var request = UnitTestFixture.CreateRequestMock(); var session = componentHub.SessionManager.GetSession(request); // act diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs index a6c2950..76c0eb5 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs @@ -80,7 +80,7 @@ public void SearchResource(string uri, string id) HttpContext = context }); - componentHub.EndpointManager.HandleRequest(UnitTestFixture.CrerateRequestMock(), searchResult?.EndpointContext); + componentHub.EndpointManager.HandleRequest(UnitTestFixture.CreateRequestMock(), searchResult?.EndpointContext); // validation Assert.Equal(id, searchResult?.EndpointContext?.EndpointId.ToString()); diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestGetRequest.cs b/src/WebExpress.WebCore.Test/Message/UnitTestGetRequest.cs index a7d6814..141d555 100644 --- a/src/WebExpress.WebCore.Test/Message/UnitTestGetRequest.cs +++ b/src/WebExpress.WebCore.Test/Message/UnitTestGetRequest.cs @@ -15,7 +15,7 @@ public class UnitTestGetRequest public void General() { var content = UnitTestFixture.GetEmbeddedResource("general.get"); - var request = UnitTestFixture.CrerateRequestMock(content); + var request = UnitTestFixture.CreateRequestMock(content); Assert.Equal("http://localhost:8080/abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A", request.Uri?.ToString()); } @@ -27,7 +27,7 @@ public void General() public void Less() { var content = UnitTestFixture.GetEmbeddedResource("less.get"); - var request = UnitTestFixture.CrerateRequestMock(content); + var request = UnitTestFixture.CreateRequestMock(content); Assert.Equal("http://localhost:8080/abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A", request.Uri?.ToString()); } @@ -39,7 +39,7 @@ public void Less() public void Massive() { var content = UnitTestFixture.GetEmbeddedResource("massive.get"); - var request = UnitTestFixture.CrerateRequestMock(content); + var request = UnitTestFixture.CreateRequestMock(content); Assert.Equal("http://localhost:8080/abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A", request.Uri?.ToString()); } @@ -51,7 +51,7 @@ public void Massive() public void GetParameter() { var content = UnitTestFixture.GetEmbeddedResource("param.get"); - var request = UnitTestFixture.CrerateRequestMock(content); + var request = UnitTestFixture.CreateRequestMock(content); var param = request?.GetParameter("a")?.Value; Assert.Equal("http://localhost:8080/abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A", request.Uri?.ToString()); @@ -65,7 +65,7 @@ public void GetParameter() public void GetParameterWithUmlaut() { var content = UnitTestFixture.GetEmbeddedResource("param_umlaut.get"); - var request = UnitTestFixture.CrerateRequestMock(content); + var request = UnitTestFixture.CreateRequestMock(content); var a = request?.GetParameter("a")?.Value; var b = request?.GetParameter("b")?.Value; diff --git a/src/WebExpress.WebCore/WebAsset/Asset.cs b/src/WebExpress.WebCore/WebAsset/Asset.cs index e2b6970..d109af0 100644 --- a/src/WebExpress.WebCore/WebAsset/Asset.cs +++ b/src/WebExpress.WebCore/WebAsset/Asset.cs @@ -1,5 +1,4 @@ -using System; -using System.IO; +using System.IO; using System.Reflection; using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebComponent; @@ -46,7 +45,7 @@ public Asset(IComponentHub componentHub, IAssetContext assetContext, IHttpServer /// /// The request. /// The response. - public Response Process(IRequest request) + public IResponse Process(IRequest request) { if (_data is null) { @@ -157,8 +156,6 @@ private byte[] GetData(Assembly assembly) public void Dispose() { _data = null; - - GC.SuppressFinalize(this); } } } \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebAsset/IAsset.cs b/src/WebExpress.WebCore/WebAsset/IAsset.cs index 12855c2..8d4c2f8 100644 --- a/src/WebExpress.WebCore/WebAsset/IAsset.cs +++ b/src/WebExpress.WebCore/WebAsset/IAsset.cs @@ -13,6 +13,6 @@ public interface IAsset : IEndpoint /// /// The request. /// The response. - Response Process(IRequest request); + IResponse Process(IRequest request); } } From ca4f8936785689cd6f6c7fa8e69f8750bd34fba9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:47:47 +0000 Subject: [PATCH 46/53] Initial plan From 61d52b29a8d17aeaeeb531cddb291d1927002230 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:58:02 +0000 Subject: [PATCH 47/53] Fix review comments from PR #10: null safety, typos, cookie parsing, interface visibility, WebSocket headers Agent-Logs-Url: https://github.com/webexpress-framework/WebExpress.WebCore/sessions/21fd93fa-c2a8-4f06-a85f-237eec4ad043 Co-authored-by: ReneSchwarzer <31061438+ReneSchwarzer@users.noreply.github.com> --- src/WebExpress.WebCore.Test/Html/UnitTestRandomId.cs | 2 +- .../Manager/UnitTestStatusPageManager.cs | 4 ++-- .../WebAttribute/SegmentRegexAttribute.cs | 12 ++++++------ .../WebAttribute/SegmentStringAttribute.cs | 12 ++++++------ .../WebAttribute/SegmentUIntAttribute.cs | 12 ++++++------ .../WebMessage/RequestHeaderFields.cs | 9 ++++++--- .../WebMessage/ResponseHeaderFields.cs | 5 +++++ .../WebSocket/{ISockt.cs => ISocket.cs} | 2 -- src/WebExpress.WebCore/WebSocket/ISocketContext.cs | 2 +- src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs | 2 +- src/WebExpress.WebCore/WebSocket/SocketContext.cs | 2 +- src/WebExpress.WebCore/WebSocket/SocketManager.cs | 4 ++-- src/WebExpress.WebCore/WebTask/TaskManager.cs | 11 ++++++++--- src/WebExpress.WebCore/WebUri/IUriPathSegment.cs | 6 +++--- src/WebExpress.WebCore/WebUri/UriQuery.cs | 8 ++++---- 15 files changed, 52 insertions(+), 41 deletions(-) rename src/WebExpress.WebCore/WebSocket/{ISockt.cs => ISocket.cs} (74%) diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestRandomId.cs b/src/WebExpress.WebCore.Test/Html/UnitTestRandomId.cs index 7b9554b..6152894 100644 --- a/src/WebExpress.WebCore.Test/Html/UnitTestRandomId.cs +++ b/src/WebExpress.WebCore.Test/Html/UnitTestRandomId.cs @@ -59,7 +59,7 @@ public void HexCharacters() var id = RandomId.Create(); // validation - var hex = id.Substring(4); + var hex = id.Substring("id_".Length); Assert.Matches("^[0-9A-F]+$", hex); } diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestStatusPageManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestStatusPageManager.cs index b6cbce4..6b13db3 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestStatusPageManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestStatusPageManager.cs @@ -165,8 +165,8 @@ public void CreateAndCheckCode(Type applicationType, int statusCode, int? expect /// Test the CreateStatusResponse function of the status page. /// [Theory] - [InlineData(typeof(TestApplicationA), 400, "content", "content", 78)] - [InlineData(typeof(TestApplicationA), 500, "content", "content", 78)] + [InlineData(typeof(TestApplicationA), 400, "content", "content", 72)] + [InlineData(typeof(TestApplicationA), 500, "content", "content", 72)] public void CreateAndCheckMessage(Type applicationType, int statusCode, string content, string expected, int length) { // arrange diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentRegexAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentRegexAttribute.cs index 9544cd4..207fb83 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentRegexAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentRegexAttribute.cs @@ -20,19 +20,19 @@ public class SegmentRegexAttribute : Attribute, IEndpointAttribute, private string Expression { get; set; } /// - /// Returns or sets the display string. + /// Returns or sets the tag. /// - private string Display { get; set; } + private string Tag { get; set; } /// /// Initializes a new instance of the class. /// /// The regular expression. - /// The display string. - public SegmentRegexAttribute(string expression, string display = null) + /// The tag. + public SegmentRegexAttribute(string expression, string tag = null) { Expression = expression; - Display = display; + Tag = tag; } /// @@ -41,7 +41,7 @@ public SegmentRegexAttribute(string expression, string display = null) /// The path segment. public IUriPathSegment ToPathSegment() { - return new UriPathSegmentVariableRegex(Expression, Display); + return new UriPathSegmentVariableRegex(Expression, Tag); } } } diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentStringAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentStringAttribute.cs index ad98978..b52850f 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentStringAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentStringAttribute.cs @@ -15,17 +15,17 @@ public class SegmentStringAttribute : Attribute, IEndpointAttribute, where TParameter : IParameterStatic, new() { /// - /// Returns or sets the display string. + /// Returns or sets the tag. /// - private string Display { get; set; } + private string Tag { get; set; } /// /// Initializes a new instance of the class. /// - /// The display string. - public SegmentStringAttribute(string display = null) + /// The tag. + public SegmentStringAttribute(string tag = null) { - Display = display; + Tag = tag; } /// @@ -34,7 +34,7 @@ public SegmentStringAttribute(string display = null) /// The path segment. public IUriPathSegment ToPathSegment() { - return new UriPathSegmentVariableString(Display); + return new UriPathSegmentVariableString(Tag); } } } diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentUIntAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentUIntAttribute.cs index fe2fb27..1d1d8ba 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentUIntAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentUIntAttribute.cs @@ -18,17 +18,17 @@ public class SegmentUIntAttribute : Attribute, IEndpointAttribute, I where TParameter : IParameterStatic, new() { /// - /// Returns or sets the display string. + /// Returns or sets the tag. /// - private string Display { get; set; } + private string Tag { get; set; } /// /// Initializes a new instance of the class. /// - /// The display string. - public SegmentUIntAttribute(string display = null) + /// The tag. + public SegmentUIntAttribute(string tag = null) { - Display = display; + Tag = tag; } /// @@ -37,7 +37,7 @@ public SegmentUIntAttribute(string display = null) /// The path segment. public IUriPathSegment ToPathSegment() { - return new UriPathSegmentVariableUInt(Display); + return new UriPathSegmentVariableUInt(Tag); } } } diff --git a/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs b/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs index 26bb453..d5a0cad 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs @@ -124,11 +124,14 @@ internal RequestHeaderFields(IFeatureCollection contextFeatures) SecWebSocketVersion = requestFeature.Headers.SecWebSocketVersion; Cookies = requestFeature.Headers.Cookie + .SelectMany(c => c.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Select(c => { - var split = c.Split('='); - return new Cookie(split[0], split[1]); - }); + var eqIndex = c.IndexOf('='); + if (eqIndex < 0) { return null; } + return new Cookie(c[..eqIndex].Trim(), c[(eqIndex + 1)..].Trim()); + }) + .Where(c => c != null); Authorization = RequestAuthorization.Parse(requestFeature.Headers.Authorization); } diff --git a/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs b/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs index 642d06d..b1de805 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs @@ -142,6 +142,11 @@ public override string ToString() sb.AppendLine("Connection: Upgrade"); } + if (!string.IsNullOrWhiteSpace(SecWebSocketAccept)) + { + sb.AppendLine("Sec-WebSocket-Accept: " + SecWebSocketAccept); + } + foreach (var c in CustomHeader) { sb.AppendLine(c.Key + ": " + c.Value); diff --git a/src/WebExpress.WebCore/WebSocket/ISockt.cs b/src/WebExpress.WebCore/WebSocket/ISocket.cs similarity index 74% rename from src/WebExpress.WebCore/WebSocket/ISockt.cs rename to src/WebExpress.WebCore/WebSocket/ISocket.cs index 532c651..3d208a2 100644 --- a/src/WebExpress.WebCore/WebSocket/ISockt.cs +++ b/src/WebExpress.WebCore/WebSocket/ISocket.cs @@ -11,8 +11,6 @@ public interface ISocket : IEndpoint, IDisposable { /// /// Invoked after the websocket handshake has been accepted. - /// Implementers may use the optional cancellation token to abort long-running startup tasks. - /// the optional connectMessage provides initial metadata from the client (may be null). /// /// The socket connection. /// An asynchronous task. diff --git a/src/WebExpress.WebCore/WebSocket/ISocketContext.cs b/src/WebExpress.WebCore/WebSocket/ISocketContext.cs index 2616e19..7042eb0 100644 --- a/src/WebExpress.WebCore/WebSocket/ISocketContext.cs +++ b/src/WebExpress.WebCore/WebSocket/ISocketContext.cs @@ -25,7 +25,7 @@ public interface ISocketContext : IEndpointContext /// Returns the maximum allowed message size in bytes, or null when the endpoint imposes no limit. /// servers and hosts may use this to protect against excessively large frames. /// - ulong MaxMessageSize { get; } + ulong? MaxMessageSize { get; } /// /// Indicates whether this websocket endpoint requires an authenticated client. diff --git a/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs b/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs index 9e0d95c..85f5142 100644 --- a/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs +++ b/src/WebExpress.WebCore/WebSocket/Model/SocketItem.cs @@ -50,7 +50,7 @@ internal class SocketItem : IDisposable /// Returns the maximum allowed message size in bytes, or null when the endpoint imposes no limit. /// servers and hosts may use this to protect against excessively large frames. /// - public ulong MaxMessageSize { get; set; } + public ulong? MaxMessageSize { get; set; } /// /// Returns the conditions that must be met for the resource to be active. diff --git a/src/WebExpress.WebCore/WebSocket/SocketContext.cs b/src/WebExpress.WebCore/WebSocket/SocketContext.cs index 5ea54f0..5fa2031 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketContext.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketContext.cs @@ -50,7 +50,7 @@ public class SocketContext : ISocketContext /// /// Maximum allowed message size in bytes, or null when no limit is imposed. /// - public ulong MaxMessageSize { get; set; } + public ulong? MaxMessageSize { get; set; } /// /// Indicates whether this websocket endpoint requires an authenticated client. diff --git a/src/WebExpress.WebCore/WebSocket/SocketManager.cs b/src/WebExpress.WebCore/WebSocket/SocketManager.cs index 9232cc2..72f663d 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketManager.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketManager.cs @@ -308,7 +308,7 @@ private void Register(IPluginContext pluginContext, IEnumerable !x.AttributeType.GetInterfaces().Contains(typeof(IEndpointAttribute))); @@ -376,7 +376,7 @@ var attribute in socketType // MAX MESSAGE SIZE if (attributeType == typeof(MaxMessageSizeAttribute)) { - maxMessageSize = (attribute as MaxMessageSizeAttribute)?.MaxMessageSize ?? 0; + maxMessageSize = (attribute as MaxMessageSizeAttribute)?.MaxMessageSize; continue; } } diff --git a/src/WebExpress.WebCore/WebTask/TaskManager.cs b/src/WebExpress.WebCore/WebTask/TaskManager.cs index 0859cd7..5543401 100644 --- a/src/WebExpress.WebCore/WebTask/TaskManager.cs +++ b/src/WebExpress.WebCore/WebTask/TaskManager.cs @@ -78,7 +78,7 @@ public ITask CreateTask(string id, params object[] args) { var key = id?.ToLower(); - if (_dictionary.TryGetValue(id, out var value)) + if (_dictionary.TryGetValue(key, out var value)) { return value; } @@ -118,7 +118,7 @@ public ITask CreateTask(string id, EventHandler handler, p { var key = id?.ToLower(); - if (_dictionary.TryGetValue(id, out var value)) + if (_dictionary.TryGetValue(key, out var value)) { return value; } @@ -141,7 +141,12 @@ public ITask CreateTask(string id, EventHandler handler, p /// The task. public void RemoveTask(ITask task) { - var key = task?.Id.ToLower(); + if (task?.Id is null) + { + return; + } + + var key = task.Id.ToLower(); if (_dictionary.TryGetValue(key, out var storedTask) && storedTask is Task t) { diff --git a/src/WebExpress.WebCore/WebUri/IUriPathSegment.cs b/src/WebExpress.WebCore/WebUri/IUriPathSegment.cs index a876c35..4877693 100644 --- a/src/WebExpress.WebCore/WebUri/IUriPathSegment.cs +++ b/src/WebExpress.WebCore/WebUri/IUriPathSegment.cs @@ -8,7 +8,7 @@ public interface IUriPathSegment /// /// Returns or sets the id. /// - internal string Id { get; } + string Id { get; } /// /// Returns the value. @@ -32,12 +32,12 @@ public interface IUriPathSegment /// This property can be used to determine if the item should be displayed in user /// interfaces or lists. /// - bool IsHidden { get; internal set; } + bool IsHidden { get; set; } /// /// Returns the URI to which the user is redirected. /// - IUri Uri { get; internal set; } + IUri Uri { get; set; } /// /// Checks whether the node matches the path element. diff --git a/src/WebExpress.WebCore/WebUri/UriQuery.cs b/src/WebExpress.WebCore/WebUri/UriQuery.cs index ebf5b07..21e3bb2 100644 --- a/src/WebExpress.WebCore/WebUri/UriQuery.cs +++ b/src/WebExpress.WebCore/WebUri/UriQuery.cs @@ -41,12 +41,12 @@ public override string ToString() /// /// Represents a key-value pair used as a query parameter in a URI. /// - /// + /// /// The type that implements the IParameterStatic interface and defines the structure /// of the query parameter. /// - public class UriQuery : IUriQuery - where TParamerer : IParameterStatic + public class UriQuery : IUriQuery + where TParameter : IParameterStatic { /// /// Returns the key. @@ -64,7 +64,7 @@ public class UriQuery : IUriQuery /// The value. public UriQuery(string value = null) { - Key = TParamerer.Key; + Key = TParameter.Key; Value = value; } From 0ebf158379fa77915fab82ce2eceeca73fe81acd Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sat, 4 Apr 2026 19:10:59 +0200 Subject: [PATCH 48/53] feat: general improvements and minor bugs --- .../Manager/UnitTestStatusPageManager.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestStatusPageManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestStatusPageManager.cs index 6b13db3..75b1173 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestStatusPageManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestStatusPageManager.cs @@ -165,16 +165,18 @@ public void CreateAndCheckCode(Type applicationType, int statusCode, int? expect /// Test the CreateStatusResponse function of the status page. /// [Theory] - [InlineData(typeof(TestApplicationA), 400, "content", "content", 72)] - [InlineData(typeof(TestApplicationA), 500, "content", "content", 72)] + [InlineData(typeof(TestApplicationA), 400, "content", "content", 78)] + [InlineData(typeof(TestApplicationA), 500, "content", "content", 78)] public void CreateAndCheckMessage(Type applicationType, int statusCode, string content, string expected, int length) { // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - var statusResponse = componentHub.StatusPageManager.CreateStatusResponse(content, statusCode, application, UnitTestFixture.CreateHttpContextMock().Request); // act + var statusResponse = componentHub.StatusPageManager.CreateStatusResponse(content, statusCode, application, UnitTestFixture.CreateHttpContextMock().Request); + + // validation Assert.Contains(expected, statusResponse?.Content?.ToString()); Assert.Equal(length, statusResponse?.Header?.ContentLength); } From 0c1db4910c71488378f08fdc34f420c796c3f3ab Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sat, 4 Apr 2026 21:46:28 +0200 Subject: [PATCH 49/53] feat: general improvements and minor bugs --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ff6d68d..b4368f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,9 @@ on: jobs: build-and-test: runs-on: ubuntu-latest + defaults: + run: + working-directory: src steps: - name: Checkout code uses: actions/checkout@v3 From 2b25cc97589267a188c1a09d88d87835f5d6fff7 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sat, 4 Apr 2026 21:55:20 +0200 Subject: [PATCH 50/53] feat: general improvements and minor bugs --- .../Manager/UnitTestStatusPageManager.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestStatusPageManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestStatusPageManager.cs index 75b1173..cfcba08 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestStatusPageManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestStatusPageManager.cs @@ -165,8 +165,8 @@ public void CreateAndCheckCode(Type applicationType, int statusCode, int? expect /// Test the CreateStatusResponse function of the status page. /// [Theory] - [InlineData(typeof(TestApplicationA), 400, "content", "content", 78)] - [InlineData(typeof(TestApplicationA), 500, "content", "content", 78)] + [InlineData(typeof(TestApplicationA), 400, "content", "content", 72)] + [InlineData(typeof(TestApplicationA), 500, "content", "content", 72)] public void CreateAndCheckMessage(Type applicationType, int statusCode, string content, string expected, int length) { // arrange @@ -177,8 +177,10 @@ public void CreateAndCheckMessage(Type applicationType, int statusCode, string c var statusResponse = componentHub.StatusPageManager.CreateStatusResponse(content, statusCode, application, UnitTestFixture.CreateHttpContextMock().Request); // validation + var normalized = statusResponse?.Content?.ToString().Replace("\r\n", "\n").Replace("\r", "\n"); Assert.Contains(expected, statusResponse?.Content?.ToString()); - Assert.Equal(length, statusResponse?.Header?.ContentLength); + Assert.Equal(length, normalized.Length); + Assert.Equal(statusResponse?.Content?.ToString().Length, statusResponse?.Header?.ContentLength); } /// From 8e68ef201be393dc49ace796fff24110ba8a218c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 20:03:16 +0000 Subject: [PATCH 51/53] Initial plan From b437f4521b7d516c448f3ed8a5db8e59ae1258c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 20:10:20 +0000 Subject: [PATCH 52/53] Fix ResponseHeaderFields.ToString() to use Connection property instead of hardcoding Connection: Upgrade Agent-Logs-Url: https://github.com/webexpress-framework/WebExpress.WebCore/sessions/e8d260a2-11b1-4b7a-a8fc-0c154ce33291 Co-authored-by: ReneSchwarzer <31061438+ReneSchwarzer@users.noreply.github.com> --- src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs b/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs index b1de805..a161e5c 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs @@ -136,10 +136,14 @@ public override string ToString() sb.AppendLine("Location: " + Location); } + if (!string.IsNullOrWhiteSpace(Connection)) + { + sb.AppendLine("Connection: " + Connection); + } + if (!string.IsNullOrWhiteSpace(Upgrade)) { sb.AppendLine("Upgrade: " + Upgrade); - sb.AppendLine("Connection: Upgrade"); } if (!string.IsNullOrWhiteSpace(SecWebSocketAccept)) From aa4e888a9de23a4bfa61f1b103c867e32d5ebaee Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 5 Apr 2026 09:49:56 +0200 Subject: [PATCH 53/53] feat: general improvements and minor bugs --- .github/workflows/{test.yml => unittest-verification.yml} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename .github/workflows/{test.yml => unittest-verification.yml} (91%) diff --git a/.github/workflows/test.yml b/.github/workflows/unittest-verification.yml similarity index 91% rename from .github/workflows/test.yml rename to .github/workflows/unittest-verification.yml index b4368f8..a820d77 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/unittest-verification.yml @@ -1,4 +1,4 @@ -name: UnitTests +name: UnitTest-Verification on: workflow_dispatch: @@ -20,7 +20,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 9.x + dotnet-version: 10.x - name: Restore dependencies run: dotnet restore