From 298cc99a89e5377ff07b62c98684e663a78eab00 Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Fri, 18 Apr 2025 17:53:29 +0200 Subject: [PATCH 01/51] feat: begin development of version 0.0.9-alpha --- LICENSE | 2 +- src/WebExpress.WebCore/WebExpress.WebCore.csproj | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/LICENSE b/LICENSE index e777fe7..91dc8d6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 René Schwarzer +Copyright (c) 2025 René Schwarzer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/WebExpress.WebCore/WebExpress.WebCore.csproj b/src/WebExpress.WebCore/WebExpress.WebCore.csproj index 95844d4..7e5c5fe 100644 --- a/src/WebExpress.WebCore/WebExpress.WebCore.csproj +++ b/src/WebExpress.WebCore/WebExpress.WebCore.csproj @@ -3,8 +3,8 @@ Library WebExpress.WebCore - 0.0.8.0 - 0.0.8.0 + 0.0.9.0 + 0.0.9.0 net9.0 any https://github.com/ReneSchwarzer/WebExpress.git @@ -14,7 +14,7 @@ true True Core library of the WebExpress web server. - 0.0.8-alpha + 0.0.9-alpha https://github.com/ReneSchwarzer/WebExpress icon.png README.md From 0dccf683e2a09a268a4bca6818d4a99d1f6f07c6 Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Mon, 21 Apr 2025 21:29:28 +0200 Subject: [PATCH 02/51] add: reference to WebExpress.Tutorial.WebUI --- README.md | 1 + docs/tutorials.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 100ea8f..d395969 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ If you're looking to get started with `WebExpress`, we would recommend using the The following tutorials illustrate the essential techniques of `WebExpress`. These tutorials are designed to assist you, as a developer, in understanding the various aspects of `WebExpress`. Each tutorial provides a detailed, step-by-step guide that you can work through using an example. If you re interested in beginning the development of `WebExpress` components, we would recommend you to complete some of these tutorials. - [HelloWorld](https://github.com/ReneSchwarzer/WebExpress.Tutorial.HelloWorld#readme) +- [WebUI](https://github.com/ReneSchwarzer/WebExpress.Tutorial.WebUI#readme) - [WebApp](https://github.com/ReneSchwarzer/WebExpress.Tutorial.WebApp#readme) - [WebIndex](https://github.com/ReneSchwarzer/WebExpress.Tutorial.WebIndex#readme) diff --git a/docs/tutorials.md b/docs/tutorials.md index 77c2af0..47b1f60 100644 --- a/docs/tutorials.md +++ b/docs/tutorials.md @@ -8,6 +8,7 @@ our tutorials offer something for everyone. # Getting Started Begin with our basic tutorial: - [HelloWorld](https://github.com/ReneSchwarzer/WebExpress.Tutorial.HelloWorld#readme) +- [WebUI](https://github.com/ReneSchwarzer/WebExpress.Tutorial.WebUI#readme) - [WebApp](https://github.com/ReneSchwarzer/WebExpress.Tutorial.WebApp#readme) - [WebIndex](https://github.com/ReneSchwarzer/WebExpress.Tutorial.WebIndex#readme) From 6c394c66f813124b93836272645af1f822470056 Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Sun, 27 Apr 2025 21:18:10 +0200 Subject: [PATCH 03/51] feat: general improvements --- src/WebExpress.WebCore/WebHtml/HtmlElement.cs | 947 +++++++++--------- 1 file changed, 484 insertions(+), 463 deletions(-) diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs index 141c3de..42ce8ce 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs @@ -1,463 +1,484 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - -namespace WebExpress.WebCore.WebHtml -{ - /// - /// The basis of all html elements (see RfC 1866). - /// - public class HtmlElement : IHtmlNode - { - private readonly List _elements = []; - private readonly List _attributes = []; - - /// - /// Returns or sets the name. des Attributes - /// - protected string ElementName { get; set; } - - /// - /// Returns or sets the attributes. - /// - protected IEnumerable Attributes => _attributes; - - /// - /// Returns the elements. - /// - protected IEnumerable Elements => _elements; - - /// - /// Returns or sets the id. - /// - public string Id - { - get => GetAttribute("id"); - set => SetAttribute("id", value); - } - - /// - /// Returns or sets the css class. - /// - public string Class - { - get => GetAttribute("class"); - set => SetAttribute("class", value); - } - - /// - /// Returns or sets the css style. - /// - public string Style - { - get => GetAttribute("style"); - set => SetAttribute("style", value); - } - - /// - /// Returns or sets the role. - /// - public string Role - { - get => GetAttribute("role"); - set => SetAttribute("role", value); - } - - /// - /// Returns or sets the html5 data attribute. - /// - public string DataToggle - { - get => GetAttribute("data-toggle"); - set => SetAttribute("data-toggle", value); - } - - /// - /// Returns or sets the html5 data attribute. - /// - public string DataProvide - { - get => GetAttribute("data-provide"); - set => SetAttribute("data-provide", value); - } - - /// - /// Returns or sets the on click attribute. - /// - public string OnClick - { - get => GetAttribute("onclick"); - set => SetAttribute("onclick", value); - } - - /// - /// Determines whether the element is inline. - /// - public bool Inline { get; set; } - - /// - /// Determines whether the element needs an end tag. - /// e.g.: true =
false =
- ///
- public bool CloseTag { get; protected set; } - - /// - /// Initializes a new instance of the class. - /// - /// The name of the HTML element. - /// A boolean value indicating whether the element requires a closing tag. Default is true. - public HtmlElement(string name, bool closeTag = true) - { - - ElementName = name; - - CloseTag = closeTag; - - } - - /// - /// Initializes a new instance of the class. - /// - /// The name of the HTML element. - /// A boolean value indicating whether the element requires a closing tag. - /// An array of IHtml nodes to be added to the element. - public HtmlElement(string name, bool closeTag, params IHtml[] nodes) - : this(name, closeTag) - { - foreach (var v in nodes) - { - if (v is HtmlAttribute attr) - { - _attributes.Add(attr); - } - else if (v is HtmlElement element) - { - _elements.Add(element); - } - else if (v is HtmlText text) - { - _elements.Add(text); - } - } - } - - /// - /// Adds one or more elements to the html element. - /// - /// The elements to add. - public void Add(params IHtmlNode[] elements) - { - _elements.AddRange(elements); - } - - /// - /// Adds one or more elements to the html element. - /// - /// The elements to add. - public void Add(IEnumerable elements) - { - _elements.AddRange(elements); - } - - /// - /// Adds one or more elements to the beginning of the html element. - /// - /// The elements to add. - public void AddFirst(params IHtmlNode[] elements) - { - _elements.InsertRange(0, elements); - } - /// - /// Adds one or more attributes to the html element. - /// - /// The attributes to add. - public void Add(params IHtmlAttribute[] attributes) - { - _attributes.AddRange(attributes); - } - - /// - /// Clear all elements frrom the html element. - /// - public void Clear() - { - _elements.Clear(); - } - - /// - /// Clear all elements from the html element that match the given predicate. - /// - /// The predicate to match elements. - protected void Clear(Func predicate) - { - _elements.RemoveAll(new Predicate(predicate)); - } - /// - /// Returns the value of an attribute. - /// - /// The attribute name. - /// The value of the attribute. - protected string GetAttribute(string name) - { - var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); - - if (a != null) - { - return a is HtmlAttribute ? (a as HtmlAttribute).Value : string.Empty; - } - - return string.Empty; - } - - /// - /// Checks whether an attribute is set. - /// - /// The attribute name. - /// True if attribute exists, false otherwise. - protected bool HasAttribute(string name) - { - var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); - - return (a != null); - } - - /// - /// Sets the value of an attribute. - /// - /// The attribute name. - /// The value of the attribute. - protected void SetAttribute(string name, string value) - { - var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); - - if (a != null) - { - if (string.IsNullOrWhiteSpace(value)) - { - _attributes.Remove(a); - } - else if (a is HtmlAttribute) - { - (a as HtmlAttribute).Value = value; - } - } - else - { - if (!string.IsNullOrWhiteSpace(value)) - { - _attributes.Add(new HtmlAttribute(name, value)); - } - } - } - - /// - /// Setzt den Wert eines Attributs - /// - /// The attribute name. - protected void SetAttribute(string name) - { - var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); - - if (a == null) - { - _attributes.Add(new HtmlAttributeNoneValue(name)); - } - } - - /// - /// Removes an attribute. - /// - /// The attribute name. - protected void RemoveAttribute(string name) - { - var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); - - if (a != null) - { - _attributes.Remove(a); - } - } - - /// - /// Returns an element based on its name. - /// - /// The element name. - /// The element. - protected HtmlElement GetElement(string name) - { - var a = _elements.Where(x => x is HtmlElement && (x as HtmlElement).ElementName == name).FirstOrDefault(); - - return a as HtmlElement; - } - - /// - /// Sets an element. - /// - /// The element. - protected void SetElement(HtmlElement element) - { - if (element != null) - { - var a = _elements.Where(x => x is HtmlElement && (x as HtmlElement).ElementName == element.ElementName); - - foreach (var v in a) - { - _elements.Remove(v); - } - - _elements.Add(element); - } - } - - /// - /// Returns the text. - /// - /// The text. - protected string GetText() - { - var a = _elements.Where(x => x is HtmlText).Select(x => (x as HtmlText).Value); - - return string.Join(" ", a); - } - - /// - /// Convert to a string using a StringBuilder. - /// - /// The string builder. - /// The call depth. - public virtual void ToString(StringBuilder builder, int deep) - { - var closeTag = false; - var nl = true; - - ToPreString(builder, deep); - - if (_elements.Count == 1 && Elements.First() is HtmlText) - { - closeTag = true; - nl = false; - - _elements.First().ToString(builder, 0); - } - else if (_elements.Count > 0) - { - closeTag = true; - var count = builder.Length; - - foreach (var v in Elements.Where(x => x != null)) - { - v.ToString(builder, deep + 1); - } - - if (count == builder.Length) - { - nl = false; - } - } - else if (_elements.Count == 0) - { - nl = false; - } - - if (closeTag || CloseTag) - { - ToPostString(builder, deep, nl); - } - } - - /// - /// Converts the element to a string and appends it to the provided StringBuilder. - /// - /// The StringBuilder to append the string representation to. - /// The depth of the element in the HTML hierarchy, used for indentation. - protected virtual void ToPreString(StringBuilder builder, int deep) - { - if (!Inline) - { - builder.AppendLine(); - builder.Append(string.Empty.PadRight(deep)); - } - - builder.Append('<'); - builder.Append(ElementName); - foreach (var attribute in Attributes) - { - builder.Append(' '); - attribute.ToString(builder, 0); - } - - builder.Append('>'); - } - - /// - /// Converts the element to a string and appends the closing tag to the provided StringBuilder. - /// - /// The StringBuilder to append the string representation to. - /// The depth of the element in the HTML hierarchy, used for indentation. - /// Indicates whether the closing tag should start on a new line. - protected virtual void ToPostString(StringBuilder builder, int deep, bool nl = true) - { - if (!Inline && nl) - { - builder.AppendLine(); - builder.Append(string.Empty.PadRight(deep)); - } - - builder.Append("'); - } - - /// - /// Sets the value of an user-defined attribute. - /// - /// The attribute name. - /// The value of the attribute. - public void AddUserAttribute(string name, string value) - { - SetAttribute(name, value); - } - - /// - /// Returns the value of an user-defined attribute. - /// - /// The attribute name. - /// The value of the attribute. - public string GetUserAttribute(string name) - { - return GetAttribute(name); - } - - /// - /// Checks if a user-defined attribute is set. - /// - /// The attribute name. - /// True wenn Attribut vorhanden, false sonst - public bool HasUserAttribute(string name) - { - return HasAttribute(name); - } - - /// - /// Removes an user-defined attribute. - /// - /// The attribute name. - protected void RemoveUserAttribute(string name) - { - RemoveAttribute(name); - } - - /// - /// Convert to String. - /// - /// The object as a string. - public override string ToString() - { - var builder = new StringBuilder(); - ToString(builder, 0); - - return builder.ToString(); - } - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace WebExpress.WebCore.WebHtml +{ + /// + /// The basis of all html elements (see RfC 1866). + /// + public class HtmlElement : IHtmlNode + { + private readonly List _elements = []; + private readonly List _attributes = []; + + /// + /// Returns or sets the name. des Attributes + /// + protected string ElementName { get; set; } + + /// + /// Returns or sets the attributes. + /// + protected IEnumerable Attributes => _attributes; + + /// + /// Returns the elements. + /// + protected IEnumerable Elements => _elements; + + /// + /// Returns or sets the id. + /// + public string Id + { + get => GetAttribute("id"); + set => SetAttribute("id", value); + } + + /// + /// Returns or sets the css class. + /// + public string Class + { + get => GetAttribute("class"); + set => SetAttribute("class", value); + } + + /// + /// Returns or sets the css style. + /// + public string Style + { + get => GetAttribute("style"); + set => SetAttribute("style", value); + } + + /// + /// Returns or sets the role. + /// + public string Role + { + get => GetAttribute("role"); + set => SetAttribute("role", value); + } + + /// + /// Returns or sets the html5 data attribute. + /// + public string DataToggle + { + get => GetAttribute("data-toggle"); + set => SetAttribute("data-toggle", value); + } + + /// + /// Returns or sets the html5 data attribute. + /// + public string DataProvide + { + get => GetAttribute("data-provide"); + set => SetAttribute("data-provide", value); + } + + /// + /// Returns or sets the theme. + /// + public string DataTheme + { + get => GetAttribute("data-bs-theme"); + set => SetAttribute("data-bs-theme", value); + } + + /// + /// Returns or sets the on click attribute. + /// + public string OnClick + { + get => GetAttribute("onclick"); + set => SetAttribute("onclick", value); + } + + /// + /// Determines whether the element is inline. + /// + public bool Inline { get; set; } + + /// + /// Determines whether the element needs an end tag. + /// e.g.: true =
false =
+ ///
+ public bool CloseTag { get; protected set; } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the HTML element. + /// A boolean value indicating whether the element requires a closing tag. Default is true. + public HtmlElement(string name, bool closeTag = true) + { + + ElementName = name; + + CloseTag = closeTag; + + } + + + /// + /// Initializes a new instance of the class. + /// + /// The name of the HTML element. + /// A boolean value indicating whether the element requires a closing tag. + /// An array of IHtml nodes to be added to the element. + public HtmlElement(string name, bool closeTag, params IHtml[] nodes) + : this(name, closeTag) + { + foreach (var v in nodes) + { + if (v is HtmlAttribute attr) + { + _attributes.Add(attr); + } + else if (v is HtmlElement element) + { + _elements.Add(element); + } + else if (v is HtmlText text) + { + _elements.Add(text); + } + } + } + + /// + /// Adds one or more elements to the html element. + /// + /// The elements to add. + public void Add(params IHtmlNode[] elements) + { + _elements.AddRange(elements); + } + + /// + /// Adds one or more elements to the html element. + /// + /// The elements to add. + public void Add(IEnumerable elements) + { + _elements.AddRange(elements); + } + + /// + /// Adds one or more elements to the beginning of the html element. + /// + /// The elements to add. + public void AddFirst(params IHtmlNode[] elements) + { + _elements.InsertRange(0, elements); + } + + /// + /// Adds one or more attributes to the html element. + /// + /// The attributes to add. + public void Add(params IHtmlAttribute[] attributes) + { + _attributes.AddRange(attributes); + } + + /// + /// Clear all elements frrom the html element. + /// + public void Clear() + { + _elements.Clear(); + } + + /// + /// Clear all elements from the html element that match the given predicate. + /// + /// The predicate to match elements. + protected void Clear(Func predicate) + { + _elements.RemoveAll(new Predicate(predicate)); + } + + /// + /// Returns the value of an attribute. + /// + /// The attribute name. + /// The value of the attribute. + protected string GetAttribute(string name) + { + var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); + + if (a != null) + { + return a is HtmlAttribute ? (a as HtmlAttribute).Value : string.Empty; + } + + return string.Empty; + } + + /// + /// Checks whether an attribute is set. + /// + /// The attribute name. + /// True if attribute exists, false otherwise. + protected bool HasAttribute(string name) + { + var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); + + return (a != null); + } + + /// + /// Sets the value of an attribute. + /// + /// The attribute name. + /// The value of the attribute. + protected void SetAttribute(string name, string value) + { + var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); + + if (a != null) + { + if (string.IsNullOrWhiteSpace(value)) + { + _attributes.Remove(a); + } + else if (a is HtmlAttribute) + { + (a as HtmlAttribute).Value = value; + } + } + else + { + if (!string.IsNullOrWhiteSpace(value)) + { + _attributes.Add(new HtmlAttribute(name, value)); + } + } + } + + /// + /// Setzt den Wert eines Attributs + /// + /// The attribute name. + protected void SetAttribute(string name) + { + var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); + + if (a == null) + { + _attributes.Add(new HtmlAttributeNoneValue(name)); + } + } + + /// + /// Removes an attribute. + /// + /// The attribute name. + protected void RemoveAttribute(string name) + { + var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); + + if (a != null) + { + _attributes.Remove(a); + } + } + + /// + /// Returns an element based on its name. + /// + /// The element name. + /// The element. + protected HtmlElement GetElement(string name) + { + var a = _elements.Where(x => x is HtmlElement && (x as HtmlElement).ElementName == name).FirstOrDefault(); + + return a as HtmlElement; + } + + /// + /// Sets an element. + /// + /// The element. + protected void SetElement(HtmlElement element) + { + if (element != null) + { + var a = _elements.Where(x => x is HtmlElement && (x as HtmlElement).ElementName == element.ElementName); + + foreach (var v in a) + { + _elements.Remove(v); + } + + _elements.Add(element); + } + } + + /// + /// Returns the text. + /// + /// The text. + protected string GetText() + { + var a = _elements.Where(x => x is HtmlText).Select(x => (x as HtmlText).Value); + + return string.Join(" ", a); + } + + /// + /// Convert to a string using a StringBuilder. + /// + /// The string builder. + /// The call depth. + public virtual void ToString(StringBuilder builder, int deep) + { + var closeTag = false; + var nl = true; + + ToPreString(builder, deep); + + if (_elements.Count == 1 && Elements.First() is HtmlText) + { + closeTag = true; + nl = false; + + _elements.First().ToString(builder, 0); + } + else if (_elements.Count > 0) + { + closeTag = true; + var count = builder.Length; + + foreach (var v in Elements.Where(x => x != null)) + { + v.ToString(builder, deep + 1); + } + + if (count == builder.Length) + { + nl = false; + } + } + else if (_elements.Count == 0) + { + nl = false; + } + + if (closeTag || CloseTag) + { + ToPostString(builder, deep, nl); + } + } + + /// + /// Converts the element to a string and appends it to the provided StringBuilder. + /// + /// The StringBuilder to append the string representation to. + /// The depth of the element in the HTML hierarchy, used for indentation. + protected virtual void ToPreString(StringBuilder builder, int deep) + { + if (!Inline) + { + builder.AppendLine(); + builder.Append(string.Empty.PadRight(deep)); + } + + builder.Append('<'); + builder.Append(ElementName); + foreach (var attribute in Attributes) + { + builder.Append(' '); + attribute.ToString(builder, 0); + } + + builder.Append('>'); + } + + /// + /// Converts the element to a string and appends the closing tag to the provided StringBuilder. + /// + /// The StringBuilder to append the string representation to. + /// The depth of the element in the HTML hierarchy, used for indentation. + /// Indicates whether the closing tag should start on a new line. + protected virtual void ToPostString(StringBuilder builder, int deep, bool nl = true) + { + if (!Inline && nl) + { + builder.AppendLine(); + builder.Append(string.Empty.PadRight(deep)); + } + + builder.Append("'); + } + + /// + /// Sets the valueless user-defined attribute. + /// + /// The attribute name. + public void AddUserAttribute(string name) + { + SetAttribute(name); + } + + /// + /// Sets the value of an user-defined attribute. + /// + /// The attribute name. + /// The value of the attribute. + public void AddUserAttribute(string name, string value) + { + SetAttribute(name, value); + } + + /// + /// Returns the value of an user-defined attribute. + /// + /// The attribute name. + /// The value of the attribute. + public string GetUserAttribute(string name) + { + return GetAttribute(name); + } + + /// + /// Checks if a user-defined attribute is set. + /// + /// The attribute name. + /// True wenn Attribut vorhanden, false sonst + public bool HasUserAttribute(string name) + { + return HasAttribute(name); + } + + /// + /// Removes an user-defined attribute. + /// + /// The attribute name. + protected void RemoveUserAttribute(string name) + { + RemoveAttribute(name); + } + + /// + /// Convert to String. + /// + /// The object as a string. + public override string ToString() + { + var builder = new StringBuilder(); + ToString(builder, 0); + + return builder.ToString(); + } + } +} From d2cb473452ec960f081b3266243a7330e2225a1c Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Tue, 29 Apr 2025 21:25:22 +0200 Subject: [PATCH 04/51] add: implement method chaining for fluent interface --- src/WebExpress.WebCore/WebHtml/HtmlElement.cs | 39 +++++++++++++------ src/WebExpress.WebCore/WebHtml/HtmlList.cs | 5 ++- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs index 42ce8ce..408bb93 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs @@ -118,14 +118,10 @@ public string OnClick /// A boolean value indicating whether the element requires a closing tag. Default is true. public HtmlElement(string name, bool closeTag = true) { - ElementName = name; - CloseTag = closeTag; - } - /// /// Initializes a new instance of the class. /// @@ -156,44 +152,59 @@ public HtmlElement(string name, bool closeTag, params IHtml[] nodes) /// Adds one or more elements to the html element. /// /// The elements to add. - public void Add(params IHtmlNode[] elements) + /// The current instance for method chaining. + public HtmlElement Add(params IHtmlNode[] elements) { _elements.AddRange(elements); + + return this; } /// /// Adds one or more elements to the html element. /// /// The elements to add. - public void Add(IEnumerable elements) + /// The current instance for method chaining. + public HtmlElement Add(IEnumerable elements) { _elements.AddRange(elements); + + return this; } /// /// Adds one or more elements to the beginning of the html element. /// /// The elements to add. - public void AddFirst(params IHtmlNode[] elements) + /// The current instance for method chaining. + public HtmlElement AddFirst(params IHtmlNode[] elements) { _elements.InsertRange(0, elements); + + return this; } /// /// Adds one or more attributes to the html element. /// /// The attributes to add. - public void Add(params IHtmlAttribute[] attributes) + /// The current instance for method chaining. + public HtmlElement Add(params IHtmlAttribute[] attributes) { _attributes.AddRange(attributes); + + return this; } /// /// Clear all elements frrom the html element. /// - public void Clear() + /// The current instance for method chaining. + public HtmlElement Clear() { _elements.Clear(); + + return this; } /// @@ -425,9 +436,12 @@ protected virtual void ToPostString(StringBuilder builder, int deep, bool nl = t /// Sets the valueless user-defined attribute. /// /// The attribute name. - public void AddUserAttribute(string name) + /// The current instance for method chaining. + public HtmlElement AddUserAttribute(string name) { SetAttribute(name); + + return this; } /// @@ -435,9 +449,12 @@ public void AddUserAttribute(string name) /// /// The attribute name. /// The value of the attribute. - public void AddUserAttribute(string name, string value) + /// The current instance for method chaining. + public HtmlElement AddUserAttribute(string name, string value) { SetAttribute(name, value); + + return this; } /// diff --git a/src/WebExpress.WebCore/WebHtml/HtmlList.cs b/src/WebExpress.WebCore/WebHtml/HtmlList.cs index 29144a1..5126f87 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlList.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlList.cs @@ -70,9 +70,12 @@ public HtmlList(IHtmlNode firstNode, IEnumerable followingNodes) /// Adds one or more elements to the list. /// /// The elements to add. - protected void Add(params IHtmlNode[] elements) + /// The current instance for method chaining. + protected HtmlList Add(params IHtmlNode[] elements) { _elements.AddRange(elements); + + return this; } /// From 963958336744edbfbb8f12dc9a502eb07d3db6e5 Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Sun, 4 May 2025 21:48:04 +0200 Subject: [PATCH 05/51] feat: general improvements --- .../Fixture/UnitTestFixture.cs | 3 +-- .../Manager/UnitTestApplication.cs | 2 +- src/WebExpress.WebCore/HttpServer.cs | 5 ++--- src/WebExpress.WebCore/HttpServerContext.cs | 8 -------- src/WebExpress.WebCore/IHttpServerContext.cs | 5 ----- .../WebApplication/ApplicationContext.cs | 4 ++-- .../WebApplication/ApplicationManager.cs | 4 ++-- .../WebApplication/IApplicationContext.cs | 4 ++-- src/WebExpress.WebCore/WebAsset/AssetManager.cs | 4 ++-- src/WebExpress.WebCore/WebEx.cs | 1 - .../WebMessage/ResponseRedirectPermanentlyMoved.cs | 5 +++-- .../WebMessage/ResponseRedirectTemporarilyMoved.cs | 5 +++-- src/WebExpress.WebCore/WebPage/Page.cs | 8 +++++--- src/WebExpress.WebCore/WebPage/PageManager.cs | 2 +- src/WebExpress.WebCore/WebPage/RedirectException.cs | 11 ++++++----- src/WebExpress.WebCore/WebPlugin/PluginManager.cs | 2 +- src/WebExpress.WebCore/WebResource/ResourceManager.cs | 2 +- src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs | 2 +- .../WebSettingPage/SettingPageManager.cs | 2 +- src/WebExpress.WebCore/WebSitemap/SitemapManager.cs | 2 +- .../WebStatusPage/StatusPageManager.cs | 2 +- src/WebExpress.WebCore/WebTheme/ThemeManager.cs | 4 ++-- 22 files changed, 38 insertions(+), 49 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs index 31d7728..ee51d00 100644 --- a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs +++ b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs @@ -37,13 +37,12 @@ public static IHttpServerContext CreateHttpServerContextMock() { return new HttpServerContext ( - new RouteEndpoint("localhost"), + new RouteEndpoint("server"), [], "", Environment.CurrentDirectory, Environment.CurrentDirectory, Environment.CurrentDirectory, - new RouteEndpoint("/server"), CultureInfo.GetCultureInfo("en"), new Log() { LogMode = LogMode.Off }, null diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestApplication.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestApplication.cs index 59c4530..db76fe1 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestApplication.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestApplication.cs @@ -129,7 +129,7 @@ public void ContextPath(Type applicationType, string contextPath) var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); // test execution - Assert.Equal(contextPath, application.ContextPath.ToString()); + Assert.Equal(contextPath, application.Route.ToString()); } /// diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index dfb0a95..a0c1a8d 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -85,7 +85,6 @@ public HttpServer(HttpServerContext context) context.AssetPath, context.DataPath, context.ConfigPath, - context.ContextPath, context.Culture, context.Log, this @@ -311,11 +310,11 @@ private Response HandleClient(HttpContext context) { if (ex.Permanet) { - response = new ResponseRedirectPermanentlyMoved(ex.Url); + response = new ResponseRedirectPermanentlyMoved(ex.Uri); } else { - response = new ResponseRedirectTemporarilyMoved(ex.Url); + response = new ResponseRedirectTemporarilyMoved(ex.Uri); } } catch (Exception ex) diff --git a/src/WebExpress.WebCore/HttpServerContext.cs b/src/WebExpress.WebCore/HttpServerContext.cs index 76db1ee..11eeeed 100644 --- a/src/WebExpress.WebCore/HttpServerContext.cs +++ b/src/WebExpress.WebCore/HttpServerContext.cs @@ -47,11 +47,6 @@ public class HttpServerContext : IHttpServerContext /// public string ConfigPath { get; protected set; } - /// - /// Returns the basic context path. - /// - public IRoute ContextPath { get; protected set; } - /// /// Returns the culture. /// @@ -76,7 +71,6 @@ public class HttpServerContext : IHttpServerContext /// The asset home directory. /// The data home directory. /// The configuration directory. - /// The basic context path. /// The culture. /// The log. /// The host. @@ -88,7 +82,6 @@ public HttpServerContext string assetBaseFolder, string dataBaseFolder, string configBaseFolder, - IRoute contextPath, CultureInfo culture, ILog log, IHost host @@ -103,7 +96,6 @@ IHost host AssetPath = assetBaseFolder; DataPath = dataBaseFolder; ConfigPath = configBaseFolder; - ContextPath = contextPath; Culture = culture; Log = log; Host = host; diff --git a/src/WebExpress.WebCore/IHttpServerContext.cs b/src/WebExpress.WebCore/IHttpServerContext.cs index 1ef850b..45385d7 100644 --- a/src/WebExpress.WebCore/IHttpServerContext.cs +++ b/src/WebExpress.WebCore/IHttpServerContext.cs @@ -46,11 +46,6 @@ public interface IHttpServerContext /// string ConfigPath { get; } - /// - /// Returns the basic context path. - /// - IRoute ContextPath { get; } - /// /// Returns the culture. /// diff --git a/src/WebExpress.WebCore/WebApplication/ApplicationContext.cs b/src/WebExpress.WebCore/WebApplication/ApplicationContext.cs index 14c97f1..f755d12 100644 --- a/src/WebExpress.WebCore/WebApplication/ApplicationContext.cs +++ b/src/WebExpress.WebCore/WebApplication/ApplicationContext.cs @@ -39,9 +39,9 @@ public class ApplicationContext : IApplicationContext public string DataPath { get; internal set; } /// - /// Returns the context path. This is mounted in the context path of the server. + /// Returns the context path. This is mounted in the route of the server. /// - public IRoute ContextPath { get; internal set; } + public IRoute Route { get; internal set; } /// /// Returns the icon uri. diff --git a/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs b/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs index 139ceb7..a2f644d 100644 --- a/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs +++ b/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs @@ -129,8 +129,8 @@ private void Register(IPluginContext pluginContext) Description = description, AssetPath = Path.Combine(_httpServerContext.AssetPath, assetPath), DataPath = Path.Combine(_httpServerContext.DataPath, dataPath), - Icon = RouteEndpoint.Combine(_httpServerContext.ContextPath, contextPath, icon), - ContextPath = RouteEndpoint.Combine(_httpServerContext.ContextPath, contextPath) + Icon = RouteEndpoint.Combine(_httpServerContext.Route, contextPath, icon), + Route = RouteEndpoint.Combine(_httpServerContext.Route, contextPath) }; // create application diff --git a/src/WebExpress.WebCore/WebApplication/IApplicationContext.cs b/src/WebExpress.WebCore/WebApplication/IApplicationContext.cs index 23c2062..9d91553 100644 --- a/src/WebExpress.WebCore/WebApplication/IApplicationContext.cs +++ b/src/WebExpress.WebCore/WebApplication/IApplicationContext.cs @@ -40,9 +40,9 @@ public interface IApplicationContext : IContext string DataPath { get; } /// - /// Returns the context path. This is mounted in the context path of the server. + /// Returns the context path. This is mounted in the route of the server. /// - IRoute ContextPath { get; } + IRoute Route { get; } /// /// Returns the icon uri. diff --git a/src/WebExpress.WebCore/WebAsset/AssetManager.cs b/src/WebExpress.WebCore/WebAsset/AssetManager.cs index 44b7876..6d5e148 100644 --- a/src/WebExpress.WebCore/WebAsset/AssetManager.cs +++ b/src/WebExpress.WebCore/WebAsset/AssetManager.cs @@ -142,7 +142,7 @@ private void Register(IPluginContext pluginContext, IEnumerable(typeof(Asset), context, _httpServerContext, _componentHub); diff --git a/src/WebExpress.WebCore/WebEx.cs b/src/WebExpress.WebCore/WebEx.cs index 5a7f82c..b862598 100644 --- a/src/WebExpress.WebCore/WebEx.cs +++ b/src/WebExpress.WebCore/WebEx.cs @@ -189,7 +189,6 @@ private void Initialization(string args, string configFile) Path.GetFullPath(assetBase), Path.GetFullPath(dataBase), Path.GetDirectoryName(configFile), - new RouteEndpoint(config.ContextPath), culture, log, null diff --git a/src/WebExpress.WebCore/WebMessage/ResponseRedirectPermanentlyMoved.cs b/src/WebExpress.WebCore/WebMessage/ResponseRedirectPermanentlyMoved.cs index 2c57bf7..fe7c7fc 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseRedirectPermanentlyMoved.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseRedirectPermanentlyMoved.cs @@ -1,4 +1,5 @@ using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebMessage { @@ -11,11 +12,11 @@ public class ResponseRedirectPermanentlyMoved : Response /// /// Initializes a new instance of the class. /// - public ResponseRedirectPermanentlyMoved(string location) + public ResponseRedirectPermanentlyMoved(IUri location) { Reason = "permanently moved"; - Header.Location = location; + Header.Location = location?.ToString(); } } } diff --git a/src/WebExpress.WebCore/WebMessage/ResponseRedirectTemporarilyMoved.cs b/src/WebExpress.WebCore/WebMessage/ResponseRedirectTemporarilyMoved.cs index c5576ac..a9f3c24 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseRedirectTemporarilyMoved.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseRedirectTemporarilyMoved.cs @@ -1,4 +1,5 @@ using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebMessage { @@ -11,14 +12,14 @@ public class ResponseRedirectTemporarilyMoved : Response /// /// Initializes a new instance of the class. /// - public ResponseRedirectTemporarilyMoved(string location) + public ResponseRedirectTemporarilyMoved(IUri location) { Reason = "temporarily moved"; //Content = ""; //HeaderFields.ContentType = "text/html"; //HeaderFields.ContentLength = Content.ToString().Length; - Header.Location = location; + Header.Location = location?.ToString(); } } } diff --git a/src/WebExpress.WebCore/WebPage/Page.cs b/src/WebExpress.WebCore/WebPage/Page.cs index db4c456..860bfe2 100644 --- a/src/WebExpress.WebCore/WebPage/Page.cs +++ b/src/WebExpress.WebCore/WebPage/Page.cs @@ -1,4 +1,6 @@ -namespace WebExpress.WebCore.WebPage +using WebExpress.WebCore.WebUri; + +namespace WebExpress.WebCore.WebPage { /// /// The prototype of a website. @@ -29,9 +31,9 @@ public Page() /// The function throws the RedirectException. /// /// The uri to redirect to. - public virtual void Redirecting(string uri) + public virtual void Redirecting(IUri uri) { - throw new RedirectException(uri?.ToString()); + throw new RedirectException(uri); } /// diff --git a/src/WebExpress.WebCore/WebPage/PageManager.cs b/src/WebExpress.WebCore/WebPage/PageManager.cs index 60c7f1e..4be1fe8 100644 --- a/src/WebExpress.WebCore/WebPage/PageManager.cs +++ b/src/WebExpress.WebCore/WebPage/PageManager.cs @@ -353,7 +353,7 @@ private void Register(IPluginContext pluginContext, IEnumerable /// Returns or sets the redirection target. /// - public string Url { get; set; } + public IUri Uri { get; set; } /// /// Determines whether a permanent redirection should occur. @@ -20,12 +21,12 @@ public class RedirectException : Exception /// /// Initializes a new instance of the class. /// - /// The redirection target. + /// The redirection target. /// true if 301 should be sent, false for 302. - public RedirectException(string url, bool permanent = false) - : base("Redirecting to " + url) + public RedirectException(IUri uri, bool permanent = false) + : base("Redirecting to " + uri) { - Url = url; + Uri = uri; Permanet = permanent; } } diff --git a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs index e3e7edd..fb9aaf3 100644 --- a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs +++ b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs @@ -277,7 +277,7 @@ private IEnumerable Register(Assembly assembly, PluginLoadContex PluginName = name, Manufacturer = type.Assembly.GetCustomAttribute()?.Company, Copyright = type.Assembly.GetCustomAttribute()?.Copyright, - Icon = RouteEndpoint.Combine(_httpServerContext?.ContextPath, icon), + Icon = RouteEndpoint.Combine(_httpServerContext?.Route, icon), Description = description, Version = type.Assembly.GetCustomAttribute()?.InformationalVersion }; diff --git a/src/WebExpress.WebCore/WebResource/ResourceManager.cs b/src/WebExpress.WebCore/WebResource/ResourceManager.cs index 6ddd4ae..d476734 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceManager.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceManager.cs @@ -162,7 +162,7 @@ private void Register(IPluginContext pluginContext, IEnumerable new { ApplicationContext = x, - x.ContextPath.PathSegments + x.Route.PathSegments }) .OrderBy(x => x.PathSegments.Count()); diff --git a/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs b/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs index a57c176..f06ed5b 100644 --- a/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs +++ b/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs @@ -156,7 +156,7 @@ private void Register(IPluginContext pluginContext, IEnumerable().StatusCode; var statusPageContext = new StatusPageContext() { diff --git a/src/WebExpress.WebCore/WebTheme/ThemeManager.cs b/src/WebExpress.WebCore/WebTheme/ThemeManager.cs index 928fc3a..4e8c830 100644 --- a/src/WebExpress.WebCore/WebTheme/ThemeManager.cs +++ b/src/WebExpress.WebCore/WebTheme/ThemeManager.cs @@ -199,9 +199,9 @@ private void Register(IPluginContext pluginContext, IEnumerable Date: Sat, 10 May 2025 06:43:56 +0200 Subject: [PATCH 06/51] add: new css concatenate method --- src/WebExpress.WebCore/WebHtml/Css.cs | 39 +++++++++++++++++++++------ 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/src/WebExpress.WebCore/WebHtml/Css.cs b/src/WebExpress.WebCore/WebHtml/Css.cs index 0b385f1..07351e4 100644 --- a/src/WebExpress.WebCore/WebHtml/Css.cs +++ b/src/WebExpress.WebCore/WebHtml/Css.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; namespace WebExpress.WebCore.WebHtml { @@ -8,21 +9,43 @@ namespace WebExpress.WebCore.WebHtml public static class Css { /// - /// Joins the specifying css classes into a string. + /// Joins the specified CSS classes into a single string, ensuring no duplicates and ignoring null or whitespace entries. /// - /// The individual css classes. - /// The css classes as a string. + /// The individual CSS classes to join. + /// A string containing the concatenated CSS classes. public static string Concatenate(params string[] items) { return string.Join(' ', items.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct()); } /// - /// Removes the specified css classes from the string. + /// Joins the specified CSS classes into a single string, starting with a required first class, ensuring no duplicates and ignoring null or whitespace entries. /// - /// The css classes connected in a common string. - /// The css classes to remove. - /// The css classes as a string. + /// The first CSS class, which is required. + /// Additional CSS classes to join. + /// A string containing the concatenated CSS classes. + public static string Concatenate(string first, params string[] items) + { + return string.Join(' ', new[] { first }.Union(items).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct()); + } + + /// + /// Joins the specified CSS classes into a single string, starting with a required first class, ensuring no duplicates and ignoring null or whitespace entries. + /// + /// The first CSS class, which is required. + /// Additional CSS classes to join. + /// A string containing the concatenated CSS classes. + public static string Concatenate(string first, IEnumerable items) + { + return string.Join(' ', new[] { first }.Union(items).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct()); + } + + /// + /// Removes the specified CSS classes from a string of concatenated CSS classes. + /// + /// The string containing concatenated CSS classes. + /// The CSS classes to remove from the string. + /// A string containing the remaining CSS classes after removal. public static string Remove(string css, params string[] remove) { return string.Join(' ', css.Split(' ').Where(x => !remove.Contains(x))); From 3641428855cd805f759d92aca1f2abd4ccb91899 Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Sun, 11 May 2025 19:55:13 +0200 Subject: [PATCH 07/51] feat: general improvements --- src/WebExpress.WebCore/WebHtml/HtmlElementFormOption.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementFormOption.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementFormOption.cs index 78c988e..0057d08 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementFormOption.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementFormOption.cs @@ -41,6 +41,15 @@ public bool Selected set { if (value) { SetAttribute("selected"); } else { RemoveAttribute("selected"); } } } + /// + /// Returns or sets a value indicating whether the option is disabled. + /// + public bool Disabled + { + get => HasAttribute("disabled"); + set { if (value) { SetAttribute("disabled"); } else { RemoveAttribute("disabled"); } } + } + /// /// Initializes a new instance of the class. /// From a416a8752e04553a5bf98a2331e94f56094ea9dd Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Mon, 12 May 2025 21:46:33 +0200 Subject: [PATCH 08/51] add: find method --- .../Html/UnitTestHtmlElement.cs | 55 +++++++++++++++++++ .../WebHtml/HTMLElementExtension.cs | 55 ++++++++++++++++++- src/WebExpress.WebCore/WebHtml/HtmlElement.cs | 2 +- 3 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs new file mode 100644 index 0000000..c01b994 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs @@ -0,0 +1,55 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFieldLabel class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElement + { + /// + /// Tests the find method. + /// + [Fact] + public void FindSingel() + { + // preconditions + var html = new HtmlElementTextContentDiv + ( + new HtmlElementTextSemanticsI(), + new HtmlElementTextSemanticsU(new HtmlElementTextSemanticsSpan()), + new HtmlElementTextSemanticsB() + ); + + // test execution + var res = html.Find(x => x is HtmlElementTextSemanticsSpan).FirstOrDefault(); + + Assert.Equal(@"", res.Trim()); + } + + /// + /// Tests the find method. + /// + [Fact] + public void Find() + { + // preconditions + var html = new HtmlElement[] + { + new HtmlElementTextContentDiv + ( + new HtmlElementTextSemanticsI(), + new HtmlElementTextSemanticsU(new HtmlElementTextSemanticsSpan()), + new HtmlElementTextSemanticsB() + ), + new HtmlElementMultimediaImg() + }; + + // test execution + var res = html.Find(x => x is HtmlElementTextSemanticsSpan).FirstOrDefault(); + + Assert.Equal(@"", res.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore/WebHtml/HTMLElementExtension.cs b/src/WebExpress.WebCore/WebHtml/HTMLElementExtension.cs index 5fc1cb1..492016b 100644 --- a/src/WebExpress.WebCore/WebHtml/HTMLElementExtension.cs +++ b/src/WebExpress.WebCore/WebHtml/HTMLElementExtension.cs @@ -7,7 +7,7 @@ namespace WebExpress.WebCore.WebHtml /// /// Extension methods for html Eelements. /// - public static class HTMLElementExtension + public static class HtmlElementExtension { /// /// Adds a css class. @@ -95,7 +95,7 @@ public static IHtmlNode AddStyle(this IHtmlNode html, string cssStyle) /// /// Removes a style. /// - /// The HTML element. + /// The HTML node. /// Der Style, welcher entfernt werden soll /// The HTML element reduced by the checkout. public static IHtmlNode RemoveStyle(this IHtmlNode html, string cssStyle) @@ -118,5 +118,56 @@ public static IHtmlNode RemoveStyle(this IHtmlNode html, string cssStyle) return html; } + + /// + /// Searches an HTML structure and returns all matching elements. + /// + /// The root node of the HTML structure. + /// + /// A function that determines whether an element should be returned. + /// + /// + /// A collection of HTML elements that match the specified condition. + /// + public static IEnumerable Find(this IHtmlNode html, Func predicate) + { + if (predicate(html)) + { + yield return html; + } + + if (html is HtmlElement element) + { + foreach (var child in element.Elements.OfType()) + { + foreach (var descendant in child.Find(predicate)) + { + yield return descendant; + } + } + } + } + + /// + /// Searches an HTML element collection and returns all matching elements. + /// + /// The collection of HTML nodes. + /// + /// A function that determines whether an element should be returned. + /// + /// + /// A collection of HTML elements that match the specified condition. + /// + public static IEnumerable Find(this IEnumerable nodes, Func predicate) + { + foreach (var element in nodes) + { + foreach (var found in element.Find(predicate)) + { + yield return found; + } + } + } + } } diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs index 408bb93..a196b93 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs @@ -26,7 +26,7 @@ public class HtmlElement : IHtmlNode /// /// Returns the elements. /// - protected IEnumerable Elements => _elements; + internal IEnumerable Elements => _elements; /// /// Returns or sets the id. From 1be0919290c7026c242a16509d626822774d0b92 Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Sun, 18 May 2025 06:43:09 +0200 Subject: [PATCH 09/51] add: new web ui interface for ui elements like fragments or controls --- .../WebFragment/IFragment.cs | 10 +-------- .../WebPage/IWebUIElement.cs | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) create mode 100644 src/WebExpress.WebCore/WebPage/IWebUIElement.cs diff --git a/src/WebExpress.WebCore/WebFragment/IFragment.cs b/src/WebExpress.WebCore/WebFragment/IFragment.cs index 8bd90a4..cf0875b 100644 --- a/src/WebExpress.WebCore/WebFragment/IFragment.cs +++ b/src/WebExpress.WebCore/WebFragment/IFragment.cs @@ -1,5 +1,4 @@ using WebExpress.WebCore.WebComponent; -using WebExpress.WebCore.WebHtml; using WebExpress.WebCore.WebPage; namespace WebExpress.WebCore.WebFragment @@ -14,16 +13,9 @@ public interface IFragment : IFragment /// /// Represents a fragment that is a part of a web component. /// - public interface IFragment : IComponent, IFragmentBase + public interface IFragment : IComponent, IFragmentBase, IWebUIElement where TRenderContext : IRenderContext where TVisualTree : IVisualTree { - /// - /// Converts the fragment to an HTML representation. - /// - /// The context in which the fragment is rendered. - /// The visual tree used for rendering the fragment. - /// An HTML node representing the rendered fragments. Can be null if no nodes are present. - IHtmlNode Render(TRenderContext renderContext, TVisualTree visualTree); } } diff --git a/src/WebExpress.WebCore/WebPage/IWebUIElement.cs b/src/WebExpress.WebCore/WebPage/IWebUIElement.cs new file mode 100644 index 0000000..c20a589 --- /dev/null +++ b/src/WebExpress.WebCore/WebPage/IWebUIElement.cs @@ -0,0 +1,22 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.WebPage +{ + /// + /// Represents a UI element that can be rendered to an HTML representation. + /// + /// The type of the rendering context used during the rendering process. + /// The type of the visual tree that represents the structure of the page. + public interface IWebUIElement + where TRenderControlContext : IRenderContext + where TVisualTree : IVisualTree + { + /// + /// Converts the control to an HTML representation. + /// + /// The context in which the ui element is rendered. + /// The visual tree representing the structure of the page. + /// An HTML node representing the rendered ui element. + IHtmlNode Render(TRenderControlContext renderContext, TVisualTree visualTree); + } +} From 3fc6086ea4110f060086e63b889c75f401dd2c5b Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Sun, 18 May 2025 07:56:32 +0200 Subject: [PATCH 10/51] feat: improved the web ui interface --- src/WebExpress.WebCore.Test/TestFragmentA.cs | 5 +++++ src/WebExpress.WebCore.Test/TestFragmentB.cs | 5 +++++ src/WebExpress.WebCore.Test/TestFragmentC.cs | 5 +++++ src/WebExpress.WebCore.Test/TestFragmentD.cs | 5 +++++ src/WebExpress.WebCore/WebPage/IWebUIElement.cs | 5 +++++ 5 files changed, 25 insertions(+) diff --git a/src/WebExpress.WebCore.Test/TestFragmentA.cs b/src/WebExpress.WebCore.Test/TestFragmentA.cs index cfba253..8fb3a7a 100644 --- a/src/WebExpress.WebCore.Test/TestFragmentA.cs +++ b/src/WebExpress.WebCore.Test/TestFragmentA.cs @@ -16,6 +16,11 @@ namespace WebExpress.WebCore.Test [Order(0)] public sealed class TestFragmentA : IFragment { + /// + /// Returns the id. + /// + public string Id => string.Empty; + /// /// Initialization of the fragment. Here, for example, managed resources can be loaded. /// diff --git a/src/WebExpress.WebCore.Test/TestFragmentB.cs b/src/WebExpress.WebCore.Test/TestFragmentB.cs index 99e12f6..88a58c6 100644 --- a/src/WebExpress.WebCore.Test/TestFragmentB.cs +++ b/src/WebExpress.WebCore.Test/TestFragmentB.cs @@ -14,6 +14,11 @@ namespace WebExpress.WebCore.Test [Order(0)] public sealed class TestFragmentB : IFragment { + /// + /// Returns the id. + /// + public string Id => string.Empty; + /// /// Initialization of the fragment. Here, for example, managed resources can be loaded. /// diff --git a/src/WebExpress.WebCore.Test/TestFragmentC.cs b/src/WebExpress.WebCore.Test/TestFragmentC.cs index 4470b51..40be3f3 100644 --- a/src/WebExpress.WebCore.Test/TestFragmentC.cs +++ b/src/WebExpress.WebCore.Test/TestFragmentC.cs @@ -13,6 +13,11 @@ namespace WebExpress.WebCore.Test [Order(0)] public sealed class TestFragmentC : IFragment { + /// + /// Returns or sets the id. + /// + public string Id => string.Empty; + /// /// Initialization of the fragment. Here, for example, managed resources can be loaded. /// diff --git a/src/WebExpress.WebCore.Test/TestFragmentD.cs b/src/WebExpress.WebCore.Test/TestFragmentD.cs index dc7f64d..3a583fd 100644 --- a/src/WebExpress.WebCore.Test/TestFragmentD.cs +++ b/src/WebExpress.WebCore.Test/TestFragmentD.cs @@ -14,6 +14,11 @@ namespace WebExpress.WebCore.Test [Condition] public sealed class TestFragmentD : IFragment { + /// + /// Returns or sets the id. + /// + public string Id => string.Empty; + /// /// Initialization of the fragment. Here, for example, managed resources can be loaded. /// diff --git a/src/WebExpress.WebCore/WebPage/IWebUIElement.cs b/src/WebExpress.WebCore/WebPage/IWebUIElement.cs index c20a589..87cda53 100644 --- a/src/WebExpress.WebCore/WebPage/IWebUIElement.cs +++ b/src/WebExpress.WebCore/WebPage/IWebUIElement.cs @@ -11,6 +11,11 @@ public interface IWebUIElement where TRenderControlContext : IRenderContext where TVisualTree : IVisualTree { + /// + /// Returns the id. + /// + string Id { get; } + /// /// Converts the control to an HTML representation. /// From 77d17bf824f85362378d88286517993e5f1bae2e Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Sun, 25 May 2025 10:54:46 +0200 Subject: [PATCH 11/51] feat: improved the html elements --- .../Html/UnitTestHtmlElement.cs | 184 +++++++++++++++++- .../WebFragment/IFragmentWebUIElement.cs | 16 ++ src/WebExpress.WebCore/WebHtml/HtmlElement.cs | 77 +++++++- .../WebHtml/IHtmlElement.cs | 111 +++++++++++ 4 files changed, 371 insertions(+), 17 deletions(-) create mode 100644 src/WebExpress.WebCore/WebFragment/IFragmentWebUIElement.cs create mode 100644 src/WebExpress.WebCore/WebHtml/IHtmlElement.cs diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs index c01b994..425b741 100644 --- a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs @@ -37,13 +37,13 @@ public void Find() // preconditions var html = new HtmlElement[] { - new HtmlElementTextContentDiv - ( - new HtmlElementTextSemanticsI(), - new HtmlElementTextSemanticsU(new HtmlElementTextSemanticsSpan()), - new HtmlElementTextSemanticsB() - ), - new HtmlElementMultimediaImg() + new HtmlElementTextContentDiv + ( + new HtmlElementTextSemanticsI(), + new HtmlElementTextSemanticsU(new HtmlElementTextSemanticsSpan()), + new HtmlElementTextSemanticsB() + ), + new HtmlElementMultimediaImg() }; // test execution @@ -51,5 +51,175 @@ public void Find() Assert.Equal(@"", res.Trim()); } + + /// + /// Tests the AddClass method. + /// + [Fact] + public void AddClassTest() + { + // preconditions + var div = new HtmlElementTextContentDiv(); + + // test execution + div.AddClass("test-class"); + + // assertion + Assert.Contains("class=\"test-class\"", div.ToString()); + } + + /// + /// Tests the RemoveClass method. + /// + [Fact] + public void RemoveClassTest() + { + // preconditions + var div = new HtmlElementTextContentDiv(); + div.AddClass("test-class"); + + // test execution + div.RemoveClass("test-class"); + + // assertion + Assert.DoesNotContain("class=\"test-class\"", div.ToString()); + } + + /// + /// Tests the AddStyle method. + /// + [Fact] + public void AddStyleTest() + { + // preconditions + var div = new HtmlElementTextContentDiv(); + + // test execution + div.AddStyle("color:red;"); + + // assertion + Assert.Contains("style=\"color:red;\"", div.ToString()); + } + + /// + /// Tests the RemoveStyle method. + /// + [Fact] + public void RemoveStyleTest() + { + // preconditions + var div = new HtmlElementTextContentDiv(); + div.AddStyle("color", "red"); + + // test execution + div.RemoveStyle("color"); + + // assertion + Assert.DoesNotContain("style=\"color:red;\"", div.ToString()); + } + + /// + /// Tests adding multiple CSS classes. + /// + [Fact] + public void AddMultipleClassesTest() + { + // preconditions + var div = new HtmlElementTextContentDiv(); + + // test execution + div.AddClass("class1"); + div.AddClass("class2"); + + // assertion + Assert.Contains("class=\"class1 class2\"", div.ToString()); + } + + /// + /// Tests removing one of multiple CSS classes. + /// + [Fact] + public void RemoveOneOfMultipleClassesTest() + { + // preconditions + var div = new HtmlElementTextContentDiv(); + div.AddClass("class1"); + div.AddClass("class2"); + + // test execution + div.RemoveClass("class1"); + + // assertion + Assert.DoesNotContain("class1", div.ToString()); + Assert.Contains("class2", div.ToString()); + } + + /// + /// Tests adding multiple styles. + /// + [Fact] + public void AddMultipleStylesTest() + { + // preconditions + var div = new HtmlElementTextContentDiv(); + + // test execution + div.AddStyle("color:red;"); + div.AddStyle("background:blue;"); + + // assertion + Assert.Contains("color:red;", div.ToString()); + Assert.Contains("background:blue;", div.ToString()); + } + + /// + /// Tests removing one of multiple styles. + /// + [Fact] + public void RemoveOneOfMultipleStylesTest() + { + // preconditions + var div = new HtmlElementTextContentDiv(); + div.AddStyle("color:red;"); + div.AddStyle("background:blue;"); + + // test execution + div.RemoveStyle("color:red;"); + + // assertion + Assert.DoesNotContain("color:red;", div.ToString()); + Assert.Contains("background:blue;", div.ToString()); + } + + /// + /// Tests that ToString returns the correct HTML for an empty div. + /// + [Fact] + public void ToStringEmptyDivTest() + { + // preconditions + var div = new HtmlElementTextContentDiv(); + + // assertion + Assert.Equal("
", div.ToString().Trim()); + } + + /// + /// Tests that ToString returns the correct HTML for a div with child elements. + /// + [Fact] + public void ToStringWithChildrenTest() + { + // preconditions + var div = new HtmlElementTextContentDiv( + new HtmlElementTextSemanticsB(), + new HtmlElementTextSemanticsI() + ); + + // assertion + Assert.Contains("", div.ToString()); + Assert.Contains("", div.ToString()); + } + } } diff --git a/src/WebExpress.WebCore/WebFragment/IFragmentWebUIElement.cs b/src/WebExpress.WebCore/WebFragment/IFragmentWebUIElement.cs new file mode 100644 index 0000000..60944fa --- /dev/null +++ b/src/WebExpress.WebCore/WebFragment/IFragmentWebUIElement.cs @@ -0,0 +1,16 @@ +using WebExpress.WebCore.WebPage; + +namespace WebExpress.WebCore.WebFragment +{ + /// + /// Represents a web UI element that is both a fragment and a part of the visual tree, designed to render within a + /// specified control context. + /// + /// The type of the rendering control context, which must implement . + /// The type of the visual tree structure, which must implement . + public interface IFragmentWebUIElement : IFragment, IWebUIElement + where TRenderControlContext : IRenderContext + where TVisualTree : IVisualTree + { + } +} diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs index a196b93..c2d119a 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs @@ -8,25 +8,25 @@ namespace WebExpress.WebCore.WebHtml /// /// The basis of all html elements (see RfC 1866). /// - public class HtmlElement : IHtmlNode + public class HtmlElement : IHtmlElement { private readonly List _elements = []; private readonly List _attributes = []; /// - /// Returns or sets the name. des Attributes + /// Returns or sets the name of the element. /// protected string ElementName { get; set; } /// /// Returns or sets the attributes. /// - protected IEnumerable Attributes => _attributes; + public IEnumerable Attributes => _attributes; /// /// Returns the elements. /// - internal IEnumerable Elements => _elements; + public IEnumerable Elements => _elements; /// /// Returns or sets the id. @@ -153,7 +153,7 @@ public HtmlElement(string name, bool closeTag, params IHtml[] nodes) /// /// The elements to add. /// The current instance for method chaining. - public HtmlElement Add(params IHtmlNode[] elements) + public IHtmlElement Add(params IHtmlNode[] elements) { _elements.AddRange(elements); @@ -165,7 +165,7 @@ public HtmlElement Add(params IHtmlNode[] elements) ///
/// The elements to add. /// The current instance for method chaining. - public HtmlElement Add(IEnumerable elements) + public IHtmlElement Add(IEnumerable elements) { _elements.AddRange(elements); @@ -177,7 +177,7 @@ public HtmlElement Add(IEnumerable elements) ///
/// The elements to add. /// The current instance for method chaining. - public HtmlElement AddFirst(params IHtmlNode[] elements) + public IHtmlElement AddFirst(params IHtmlNode[] elements) { _elements.InsertRange(0, elements); @@ -189,18 +189,70 @@ public HtmlElement AddFirst(params IHtmlNode[] elements) ///
/// The attributes to add. /// The current instance for method chaining. - public HtmlElement Add(params IHtmlAttribute[] attributes) + public IHtmlElement Add(params IHtmlAttribute[] attributes) { _attributes.AddRange(attributes); return this; } + /// + /// Adds one or more CSS class names to the current HTML element. + /// + /// An array of CSS class names to add. Each class name must be a non-empty string. + /// The current instance, allowing for method chaining. + public IHtmlElement AddClass(params string[] classes) + { + Class = Css.Concatenate(Class, classes); + + return this; + } + + /// + /// Removes the specified CSS class or classes from the current HTML element. + /// + /// An array of class names to remove. Each class name must be a non-empty string. + /// The current instance, allowing for method chaining. + public IHtmlElement RemoveClass(params string[] classes) + { + Class = Css.Remove(Class, classes); + + return this; + } + + /// + /// Adds one or more CSS class names to the current HTML element. + /// + /// If a specified class name already exists on the element, it will not be added + /// again. + /// An array of CSS class names to add. Each class name must be a valid CSS identifier. + /// The current instance, allowing for method chaining. + public IHtmlElement AddStyle(params string[] styles) + { + Style = Css.Concatenate(Style, styles); + + return this; + } + + /// + /// Removes the specified CSS styles from the current HTML element. + /// + /// If a specified style does not exist on the element, it will be ignored. This method + /// is chainable, enabling multiple operations to be performed on the same element in a fluent manner. + /// An array of CSS style names to remove. Each style name should correspond to a valid CSS property. + /// The current instance, allowing for method chaining. + public IHtmlElement RemoveStyle(params string[] styles) + { + Style = Css.Remove(Style, styles); + + return this; + } + /// /// Clear all elements frrom the html element. /// /// The current instance for method chaining. - public HtmlElement Clear() + public IHtmlElement Clear() { _elements.Clear(); @@ -275,11 +327,16 @@ protected void SetAttribute(string name, string value) } /// - /// Setzt den Wert eines Attributs + /// Sets an attribute without a value /// /// The attribute name. protected void SetAttribute(string name) { + if (string.IsNullOrWhiteSpace(name)) + { + return; + } + var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); if (a == null) diff --git a/src/WebExpress.WebCore/WebHtml/IHtmlElement.cs b/src/WebExpress.WebCore/WebHtml/IHtmlElement.cs new file mode 100644 index 0000000..8e3eca4 --- /dev/null +++ b/src/WebExpress.WebCore/WebHtml/IHtmlElement.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; + +namespace WebExpress.WebCore.WebHtml +{ + /// + /// Interface of an html element. + /// + public interface IHtmlElement : IHtmlNode + { + /// + /// Returns or sets the attributes. + /// + IEnumerable Attributes { get; } + + /// + /// Returns the elements. + /// + IEnumerable Elements { get; } + + /// + /// Returns or sets the id. + /// + public string Id { get; set; } + + /// + /// Returns or sets the css class. + /// + public string Class { get; set; } + + /// + /// Returns or sets the css style. + /// + public string Style { get; set; } + + /// + /// Returns or sets the role. + /// + public string Role { get; set; } + + /// + /// Returns or sets the theme. + /// + string DataTheme { get; set; } + + /// + /// Adds one or more elements to the html element. + /// + /// The elements to add. + /// The current instance for method chaining. + IHtmlElement Add(params IHtmlNode[] elements); + + /// + /// Adds one or more elements to the html element. + /// + /// The elements to add. + /// The current instance for method chaining. + IHtmlElement Add(IEnumerable elements); + + /// + /// Adds one or more elements to the beginning of the html element. + /// + /// The elements to add. + /// The current instance for method chaining. + IHtmlElement AddFirst(params IHtmlNode[] elements); + + /// + /// Adds one or more attributes to the html element. + /// + /// The attributes to add. + /// The current instance for method chaining. + IHtmlElement Add(params IHtmlAttribute[] attributes); + + /// + /// Clear all elements frrom the html element. + /// + /// The current instance for method chaining. + IHtmlElement Clear(); + + /// + /// Adds one or more CSS class names to the current HTML element. + /// + /// An array of CSS class names to add. Each class name must be a non-empty string. + /// The current instance, allowing for method chaining. + IHtmlElement AddClass(params string[] classes); + + /// + /// Removes the specified CSS class or classes from the current HTML element. + /// + /// An array of class names to remove. Each class name must be a non-empty string. + /// The current instance, allowing for method chaining. + IHtmlElement RemoveClass(params string[] classes); + + /// + /// Adds one or more CSS class names to the current HTML element. + /// + /// If a specified class name already exists on the element, it will not be added + /// again. + /// An array of CSS class names to add. Each class name must be a valid CSS identifier. + /// The current instance, allowing for method chaining. + IHtmlElement AddStyle(params string[] styles); + + /// + /// Removes the specified CSS styles from the current HTML element. + /// + /// If a specified style does not exist on the element, it will be ignored. This method + /// is chainable, enabling multiple operations to be performed on the same element in a fluent manner. + /// An array of CSS style names to remove. Each style name should correspond to a valid CSS property. + /// The current instance, allowing for method chaining. + IHtmlElement RemoveStyle(params string[] styles); + } +} From 2f693c6c529fc4f5918d5c03d03ea51c8e5f0671 Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Sat, 7 Jun 2025 09:46:29 +0200 Subject: [PATCH 12/51] feat: general improvements --- .../WebAsset/AssetContext.cs | 2 +- .../WebEndpoint/EndpointManager.cs | 32 +++++++++++++++++++ .../WebEndpoint/IEndpointContext.cs | 2 +- src/WebExpress.WebCore/WebMessage/Response.cs | 12 +++++++ src/WebExpress.WebCore/WebPage/PageContext.cs | 2 +- src/WebExpress.WebCore/WebPage/PageManager.cs | 2 +- .../WebResource/ResourceContext.cs | 2 +- .../WebResource/ResourceManager.cs | 2 +- .../WebRestAPI/RestApiContext.cs | 2 +- .../WebRestAPI/RestApiManager.cs | 5 +-- .../WebSettingPage/SettingPageManager.cs | 2 +- 11 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/WebExpress.WebCore/WebAsset/AssetContext.cs b/src/WebExpress.WebCore/WebAsset/AssetContext.cs index f8b15a0..b1b1f36 100644 --- a/src/WebExpress.WebCore/WebAsset/AssetContext.cs +++ b/src/WebExpress.WebCore/WebAsset/AssetContext.cs @@ -51,7 +51,7 @@ public class AssetContext : IAssetContext /// /// Returns the attributes associated with the page. /// - public IEnumerable Attributes => []; + public IEnumerable Attributes => []; /// /// Initializes a new instance of the class with the specified endpoint manager, parent type, context path, and path segment. diff --git a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs index a87b971..97c8d45 100644 --- a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs +++ b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs @@ -249,5 +249,37 @@ public static IRoute CreateEndpointRoute return uri; } + + /// + /// Creates instances of attributes from a collection of objects. + /// + /// A collection of objects representing the metadata of attributes. + /// An enumerable of instances created from the provided metadata. If + /// no attributes are instantiated, an empty collection is returned. + public static IEnumerable GetAttributeInstances(IEnumerable customAttributesData) + { + List attributeInstances = []; + + foreach (var attrData in customAttributesData) + { + var attributeType = attrData.AttributeType; + var constructorArgs = new List(); + + // extract constructor arguments + foreach (var arg in attrData.ConstructorArguments) + { + constructorArgs.Add(arg.Value); + } + + // instantiate the attribute using Reflection + var attributeInstance = Activator.CreateInstance(attributeType, constructorArgs.ToArray()) as Attribute; + if (attributeInstance != null) + { + attributeInstances.Add(attributeInstance); + } + } + + return attributeInstances; + } } } diff --git a/src/WebExpress.WebCore/WebEndpoint/IEndpointContext.cs b/src/WebExpress.WebCore/WebEndpoint/IEndpointContext.cs index 0a37b8f..df0d3f9 100644 --- a/src/WebExpress.WebCore/WebEndpoint/IEndpointContext.cs +++ b/src/WebExpress.WebCore/WebEndpoint/IEndpointContext.cs @@ -50,6 +50,6 @@ public interface IEndpointContext : IContext /// /// Returns the attributes associated with the page. /// - IEnumerable Attributes { get; } + IEnumerable Attributes { get; } } } diff --git a/src/WebExpress.WebCore/WebMessage/Response.cs b/src/WebExpress.WebCore/WebMessage/Response.cs index 504238d..f4eefb6 100644 --- a/src/WebExpress.WebCore/WebMessage/Response.cs +++ b/src/WebExpress.WebCore/WebMessage/Response.cs @@ -34,5 +34,17 @@ public abstract class Response protected Response() { } + + /// + /// Appends the specified content type to the existing Content-Type header value. + /// + /// The content type to append. Cannot be null or empty. + /// The current instance, allowing for method chaining. + public Response AddHeaderContentType(string contentType) + { + Header.ContentType = contentType?.Trim(); + + return this; + } } } diff --git a/src/WebExpress.WebCore/WebPage/PageContext.cs b/src/WebExpress.WebCore/WebPage/PageContext.cs index a350cd3..f4dbca2 100644 --- a/src/WebExpress.WebCore/WebPage/PageContext.cs +++ b/src/WebExpress.WebCore/WebPage/PageContext.cs @@ -59,7 +59,7 @@ public class PageContext : IPageContext /// /// Returns the attributes associated with the page. /// - public IEnumerable Attributes { get; internal set; } + public IEnumerable Attributes { get; internal set; } /// /// Returns the context path. diff --git a/src/WebExpress.WebCore/WebPage/PageManager.cs b/src/WebExpress.WebCore/WebPage/PageManager.cs index 4be1fe8..aecbf43 100644 --- a/src/WebExpress.WebCore/WebPage/PageManager.cs +++ b/src/WebExpress.WebCore/WebPage/PageManager.cs @@ -371,7 +371,7 @@ private void Register(IPluginContext pluginContext, IEnumerable x.AttributeType) + Attributes = EndpointManager.GetAttributeInstances(attributes) }; var pageItem = new PageItem(_componentHub.EndpointManager) diff --git a/src/WebExpress.WebCore/WebResource/ResourceContext.cs b/src/WebExpress.WebCore/WebResource/ResourceContext.cs index 09249e7..30231bf 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceContext.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceContext.cs @@ -57,7 +57,7 @@ public class ResourceContext : IResourceContext /// /// Returns the attributes associated with the page. /// - public IEnumerable Attributes { get; internal set; } + public IEnumerable Attributes { get; internal set; } /// /// Initializes a new instance of the class with the specified endpoint manager, parent type, context path, and path segment. diff --git a/src/WebExpress.WebCore/WebResource/ResourceManager.cs b/src/WebExpress.WebCore/WebResource/ResourceManager.cs index d476734..c6a899b 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceManager.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceManager.cs @@ -178,7 +178,7 @@ private void Register(IPluginContext pluginContext, IEnumerable x.AttributeType) + Attributes = EndpointManager.GetAttributeInstances(attributes) }; var resourceItem = new ResourceItem(_componentHub.ResourceManager) diff --git a/src/WebExpress.WebCore/WebRestAPI/RestApiContext.cs b/src/WebExpress.WebCore/WebRestAPI/RestApiContext.cs index 3dc09ce..9a21399 100644 --- a/src/WebExpress.WebCore/WebRestAPI/RestApiContext.cs +++ b/src/WebExpress.WebCore/WebRestAPI/RestApiContext.cs @@ -57,7 +57,7 @@ public class RestApiContext : IRestApiContext /// /// Returns the attributes associated with the page. /// - public IEnumerable Attributes { get; internal set; } + public IEnumerable Attributes { get; internal set; } /// /// Returns the context path. diff --git a/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs b/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs index ac85dc6..22446e2 100644 --- a/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs +++ b/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs @@ -91,7 +91,8 @@ private RestApiManager(IComponentHub componentHub, IHttpServerContext httpServer return new ResponseOK { Content = content - }; + } + .AddHeaderContentType("application/json"); } return new ResponseOK(); @@ -387,7 +388,7 @@ private void Register(IPluginContext pluginContext, IEnumerable x.AttributeType), + Attributes = EndpointManager.GetAttributeInstances(attributes), Version = version, Methods = methods.Distinct() }; diff --git a/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs b/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs index 62164ed..440dd18 100644 --- a/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs +++ b/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs @@ -536,7 +536,7 @@ private void RegisterPage(IPluginContext pluginContext, IEnumerable x.AttributeType), + Attributes = EndpointManager.GetAttributeInstances(attributes), PageTitle = title, Scopes = scopes, SettingGroup = _groupDictionary.GetSettingGroup(applicationContext, group), From 5966e5d85c9fb65ea96b8aa342a4ff3478e6737f Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Tue, 10 Jun 2025 20:22:00 +0200 Subject: [PATCH 13/51] feat: general improvements --- src/WebExpress.WebCore.Test/TestVisualTree.cs | 14 +++++++ src/WebExpress.WebCore/HttpServer.cs | 4 +- src/WebExpress.WebCore/WebHtml/HtmlElement.cs | 9 +++-- .../WebHtml/HtmlElementFormForm.cs | 10 +++++ .../WebHtml/HtmlElementTextContentDiv.cs | 10 +++++ .../WebHtml/HtmlElementTextContentP.cs | 13 ++++++- .../WebHtml/IHtmlElement.cs | 22 +++++++++++ .../WebMessage/ResponseCreated.cs | 19 ++++++++++ .../WebMessage/ResponseMovedPermanently.cs | 14 ++++++- .../WebMessage/ResponseMovedTemporarily.cs | 26 +++++++++++++ .../WebMessage/ResponseNoContent.cs | 22 +++++++++++ .../ResponseRedirectPermanentlyMoved.cs | 22 ----------- .../ResponseRedirectTemporarilyMoved.cs | 25 ------------- .../WebMessage/ResponseUnprocessableEntity.cs | 37 +++++++++++++++++++ src/WebExpress.WebCore/WebPage/IVisualTree.cs | 8 ++++ src/WebExpress.WebCore/WebPage/PageManager.cs | 6 +-- src/WebExpress.WebCore/WebPage/VisualTree.cs | 14 +++++++ .../WebRestAPI/CrudMethod.cs | 5 +++ .../WebRestAPI/RestApiManager.cs | 4 ++ 19 files changed, 224 insertions(+), 60 deletions(-) create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseCreated.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseMovedTemporarily.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseNoContent.cs delete mode 100644 src/WebExpress.WebCore/WebMessage/ResponseRedirectPermanentlyMoved.cs delete mode 100644 src/WebExpress.WebCore/WebMessage/ResponseRedirectTemporarilyMoved.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseUnprocessableEntity.cs diff --git a/src/WebExpress.WebCore.Test/TestVisualTree.cs b/src/WebExpress.WebCore.Test/TestVisualTree.cs index 31f2d17..4509ae9 100644 --- a/src/WebExpress.WebCore.Test/TestVisualTree.cs +++ b/src/WebExpress.WebCore.Test/TestVisualTree.cs @@ -1,5 +1,6 @@ using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebHtml; +using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPage; namespace WebExpress.WebCore.Test @@ -112,5 +113,18 @@ public virtual IHtmlNode Render(IVisualTreeContext context) return html; } + + /// + /// Retrieves a response based on the provided visual tree context. + /// + /// The visual tree context used to generate the response. Cannot be null. + /// A object representing the result of the operation. + public Response GetResponse(IVisualTreeContext context) + { + return new ResponseOK() + { + Content = Render(context) + }; + } } } diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index a0c1a8d..4e77b39 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -310,11 +310,11 @@ private Response HandleClient(HttpContext context) { if (ex.Permanet) { - response = new ResponseRedirectPermanentlyMoved(ex.Uri); + response = new ResponseMovedPermanently(ex.Uri); } else { - response = new ResponseRedirectTemporarilyMoved(ex.Uri); + response = new ResponseMovedTemporarily(ex.Uri); } } catch (Exception ex) diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs index c2d119a..2b7609f 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs @@ -494,7 +494,7 @@ protected virtual void ToPostString(StringBuilder builder, int deep, bool nl = t /// /// The attribute name. /// The current instance for method chaining. - public HtmlElement AddUserAttribute(string name) + public IHtmlElement AddUserAttribute(string name) { SetAttribute(name); @@ -507,7 +507,7 @@ public HtmlElement AddUserAttribute(string name) /// The attribute name. /// The value of the attribute. /// The current instance for method chaining. - public HtmlElement AddUserAttribute(string name, string value) + public IHtmlElement AddUserAttribute(string name, string value) { SetAttribute(name, value); @@ -538,9 +538,12 @@ public bool HasUserAttribute(string name) /// Removes an user-defined attribute. /// /// The attribute name. - protected void RemoveUserAttribute(string name) + /// The current instance for method chaining. + public IHtmlElement RemoveUserAttribute(string name) { RemoveAttribute(name); + + return this; } /// diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementFormForm.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementFormForm.cs index 54c66ce..bcfe194 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementFormForm.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementFormForm.cs @@ -95,6 +95,16 @@ public HtmlElementFormForm(params IHtmlNode[] nodes) Add(nodes); } + /// + /// Initializes a new instance of the class. + /// + /// The content of the html element. + public HtmlElementFormForm(IEnumerable nodes) + : this() + { + Add(nodes); + } + /// /// Convert to a string using a StringBuilder. /// diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentDiv.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentDiv.cs index fbe6fc0..e142b58 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentDiv.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentDiv.cs @@ -30,5 +30,15 @@ public HtmlElementTextContentDiv(params IHtmlNode[] nodes) { Add(nodes); } + + /// + /// Initializes a new instance of the class. + /// + /// The content of the html element. + public HtmlElementTextContentDiv(IEnumerable nodes) + : this() + { + Add(nodes); + } } } diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentP.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentP.cs index 2f55435..a1f59d6 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentP.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentP.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using System.Text; namespace WebExpress.WebCore.WebHtml @@ -46,6 +47,16 @@ public HtmlElementTextContentP(params IHtmlNode[] nodes) Add(nodes); } + /// + /// Initializes a new instance of the class. + /// + /// The content of the html element. + public HtmlElementTextContentP(IEnumerable nodes) + : this() + { + Add(nodes); + } + /// /// Convert to a string using a StringBuilder. /// diff --git a/src/WebExpress.WebCore/WebHtml/IHtmlElement.cs b/src/WebExpress.WebCore/WebHtml/IHtmlElement.cs index 8e3eca4..697c404 100644 --- a/src/WebExpress.WebCore/WebHtml/IHtmlElement.cs +++ b/src/WebExpress.WebCore/WebHtml/IHtmlElement.cs @@ -107,5 +107,27 @@ public interface IHtmlElement : IHtmlNode /// An array of CSS style names to remove. Each style name should correspond to a valid CSS property. /// The current instance, allowing for method chaining. IHtmlElement RemoveStyle(params string[] styles); + + /// + /// Sets the valueless user-defined attribute. + /// + /// The attribute name. + /// The current instance for method chaining. + IHtmlElement AddUserAttribute(string name); + + /// + /// Sets the value of an user-defined attribute. + /// + /// The attribute name. + /// The value of the attribute. + /// The current instance for method chaining. + IHtmlElement AddUserAttribute(string name, string value); + + /// + /// Removes an user-defined attribute. + /// + /// The attribute name. + /// The current instance for method chaining. + IHtmlElement RemoveUserAttribute(string name); } } diff --git a/src/WebExpress.WebCore/WebMessage/ResponseCreated.cs b/src/WebExpress.WebCore/WebMessage/ResponseCreated.cs new file mode 100644 index 0000000..283228d --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/ResponseCreated.cs @@ -0,0 +1,19 @@ +using WebExpress.WebCore.WebAttribute; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Represents a response with a 201 Created status code, indicating that a resource has been successfully created. + /// + [StatusCode(201)] + public class ResponseCreated : Response + { + /// + /// Initializes a new instance of the class. + /// + public ResponseCreated() + { + Reason = "Created"; + } + } +} diff --git a/src/WebExpress.WebCore/WebMessage/ResponseMovedPermanently.cs b/src/WebExpress.WebCore/WebMessage/ResponseMovedPermanently.cs index a5ac961..30aadce 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseMovedPermanently.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseMovedPermanently.cs @@ -1,5 +1,6 @@ using WebExpress.WebCore.WebAttribute; using WebExpress.WebCore.WebStatusPage; +using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebMessage { @@ -13,9 +14,18 @@ public class ResponseMovedPermanently : Response /// Initializes a new instance of the class. /// public ResponseMovedPermanently() - : this(null) { + } + /// + /// Initializes a new instance of the class with the specified location. + /// + /// The parameter specifies the new location of the resource. + /// The URI to which the resource has been moved permanently. This value cannot be . + public ResponseMovedPermanently(IUri location) + { + Reason = "moved permanently"; + Header.Location = location?.ToString(); } /// @@ -25,7 +35,7 @@ public ResponseMovedPermanently() public ResponseMovedPermanently(StatusMessage message) { var content = message?.Message ?? "404301 - Moved Permanently"; - Reason = "Moved Permanently"; + Reason = "moved permanently"; Header.ContentType = "text/html"; Header.ContentLength = content.Length; diff --git a/src/WebExpress.WebCore/WebMessage/ResponseMovedTemporarily.cs b/src/WebExpress.WebCore/WebMessage/ResponseMovedTemporarily.cs new file mode 100644 index 0000000..febbc4d --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/ResponseMovedTemporarily.cs @@ -0,0 +1,26 @@ +using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebUri; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Represents a response according to RFC 2616 Section 6. + /// + [StatusCode(302)] + public class ResponseMovedTemporarily : Response + { + /// + /// Initializes a new instance of the class, representing an HTTP response indicating + /// that the requested resource has been temporarily moved to a new location. + /// + /// This response typically corresponds to the HTTP 302 status code, indicating that the + /// resource is temporarily located at a different URI. The parameter is used to set + /// the "Location" header in the response. + /// The URI of the new temporary location for the requested resource. + public ResponseMovedTemporarily(IUri location) + { + Reason = "temporarily moved"; + Header.Location = location?.ToString(); + } + } +} diff --git a/src/WebExpress.WebCore/WebMessage/ResponseNoContent.cs b/src/WebExpress.WebCore/WebMessage/ResponseNoContent.cs new file mode 100644 index 0000000..28cd064 --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/ResponseNoContent.cs @@ -0,0 +1,22 @@ +using WebExpress.WebCore.WebAttribute; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Represents an HTTP response with a 204 No Content status code. + /// + /// This response indicates that the request was successfully processed, but no content is + /// returned in the response body. Use this class to represent operations where no additional data needs to be + /// conveyed to the client. + [StatusCode(204)] + public class ResponseNoContent : Response + { + /// + /// Initializes a new instance of the class. + /// + public ResponseNoContent() + { + Reason = "NoContent"; + } + } +} diff --git a/src/WebExpress.WebCore/WebMessage/ResponseRedirectPermanentlyMoved.cs b/src/WebExpress.WebCore/WebMessage/ResponseRedirectPermanentlyMoved.cs deleted file mode 100644 index fe7c7fc..0000000 --- a/src/WebExpress.WebCore/WebMessage/ResponseRedirectPermanentlyMoved.cs +++ /dev/null @@ -1,22 +0,0 @@ -using WebExpress.WebCore.WebAttribute; -using WebExpress.WebCore.WebUri; - -namespace WebExpress.WebCore.WebMessage -{ - /// - /// Represents a response according to RFC 2616 Section 6. - /// - [StatusCode(301)] - public class ResponseRedirectPermanentlyMoved : Response - { - /// - /// Initializes a new instance of the class. - /// - public ResponseRedirectPermanentlyMoved(IUri location) - { - Reason = "permanently moved"; - - Header.Location = location?.ToString(); - } - } -} diff --git a/src/WebExpress.WebCore/WebMessage/ResponseRedirectTemporarilyMoved.cs b/src/WebExpress.WebCore/WebMessage/ResponseRedirectTemporarilyMoved.cs deleted file mode 100644 index a9f3c24..0000000 --- a/src/WebExpress.WebCore/WebMessage/ResponseRedirectTemporarilyMoved.cs +++ /dev/null @@ -1,25 +0,0 @@ -using WebExpress.WebCore.WebAttribute; -using WebExpress.WebCore.WebUri; - -namespace WebExpress.WebCore.WebMessage -{ - /// - /// Represents a response according to RFC 2616 Section 6. - /// - [StatusCode(302)] - public class ResponseRedirectTemporarilyMoved : Response - { - /// - /// Initializes a new instance of the class. - /// - public ResponseRedirectTemporarilyMoved(IUri location) - { - Reason = "temporarily moved"; - //Content = ""; - - //HeaderFields.ContentType = "text/html"; - //HeaderFields.ContentLength = Content.ToString().Length; - Header.Location = location?.ToString(); - } - } -} diff --git a/src/WebExpress.WebCore/WebMessage/ResponseUnprocessableEntity.cs b/src/WebExpress.WebCore/WebMessage/ResponseUnprocessableEntity.cs new file mode 100644 index 0000000..58df540 --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/ResponseUnprocessableEntity.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebStatusPage; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Represents a response with a 422 Unprocessable Entity status code. + /// + /// This response is typically used to indicate that the server understands the content type of + /// the request entity, and the syntax of the request entity is correct, but it was unable to process the contained + /// instructions. + [StatusCode(422)] + public class ResponseUnprocessableEntity : Response + { + /// + /// Initializes a new instance of the class. + /// + public ResponseUnprocessableEntity() + : this(null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The user defined status message or null. + public ResponseUnprocessableEntity(StatusMessage message) + { + var content = message?.Message ?? "404422 - Unprocessable Entity"; + Reason = "unprocessable entity"; + + Header.ContentType = "text/html"; + Header.ContentLength = content.Length; + Content = content; + } + } +} diff --git a/src/WebExpress.WebCore/WebPage/IVisualTree.cs b/src/WebExpress.WebCore/WebPage/IVisualTree.cs index 72e5d49..db9810b 100644 --- a/src/WebExpress.WebCore/WebPage/IVisualTree.cs +++ b/src/WebExpress.WebCore/WebPage/IVisualTree.cs @@ -1,4 +1,5 @@ using WebExpress.WebCore.WebHtml; +using WebExpress.WebCore.WebMessage; namespace WebExpress.WebCore.WebPage { @@ -13,5 +14,12 @@ public interface IVisualTree /// The context for rendering the visual tree. /// The page as html. IHtmlNode Render(IVisualTreeContext context); + + /// + /// Retrieves a response based on the provided visual tree context. + /// + /// The visual tree context used to generate the response. Cannot be null. + /// A object representing the result of the operation. + Response GetResponse(IVisualTreeContext context); } } diff --git a/src/WebExpress.WebCore/WebPage/PageManager.cs b/src/WebExpress.WebCore/WebPage/PageManager.cs index aecbf43..8664878 100644 --- a/src/WebExpress.WebCore/WebPage/PageManager.cs +++ b/src/WebExpress.WebCore/WebPage/PageManager.cs @@ -10,7 +10,6 @@ using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebCondition; using WebExpress.WebCore.WebEndpoint; -using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPage.Model; using WebExpress.WebCore.WebPlugin; using WebExpress.WebCore.WebScope; @@ -130,10 +129,7 @@ private PageManager(IComponentHub componentHub, IHttpServerContext httpServerCon // execute the cached delegate del.DynamicInvoke(renderContext, visualTreeInstance); - return new ResponseOK() - { - Content = visualTreeInstance.Render(visualTreeContext) - }; + return visualTreeInstance.GetResponse(visualTreeContext); } }; diff --git a/src/WebExpress.WebCore/WebPage/VisualTree.cs b/src/WebExpress.WebCore/WebPage/VisualTree.cs index 8daed66..948e150 100644 --- a/src/WebExpress.WebCore/WebPage/VisualTree.cs +++ b/src/WebExpress.WebCore/WebPage/VisualTree.cs @@ -2,6 +2,7 @@ using System.Linq; using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebHtml; +using WebExpress.WebCore.WebMessage; namespace WebExpress.WebCore.WebPage { @@ -128,5 +129,18 @@ public virtual IHtmlNode Render(IVisualTreeContext context) return html; } + + /// + /// Retrieves a response based on the provided visual tree context. + /// + /// The visual tree context used to generate the response. Cannot be null. + /// A object representing the result of the operation. + public Response GetResponse(IVisualTreeContext context) + { + return new ResponseOK() + { + Content = Render(context) + }; + } } } diff --git a/src/WebExpress.WebCore/WebRestAPI/CrudMethod.cs b/src/WebExpress.WebCore/WebRestAPI/CrudMethod.cs index 33f66be..a41d357 100644 --- a/src/WebExpress.WebCore/WebRestAPI/CrudMethod.cs +++ b/src/WebExpress.WebCore/WebRestAPI/CrudMethod.cs @@ -22,6 +22,11 @@ public enum CrudMethod /// PATCH = RequestMethod.PATCH, + /// + /// Represents the HTTP PATCH method. + /// + PUT = RequestMethod.PUT, + /// /// Represents the HTTP DELETE method. /// diff --git a/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs b/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs index 22446e2..dc88d72 100644 --- a/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs +++ b/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs @@ -99,6 +99,10 @@ private RestApiManager(IComponentHub componentHub, IHttpServerContext httpServer case RequestMethod.PATCH: restApi.UpdateData(request); + return new ResponseOK(); + case RequestMethod.PUT: + restApi.UpdateData(request); + return new ResponseOK(); case RequestMethod.DELETE: restApi.DeleteData(request); From 7b0837285b2157b12d1e51cd1c6891b07a0ebcce Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Wed, 11 Jun 2025 20:36:14 +0200 Subject: [PATCH 14/51] fix: base header --- .../Html/UnitTestHtmlElementMetadataBase.cs | 45 +++++++++++++++++++ .../Html/UnitTestHtmlElementRootHtml.cs | 28 ++++++++++++ .../WebHtml/HtmlElementMetadataBase.cs | 7 +-- .../WebHtml/HtmlElementMetadataHead.cs | 2 +- .../WebHtml/HtmlElementSectionBody.cs | 17 ++++--- .../WebMessage/ResponseMovedTemporarily.cs | 7 +++ 6 files changed, 90 insertions(+), 16 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataBase.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementRootHtml.cs diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataBase.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataBase.cs new file mode 100644 index 0000000..8a80013 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataBase.cs @@ -0,0 +1,45 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMetadataBase class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMetadataBase + { + /// + /// Tests the constructor of the HtmlElementMetadataBase class. + /// + [Fact] + public void Constructor() + { + // preconditions + var url = "https://example.com"; + + // test execution + var element = new HtmlElementMetadataBase(url); + + // validation + Assert.Equal("https://example.com", element.Href); + } + + /// + /// Sets the URI for the HTML element and validates its format. + /// + [Theory] + [InlineData(null, "")] + [InlineData("https://example.com", "https://example.com")] + public void Href(string uri, string expected) + { + // preconditions + var element = new HtmlElementMetadataBase(); + + // test execution + element.Href = uri; + + // validation + Assert.Equal(expected, element.Href); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementRootHtml.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementRootHtml.cs new file mode 100644 index 0000000..d5115e3 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementRootHtml.cs @@ -0,0 +1,28 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementRootHtml class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementRootHtml + { + /// + /// Tests the Head.Base of the HtmlElementMetadataBase class. + /// + [Fact] + public void HeadBase() + { + // preconditions + var url = "https://example.com"; + + // test execution + var element = new HtmlElementRootHtml(); + element.Head.Base = url; + + // validation + Assert.Contains($"", element.ToString()); + } + } +} diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementMetadataBase.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementMetadataBase.cs index e7afec7..7e7aa47 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementMetadataBase.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementMetadataBase.cs @@ -13,12 +13,7 @@ public string Href get => GetAttribute("href"); set { - var url = value; - - if (!string.IsNullOrWhiteSpace(url) && !url.EndsWith("/")) - { - url += url + "/"; - } + var url = value?.TrimEnd(); SetAttribute("href", url); } diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementMetadataHead.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementMetadataHead.cs index c9341f3..c57f48d 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementMetadataHead.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementMetadataHead.cs @@ -139,7 +139,7 @@ public override void ToString(StringBuilder builder, int deep) if (!string.IsNullOrWhiteSpace(Base)) { - //ElementBase.ToString(builder, deep + 1); + _elementBase.ToString(builder, deep + 1); } foreach (var v in _elementFavicons) diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementSectionBody.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementSectionBody.cs index 08ecf45..69f1bb6 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementSectionBody.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementSectionBody.cs @@ -17,24 +17,23 @@ public class HtmlElementSectionBody : HtmlElement, IHtmlElementSection /// /// Returns or sets the script elements. /// - public List Scripts { get; set; } + public List Scripts { get; set; } = []; /// /// Returns or sets the text/javascript. /// public List ScriptLinks { - get => (from x in ElementScriptLinks select x.Src).ToList(); + get => [.. ElementScriptLinks.Select(x => x.Src)]; set { ElementScriptLinks.Clear(); - ElementScriptLinks.AddRange(from x in value - select new HtmlElementScriptingScript() - { - Language = "javascript", - Src = x, - Type = "text/javascript" - }); + ElementScriptLinks.AddRange(value.Select(x => new HtmlElementScriptingScript() + { + Language = "javascript", + Src = x, + Type = "text/javascript" + })); } } diff --git a/src/WebExpress.WebCore/WebMessage/ResponseMovedTemporarily.cs b/src/WebExpress.WebCore/WebMessage/ResponseMovedTemporarily.cs index febbc4d..232cd0e 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseMovedTemporarily.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseMovedTemporarily.cs @@ -9,6 +9,13 @@ namespace WebExpress.WebCore.WebMessage [StatusCode(302)] public class ResponseMovedTemporarily : Response { + /// + /// Initializes a new instance of the class. + /// + public ResponseMovedTemporarily() + { + } + /// /// Initializes a new instance of the class, representing an HTTP response indicating /// that the requested resource has been temporarily moved to a new location. From 4ce98a7ba9ee5459ca4c04f94151c32a26f88afb Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Thu, 12 Jun 2025 08:16:34 +0200 Subject: [PATCH 15/51] feat: improved the uri --- .../Uri/UnitTestUri.cs | 22 +++++++++++++++++++ src/WebExpress.WebCore/WebUri/IUri.cs | 6 +++++ src/WebExpress.WebCore/WebUri/UriEndpoint.cs | 9 ++++++++ 3 files changed, 37 insertions(+) diff --git a/src/WebExpress.WebCore.Test/Uri/UnitTestUri.cs b/src/WebExpress.WebCore.Test/Uri/UnitTestUri.cs index 6417065..cb5e36a 100644 --- a/src/WebExpress.WebCore.Test/Uri/UnitTestUri.cs +++ b/src/WebExpress.WebCore.Test/Uri/UnitTestUri.cs @@ -187,5 +187,27 @@ public void BasePath(string uri, string baseUri, string expected) Assert.Equal(uri, resourceUri.ToString()); Assert.Equal(expected, resourceUri.BasePath.ToString()); } + + /// + /// Test the setfragment method. + /// + [Theory] + [InlineData("http://user@example.com/x", null, "http://user@example.com/x")] + [InlineData("http://user@example.com/x", "", "http://user@example.com/x")] + [InlineData("http://user@example.com/x?a=1&b=2", "myfragment", "http://user@example.com/x?a=1&b=2#myfragment")] + [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 + var resourceUri = (IUri)new UriEndpoint(uri) + { + }; + + // test execution + resourceUri = resourceUri.SetFragment(fragment); + + // validation + Assert.Equal(expected, resourceUri.ToString()); + } } } diff --git a/src/WebExpress.WebCore/WebUri/IUri.cs b/src/WebExpress.WebCore/WebUri/IUri.cs index c8ecfa0..a92fd57 100644 --- a/src/WebExpress.WebCore/WebUri/IUri.cs +++ b/src/WebExpress.WebCore/WebUri/IUri.cs @@ -118,5 +118,11 @@ public interface IUri /// The Uri to be checked. /// true if part of the uri, false otherwise. bool StartsWith(IUri uri); + + /// + /// Sets the fragment component of the URI. + /// + /// A new IUri instance with the updated fragment. The original URI remains unchanged. + IUri SetFragment(string fragment); } } diff --git a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs index 6348439..35f2c23 100644 --- a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs +++ b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs @@ -458,6 +458,15 @@ public static IUri Combine(IUri uri, params string[] uris) return copy; } + /// + /// Sets the fragment component of the URI. + /// + /// A new IUri instance with the updated fragment. The original URI remains unchanged. + public IUri SetFragment(string fragment) + { + return new UriEndpoint(Scheme, Authority, fragment, Query, PathSegments); + } + /// /// Converts a resource uri to a normal uri. /// From ca6eed5bdd90292df583d928bf857bddb7f11961 Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Sat, 21 Jun 2025 00:42:52 +0200 Subject: [PATCH 16/51] feat: introduced typed API responses for better result handling --- .../WWW/Api/1/TestRestApiA.cs | 23 ++-- .../WWW/Api/2/TestRestApiB.cs | 23 ++-- .../WWW/Api/3/TestRestApiC.cs | 23 ++-- src/WebExpress.WebCore/WebRestAPI/IRestApi.cs | 13 +- src/WebExpress.WebCore/WebRestAPI/RestApi.cs | 22 ++-- .../WebRestAPI/RestApiManager.cs | 34 +---- .../WebRestApi/IRestApiPaginationInfo.cs | 28 +++++ .../WebRestApi/IRestApiResult.cs | 16 +++ .../WebRestApi/RestApiError.cs | 47 +++++++ .../WebRestApi/RestApiPaginationInfo.cs | 35 ++++++ .../WebRestApi/RestApiResult.cs | 117 ++++++++++++++++++ 11 files changed, 317 insertions(+), 64 deletions(-) create mode 100644 src/WebExpress.WebCore/WebRestApi/IRestApiPaginationInfo.cs create mode 100644 src/WebExpress.WebCore/WebRestApi/IRestApiResult.cs create mode 100644 src/WebExpress.WebCore/WebRestApi/RestApiError.cs create mode 100644 src/WebExpress.WebCore/WebRestApi/RestApiPaginationInfo.cs create mode 100644 src/WebExpress.WebCore/WebRestApi/RestApiResult.cs diff --git a/src/WebExpress.WebCore.Test/WWW/Api/1/TestRestApiA.cs b/src/WebExpress.WebCore.Test/WWW/Api/1/TestRestApiA.cs index 3c4f47c..769e67a 100644 --- a/src/WebExpress.WebCore.Test/WWW/Api/1/TestRestApiA.cs +++ b/src/WebExpress.WebCore.Test/WWW/Api/1/TestRestApiA.cs @@ -1,5 +1,7 @@ using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebRestApi; +using WebExpress.WebCore.WebStatusPage; namespace WebExpress.WebCore.Test.WWW.Api._1 { @@ -27,45 +29,52 @@ public TestRestApiA(IRestApiContext restApiContext) /// Creates data. /// /// The request. - public void CreateData(WebMessage.Request request) + /// The response containing the result of the operation. + public Response CreateData(Request request) { - + return new ResponseBadRequest(new StatusMessage("Not implemented.")); } /// /// Gets data. /// /// The request. - /// The data. - public object GetData(WebMessage.Request request) + /// The response containing the result of the operation. + public Response GetData(Request request) { - return null; + return new ResponseBadRequest(new StatusMessage("Not implemented.")); } /// /// Updates data. /// /// The request. - public void UpdateData(WebMessage.Request request) + /// The response containing the result of the operation. + public Response UpdateData(Request request) { // test the request if (request == null) { throw new ArgumentNullException(nameof(request), "Parameter cannot be null or empty."); } + + return new ResponseBadRequest(new StatusMessage("Not implemented.")); } /// /// Deletes data. /// /// The request. - public void DeleteData(WebMessage.Request request) + /// The response containing the result of the operation. + public Response DeleteData(Request request) { // test the request if (request == null) { throw new ArgumentNullException(nameof(request), "Parameter cannot be null or empty."); } + + return new ResponseBadRequest(new StatusMessage("Not implemented.")); } /// diff --git a/src/WebExpress.WebCore.Test/WWW/Api/2/TestRestApiB.cs b/src/WebExpress.WebCore.Test/WWW/Api/2/TestRestApiB.cs index b4d11a8..d243db1 100644 --- a/src/WebExpress.WebCore.Test/WWW/Api/2/TestRestApiB.cs +++ b/src/WebExpress.WebCore.Test/WWW/Api/2/TestRestApiB.cs @@ -1,5 +1,7 @@ using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebRestApi; +using WebExpress.WebCore.WebStatusPage; namespace WebExpress.WebCore.Test.WWW.Api._2 { @@ -26,45 +28,52 @@ public TestRestApiB(IRestApiContext restApiContext) /// Creates data. /// /// The request. - public void CreateData(WebMessage.Request request) + /// The response containing the result of the operation. + public Response CreateData(Request request) { - + return new ResponseBadRequest(new StatusMessage("Not implemented.")); } /// /// Gets data. /// /// The request. - /// The data. - public object GetData(WebMessage.Request request) + /// The response containing the result of the operation. + public Response GetData(Request request) { - return null; + return new ResponseBadRequest(new StatusMessage("Not implemented.")); } /// /// Updates data. /// /// The request. - public void UpdateData(WebMessage.Request request) + /// The response containing the result of the operation. + public Response UpdateData(Request request) { // test the request if (request == null) { throw new ArgumentNullException(nameof(request), "Parameter cannot be null or empty."); } + + return new ResponseBadRequest(new StatusMessage("Not implemented.")); } /// /// Deletes data. /// /// The request. - public void DeleteData(WebMessage.Request request) + /// The response containing the result of the operation. + public Response DeleteData(Request request) { // test the request if (request == null) { throw new ArgumentNullException(nameof(request), "Parameter cannot be null or empty."); } + + 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 920d4de..9b499f1 100644 --- a/src/WebExpress.WebCore.Test/WWW/Api/3/TestRestApiC.cs +++ b/src/WebExpress.WebCore.Test/WWW/Api/3/TestRestApiC.cs @@ -1,6 +1,8 @@ using WebExpress.WebCore.WebAttribute; using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebRestApi; +using WebExpress.WebCore.WebStatusPage; namespace WebExpress.WebCore.Test.WWW.Api._3 { @@ -35,45 +37,52 @@ public TestRestApiC(IComponentHub componentHub, IRestApiContext restApiContext) /// Creates data. /// /// The request. - public override void CreateData(WebMessage.Request request) + /// The response containing the result of the operation. + public override Response CreateData(Request request) { - + return new ResponseBadRequest(new StatusMessage("Not implemented.")); } /// /// Gets data. /// /// The request. - /// The data. - public override object GetData(WebMessage.Request request) + /// The response containing the result of the operation. + public override Response GetData(Request request) { - return null; + return new ResponseBadRequest(new StatusMessage("Not implemented.")); } /// /// Updates data. /// /// The request. - public override void UpdateData(WebMessage.Request request) + /// The response containing the result of the operation. + public override Response UpdateData(Request request) { // test the request if (request == null) { throw new ArgumentNullException(nameof(request), "Parameter cannot be null or empty."); } + + return new ResponseBadRequest(new StatusMessage("Not implemented.")); } /// /// Deletes data. /// /// The request. - public override void DeleteData(WebMessage.Request request) + /// The response containing the result of the operation. + public override Response DeleteData(Request request) { // test the request if (request == null) { throw new ArgumentNullException(nameof(request), "Parameter cannot be null or empty."); } + + return new ResponseBadRequest(new StatusMessage("Not implemented.")); } /// diff --git a/src/WebExpress.WebCore/WebRestAPI/IRestApi.cs b/src/WebExpress.WebCore/WebRestAPI/IRestApi.cs index df36426..bc3368b 100644 --- a/src/WebExpress.WebCore/WebRestAPI/IRestApi.cs +++ b/src/WebExpress.WebCore/WebRestAPI/IRestApi.cs @@ -12,25 +12,28 @@ public interface IRestApi : IEndpoint /// Creates data. /// /// The request. - void CreateData(Request request); + /// The response containing the result of the operation. + Response CreateData(Request request); /// /// Gets data. /// /// The request. - /// The data. - object GetData(Request request); + /// The response containing the result of the operation. + Response GetData(Request request); /// /// Updates data. /// /// The request. - void UpdateData(Request request); + /// The response containing the result of the operation. + Response UpdateData(Request request); /// /// Deletes data. /// /// The request. - void DeleteData(Request request); + /// The response containing the result of the operation. + Response DeleteData(Request request); } } diff --git a/src/WebExpress.WebCore/WebRestAPI/RestApi.cs b/src/WebExpress.WebCore/WebRestAPI/RestApi.cs index eec24c4..647894f 100644 --- a/src/WebExpress.WebCore/WebRestAPI/RestApi.cs +++ b/src/WebExpress.WebCore/WebRestAPI/RestApi.cs @@ -1,4 +1,5 @@ using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebStatusPage; namespace WebExpress.WebCore.WebRestApi { @@ -25,36 +26,39 @@ public RestApi(IRestApiContext restApiContext) /// Creates data. /// /// The request. - public virtual void CreateData(Request 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 data. - public virtual object GetData(Request request) + /// The response containing the result of the operation. + public virtual Response GetData(Request request) { - return null; + return new ResponseBadRequest(new StatusMessage("Not implemented.")); } /// /// Updates data. /// /// The request. - public virtual void UpdateData(Request 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. - public virtual void DeleteData(Request request) + /// The response containing the result of the operation. + public virtual Response DeleteData(Request request) { - + return new ResponseBadRequest(new StatusMessage("Not implemented.")); } /// diff --git a/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs b/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs index dc88d72..ed0e7f6 100644 --- a/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs +++ b/src/WebExpress.WebCore/WebRestAPI/RestApiManager.cs @@ -2,8 +2,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Text; -using System.Text.Json; using System.Text.RegularExpressions; using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebApplication; @@ -26,7 +24,6 @@ public partial class RestApiManager : IRestApiManager private readonly IComponentHub _componentHub; private readonly IHttpServerContext _httpServerContext; private readonly RestApiDictionary _dictionary = []; - private readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; [GeneratedRegex(@"\.(?:_|V|v)(\d+)\.")] private static partial Regex ApiVersionRegex(); @@ -78,36 +75,15 @@ private RestApiManager(IComponentHub componentHub, IHttpServerContext httpServer switch (request.Method) { case RequestMethod.POST: - restApi.CreateData(request); - - return new ResponseOK(); + return restApi.CreateData(request) ?? new ResponseOK(); case RequestMethod.GET: - var data = restApi.GetData(request); - if (data != null) - { - var jsonData = JsonSerializer.Serialize(data, _jsonOptions); - var content = Encoding.UTF8.GetBytes(jsonData); - - return new ResponseOK - { - Content = content - } - .AddHeaderContentType("application/json"); - } - - return new ResponseOK(); + return restApi.GetData(request) ?? new ResponseOK(); case RequestMethod.PATCH: - restApi.UpdateData(request); - - return new ResponseOK(); + return restApi.UpdateData(request) ?? new ResponseOK(); case RequestMethod.PUT: - restApi.UpdateData(request); - - return new ResponseOK(); + return restApi.UpdateData(request) ?? new ResponseOK(); case RequestMethod.DELETE: - restApi.DeleteData(request); - - return new ResponseOK(); + return restApi.DeleteData(request) ?? new ResponseOK(); } } diff --git a/src/WebExpress.WebCore/WebRestApi/IRestApiPaginationInfo.cs b/src/WebExpress.WebCore/WebRestApi/IRestApiPaginationInfo.cs new file mode 100644 index 0000000..a952602 --- /dev/null +++ b/src/WebExpress.WebCore/WebRestApi/IRestApiPaginationInfo.cs @@ -0,0 +1,28 @@ +namespace WebExpress.WebCore.WebRestApi +{ + /// + /// Represents pagination information for a REST API CRUD operation. + /// + public interface IRestApiPaginationInfo + { + /// + /// Returns the current page number in a paginated result set. + /// + int PageNumber { get; } + + /// + /// Returns the number of items to display per page in a paginated list. + /// + int PageSize { get; } + + /// + /// Returns the total count of items. + /// + int TotalCount { get; } + + /// + /// Returns the total number of pages based on the total item count and the page size. + /// + int TotalPages { get; } + } +} diff --git a/src/WebExpress.WebCore/WebRestApi/IRestApiResult.cs b/src/WebExpress.WebCore/WebRestApi/IRestApiResult.cs new file mode 100644 index 0000000..2b80c6b --- /dev/null +++ b/src/WebExpress.WebCore/WebRestApi/IRestApiResult.cs @@ -0,0 +1,16 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.WebRestApi +{ + /// + /// Represents the result of a CRUD operation performed via a REST API. + /// + public interface IRestApiResult + { + /// + /// Converts the current instance into a object. + /// + /// A Response object representing the result of the conversion. + Response ToResponse(); + } +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiError.cs b/src/WebExpress.WebCore/WebRestApi/RestApiError.cs new file mode 100644 index 0000000..900a7a5 --- /dev/null +++ b/src/WebExpress.WebCore/WebRestApi/RestApiError.cs @@ -0,0 +1,47 @@ +namespace WebExpress.WebCore.WebRestApi +{ + /// + /// Represents an error returned by a REST API. + /// + public class RestApiError + { + /// + /// Returns or sets the error code (e.g. "VALIDATION_FAILED"). + /// + public string Code { get; init; } + + /// + /// Returns a human-readable message. + /// + public string Message { get; init; } + + /// + /// Returns the name of the field or parameter affected by the operation. + /// + public string Field { get; init; } + + /// + /// Represents an error returned by a REST API. + /// + /// The error message describing the issue. This parameter cannot be null or empty. + /// The optional error code associated with the error. This can be used to identify specific error types. + /// The optional name of the field that caused the error, if applicable. + public RestApiError(string message, string code = null, string field = null) + { + Message = message; + Code = code; + Field = field; + } + + /// + /// Returns a string representation of the object. + /// + /// A string that represents the current object. + public override string ToString() + { + return !string.IsNullOrWhiteSpace(Field) + ? $"{Field}: {Message}" + : Message; + } + } +} diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiPaginationInfo.cs b/src/WebExpress.WebCore/WebRestApi/RestApiPaginationInfo.cs new file mode 100644 index 0000000..3a8345e --- /dev/null +++ b/src/WebExpress.WebCore/WebRestApi/RestApiPaginationInfo.cs @@ -0,0 +1,35 @@ +using System; +using System.Text.Json.Serialization; + +namespace WebExpress.WebCore.WebRestApi +{ + /// + /// Represents pagination information for a REST API CRUD operation. + /// + public class RestApiPaginationInfo : IRestApiPaginationInfo + { + /// + /// Returns or sets the current page number in a paginated result set. + /// + [JsonPropertyName("page")] + public int PageNumber { get; set; } + + /// + /// Returns or sets the number of items to display per page in a paginated list. + /// + [JsonPropertyName("pageSize")] + public int PageSize { get; set; } + + /// + /// Returns or sets the total count of items. + /// + [JsonPropertyName("total")] + public int TotalCount { get; set; } + + /// + /// Returns the total number of pages based on the total item count and the page size. + /// + [JsonPropertyName("totalPages")] + public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); + } +} diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiResult.cs b/src/WebExpress.WebCore/WebRestApi/RestApiResult.cs new file mode 100644 index 0000000..1bdb468 --- /dev/null +++ b/src/WebExpress.WebCore/WebRestApi/RestApiResult.cs @@ -0,0 +1,117 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.WebRestApi +{ + /// + /// Represents the result of a CRUD operation performed via a REST API. + /// + public class RestApiResult : IRestApiResult + { + private readonly List _errors = []; + private readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; + + /// + /// Returns a value indicating whether the operation was successful. + /// + [JsonIgnore()] + public bool Success => !Errors.Any(); + + /// + /// Returns the collection of error messages. + /// + [JsonPropertyName("errors")] + public IEnumerable Errors => _errors; + + /// + /// Returns or sets the data associated with the index item. + /// + [JsonPropertyName("data")] + public object Data { get; set; } + + /// + /// Returns or sets the pagination information for the current API request. + /// + [JsonPropertyName("pagination")] + public RestApiPaginationInfo Pagination { get; set; } + + /// + /// Adds one or more error messages to the result. + /// + /// An array of error messages to add. Each message should describe an issue encountered during the operation. + /// The current instance, allowing for method chaining. + public RestApiResult AddError(params RestApiError[] errors) + { + _errors.AddRange(errors); + + return this; + } + + /// + /// Adds one or more error messages to the result. + /// + /// An array of error messages to add. Each message should describe an issue encountered during the operation. + /// The current instance, allowing for method chaining. + public RestApiResult AddError(IEnumerable errors) + { + _errors.AddRange(errors); + + return this; + } + + /// + /// Converts the current instance into a object. + /// + /// A Response object representing the result of the conversion. + public virtual Response ToResponse() + { + if (Data != null) + { + var jsonData = JsonSerializer.Serialize(Data, _jsonOptions); + var content = Encoding.UTF8.GetBytes(jsonData); + + return new ResponseOK + { + Content = content + } + .AddHeaderContentType("application/json"); + } + + return new ResponseBadRequest + { + Content = Encoding.UTF8.GetBytes("No data provided.") + }; + } + + /// + /// Creates a successful result for a REST API operation, containing the specified + /// data and optional pagination information. + /// + /// The data item to include in the result. Cannot be null. + /// Optional pagination information to include in the result. If null, no pagination details are provided. + /// Containing the specified data and pagination information. + public static IRestApiResult Ok(object data, RestApiPaginationInfo pagination = null) + { + return new RestApiResult + { + Data = data, + Pagination = pagination + }; + } + + /// + /// Creates a failed result with the specified error messages. + /// + /// An array of error messages describing the failure. Cannot be null, but may be empty. + /// Containing the provided error messages. + public static IRestApiResult Fail(params RestApiError[] errors) + { + return new RestApiResult() + .AddError(errors); + } + } +} \ No newline at end of file From c5e095a5b46016b8160885f31d7319baf2d15e03 Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Sun, 22 Jun 2025 18:25:45 +0200 Subject: [PATCH 17/51] add: rest api validator --- .../Fixture/UnitTestFixture.cs | 2 +- .../Manager/UnitTestRestApiManager.cs | 325 ++++++++++ .../Internationalization/de | 16 + .../Internationalization/en | 17 + src/WebExpress.WebCore/WebMessage/Request.cs | 2 +- .../WebRestApi/RestApiError.cs | 7 +- .../WebRestApi/RestApiResult.cs | 117 ---- .../WebRestApi/RestApiValidationResult.cs | 114 ++++ .../WebRestApi/RestApiValidator.cs | 590 ++++++++++++++++++ 9 files changed, 1070 insertions(+), 120 deletions(-) delete mode 100644 src/WebExpress.WebCore/WebRestApi/RestApiResult.cs create mode 100644 src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs create mode 100644 src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs diff --git a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs index ee51d00..027f651 100644 --- a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs +++ b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs @@ -120,7 +120,7 @@ public static WebMessage.HttpContext CreateHttpContextMock(string content = "") var firstLine = content.Split('\n').FirstOrDefault(); var lines = content.Split(_separator, StringSplitOptions.None); var filteredLines = lines.Skip(1).TakeWhile(line => !string.IsNullOrWhiteSpace(line)); - var pos = content.Length > 0 ? content.IndexOf(filteredLines.LastOrDefault()) + filteredLines.LastOrDefault().Length + 4 : 0; + var pos = content.Length > 0 ? content.IndexOf(filteredLines.LastOrDefault() ?? "") + filteredLines.LastOrDefault()?.Length ?? 0 + 4 : 0; var innerContent = pos < content.Length ? content[pos..] : ""; var contentBytes = Encoding.UTF8.GetBytes(innerContent); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestRestApiManager.cs index c0d25cb..9c18eba 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.WebRestApi; namespace WebExpress.WebCore.Test.Manager @@ -40,6 +41,7 @@ public void Remove() // test execution apiManager.Remove(plugin); + // validation Assert.Empty(componentHub.RestApiManager.RestApis); } @@ -138,5 +140,328 @@ public void IsIContext() Assert.True(typeof(IContext).IsAssignableFrom(api.GetType()), $"Api context {api.GetType().Name} does not implement IContext."); } } + + /// + /// Verifies that Require adds a validation error for missing or empty values. + /// + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ValidateRequire(string input) + { + // preconditions + var request = UnitTestFixture.CrerateRequestMock($"name={input}"); + request.AddParameter(new Parameter("name", input, ParameterScope.Parameter)); + + // test execution + var validator = new RestApiValidator(request) + .Require("name"); + + // validation + Assert.False(validator.IsValid); + Assert.Contains(validator.Result.Errors, e => e.Field == "name" && e.Code == "REQUIRED"); + } + + /// + /// Verifies that MinLength fails when input is shorter than allowed. + /// + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("ab")] + public void ValidateMinLength(string input) + { + // preconditions + var request = UnitTestFixture.CrerateRequestMock(); + request.AddParameter(new Parameter("code", input, ParameterScope.Parameter)); + + // test execution + var validator = new RestApiValidator(request) + .MinLength("code", 3); + + // validation + Assert.False(validator.IsValid); + } + + /// + /// Verifies that MaxLength fails when input exceeds the specified limit. + /// + [Theory] + [InlineData(256)] + [InlineData(300)] + public void ValidateMaxLength(int length) + { + // preconditions + var input = new string('x', length); + var request = UnitTestFixture.CrerateRequestMock(); + request.AddParameter(new Parameter("bio", input, ParameterScope.Parameter)); + + // test execution + var validator = new RestApiValidator(request) + .MaxLength("bio", 255); + + // validation + Assert.False(validator.IsValid); + Assert.Contains(validator.Result.Errors, e => e.Field == "bio" && e.Code == "TOO_LONG"); + } + + /// + /// Verifies that Email validation detects invalid email addresses. + /// + [Theory] + [InlineData("invalid")] + [InlineData("missing@domain")] + [InlineData("@nouser.com")] + public void ValidateEmail(string email) + { + // preconditions + var request = UnitTestFixture.CrerateRequestMock(); + request.AddParameter(new Parameter("email", email, ParameterScope.Parameter)); + + // test execution + var validator = new RestApiValidator(request) + .Email("email"); + + // validation + Assert.False(validator.IsValid); + Assert.Contains(validator.Result.Errors, e => e.Code == "INVALID_EMAIL"); + } + + /// + /// Verifies that IsInt fails when value is not a valid integer. + /// + [Theory] + [InlineData("abc")] + [InlineData("12.34")] + [InlineData("123a")] + public void ValidateIsInt(string input) + { + // preconditions + var request = UnitTestFixture.CrerateRequestMock(); + request.AddParameter(new Parameter("age", input, ParameterScope.Parameter)); + + // test execution + var validator = new RestApiValidator(request) + .IsInt("age"); + + // validation + Assert.False(validator.IsValid); + Assert.Contains(validator.Result.Errors, e => e.Code == "NOT_INTEGER"); + } + + /// + /// Verifies that EqualTo fails when value does not match expected. + /// + [Theory] + [InlineData("wrong")] + [InlineData("WrongCase")] + public void ValidateEqualTo(string input) + { + // preconditions + var request = UnitTestFixture.CrerateRequestMock(); + request.AddParameter(new Parameter("role", input, ParameterScope.Parameter)); + + // test execution + var validator = new RestApiValidator(request) + .EqualTo("role", "admin"); + + // validation + Assert.False(validator.IsValid); + Assert.Contains(validator.Result.Errors, e => e.Code == "MISMATCH"); + } + + /// + /// Verifies that Range fails when integer value is outside valid range. + /// + [Theory] + [InlineData("-1")] + [InlineData("101")] + public void ValidateRange(string input) + { + var request = UnitTestFixture.CrerateRequestMock(); + request.AddParameter(new Parameter("level", input, ParameterScope.Parameter)); + + var validator = new RestApiValidator(request) + .Range("level", 0, 100); + + Assert.False(validator.IsValid); + Assert.Contains(validator.Result.Errors, e => e.Code == "OUT_OF_RANGE"); + } + + /// + /// Verifies that StartsWith fails when input does not begin with prefix. + /// + [Theory] + [InlineData("abc123")] + [InlineData("xyz-start")] + public void ValidateStartsWith(string input) + { + var request = UnitTestFixture.CrerateRequestMock(); + request.AddParameter(new Parameter("code", input, ParameterScope.Parameter)); + + var validator = new RestApiValidator(request) + .StartsWith("code", "start"); + + Assert.False(validator.IsValid); + Assert.Contains(validator.Result.Errors, e => e.Code == "PREFIX_MISMATCH"); + } + + /// + /// Verifies that EndsWith fails when input does not end with suffix. + /// + [Theory] + [InlineData("summary.txt")] + [InlineData("document.pdf")] + public void ValidateEndsWith(string input) + { + var request = UnitTestFixture.CrerateRequestMock(); + request.AddParameter(new Parameter("filename", input, ParameterScope.Parameter)); + + var validator = new RestApiValidator(request) + .EndsWith("filename", ".log"); + + Assert.False(validator.IsValid); + Assert.Contains(validator.Result.Errors, e => e.Code == "SUFFIX_MISMATCH"); + } + + /// + /// Verifies that In fails when value is not in allowed set. + /// + [Theory] + [InlineData("guest")] + [InlineData("anonymous")] + public void ValidateIn(string input) + { + var request = UnitTestFixture.CrerateRequestMock(); + request.AddParameter(new Parameter("role", input, ParameterScope.Parameter)); + + var validator = new RestApiValidator(request) + .In("role", "Admin", "Editor", "User"); + + Assert.False(validator.IsValid); + Assert.Contains(validator.Result.Errors, e => e.Code == "INVALID_CHOICE"); + } + + /// + /// Verifies that Contains fails when input does not include required text. + /// + [Theory] + [InlineData("this is unrelated")] + [InlineData("foo bar")] + public void ValidateContains(string input) + { + var request = UnitTestFixture.CrerateRequestMock(); + request.AddParameter(new Parameter("description", input, ParameterScope.Parameter)); + + var validator = new RestApiValidator(request) + .Contains("description", "pirate"); + + Assert.False(validator.IsValid); + Assert.Contains(validator.Result.Errors, e => e.Code == "MISSING_FRAGMENT"); + } + + public enum Difficulty { Easy, Medium, Hard } + + /// + /// Verifies that MatchesEnum fails for invalid enum names. + /// + [Theory] + [InlineData("Impossible")] + [InlineData("easy-peasy")] + public void ValidateMatchesEnum(string value) + { + var request = UnitTestFixture.CrerateRequestMock(); + request.AddParameter(new Parameter("difficulty", value, ParameterScope.Parameter)); + + var validator = new RestApiValidator(request) + .MatchesEnum("difficulty"); + + Assert.False(validator.IsValid); + Assert.Contains(validator.Result.Errors, e => e.Code == "INVALID_ENUM"); + } + + /// + /// Verifies that IsDate fails when value cannot be parsed as date. + /// + [Theory] + [InlineData("not-a-date")] + [InlineData("31/31/2020")] + public void ValidateIsDate(string input) + { + var request = UnitTestFixture.CrerateRequestMock(); + request.AddParameter(new Parameter("date", input, ParameterScope.Parameter)); + + var validator = new RestApiValidator(request) + .IsDate("date"); + + Assert.False(validator.IsValid); + Assert.Contains(validator.Result.Errors, e => e.Code == "INVALID_DATE"); + } + + /// + /// Verifies that Custom fails when the condition evaluates to false. + /// + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("nonpirate")] + public void ValidateCustom(string input) + { + var request = UnitTestFixture.CrerateRequestMock(); + request.AddParameter(new Parameter("nickname", input, ParameterScope.Parameter)); + + var validator = new RestApiValidator(request) + .Custom( + r => r.GetParameter("nickname")?.Value == "pirate", + "Only pirates allowed", + "nickname", + "NOT_PIRATE" + ); + + Assert.False(validator.IsValid); + Assert.Contains(validator.Result.Errors, e => e.Code == "NOT_PIRATE"); + } + + /// + /// Verifies that Require is conditionally executed when When returns true. + /// + [Theory] + [InlineData("true", null)] + [InlineData("true", "")] + public void ValidateWhen_ConditionalRequire(string subscribe, string email) + { + var request = UnitTestFixture.CrerateRequestMock(); + request.AddParameter(new Parameter("subscribe", subscribe, ParameterScope.Parameter)); + request.AddParameter(new Parameter("email", email, ParameterScope.Parameter)); + + var validator = new RestApiValidator(request) + .When(r => r.GetParameter("subscribe")?.Value == "true") + .Require("email", "Email is required when subscribing."); + + Assert.False(validator.IsValid); + Assert.Contains(validator.Result.Errors, e => e.Field == "email"); + } + + /// + /// Verifies that validation is skipped when When condition is false. + /// + [Theory] + [InlineData("false", null)] + [InlineData(null, "")] + public void ValidateWhen_ConditionFalse(string subscribe, string email) + { + var request = UnitTestFixture.CrerateRequestMock(); + request.AddParameter(new Parameter("subscribe", subscribe, ParameterScope.Parameter)); + request.AddParameter(new Parameter("email", email, ParameterScope.Parameter)); + + var validator = new RestApiValidator(request) + .When(r => r.GetParameter("subscribe")?.Value == "true") + .Require("email", "Email required if subscribing."); + + Assert.True(validator.IsValid); // validation skipped + Assert.Empty(validator.Result.Errors); + } + } } diff --git a/src/WebExpress.WebCore/Internationalization/de b/src/WebExpress.WebCore/Internationalization/de index 8d5479e..9300415 100644 --- a/src/WebExpress.WebCore/Internationalization/de +++ b/src/WebExpress.WebCore/Internationalization/de @@ -172,3 +172,19 @@ thememanager.theme=Designvorlage: '{0}' für die Anwendung '{1}'. resource.variable.duplicate=Variable '{0}' bereits vorhanden! resource.file={0}: Datei '{1}' wurde geladen. + +validation.required={0} ist erforderlich. +validation.too_long={0} darf {1} Zeichen nicht überschreiten. +validation.too_short={0} muss mindestens {1} Zeichen lang sein. +validation.regex_mismatch={0} ist ungültig. +validation.out_of_range={0} muss zwischen {1} und {2} liegen. +validation.not_integer={0} muss eine gültige Ganzzahl sein. +validation.invalid_email={0} muss eine gültige E-Mail-Adresse sein. +validation.mismatch={0} muss '{1}' entsprechen. +validation.must_differ={0} darf nicht '{1}' sein. +validation.prefix_mismatch={0} muss mit '{1}' beginnen. +validation.invalid_choice={0} muss einer der folgenden Werte sein: {1}. +validation.missing_fragment={0} muss '{1}' enthalten. +validation.suffix_mismatch={0} muss mit '{1}' enden. +validation.invalid_enum={0} muss ein gültiger {1}-Wert sein. +validation.invalid_date={0} muss ein gültiges Datum sein. diff --git a/src/WebExpress.WebCore/Internationalization/en b/src/WebExpress.WebCore/Internationalization/en index c2dcfb9..9b74b55 100644 --- a/src/WebExpress.WebCore/Internationalization/en +++ b/src/WebExpress.WebCore/Internationalization/en @@ -172,3 +172,20 @@ thememanager.theme=Theme: '{0}' for application '{1}'. resource.variable.duplicate=Variable '{0}' already exists! resource.file={0}: File '{1}' has been loaded. + +validation.required={0} is required. +validation.too_long= {0} must not exceed {1} characters. +validation.too_short={0} must be at least {1} characters. +validation.regex_mismatch={0} is invalid. +validation.out_of_range={0} must be between {1} and {2}. +validation.not_integer={0} must be a valid integer. +validation.invalid_email={0} must be a valid email address. +validation.mismatch={0} must equal '{1}'. +validation.must_differ={0} must not be '{1}'. +validation.prefix_mismatch={0} must start with '{1}'. +validation.invalid_choice={0} must be one of: {1}. +validation.missing_fragment={0} must contain '{1}'. +validation.suffix_mismatch={0} must end with '{1}'. +validation.invalid_enum={0} must be a valid {1} value. +validation.invalid_date={0} must be a valid date. + diff --git a/src/WebExpress.WebCore/WebMessage/Request.cs b/src/WebExpress.WebCore/WebMessage/Request.cs index f0a706d..49e54c8 100644 --- a/src/WebExpress.WebCore/WebMessage/Request.cs +++ b/src/WebExpress.WebCore/WebMessage/Request.cs @@ -429,7 +429,7 @@ private void ParseRequestParams() /// private void ParseSessionParams() { - Session = WebEx.ComponentHub.SessionManager?.GetSession(this); + Session = WebEx.ComponentHub?.SessionManager?.GetSession(this); var property = Session?.GetProperty(); if (property != null && property.Params != null) diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiError.cs b/src/WebExpress.WebCore/WebRestApi/RestApiError.cs index 900a7a5..f15d014 100644 --- a/src/WebExpress.WebCore/WebRestApi/RestApiError.cs +++ b/src/WebExpress.WebCore/WebRestApi/RestApiError.cs @@ -1,4 +1,6 @@ -namespace WebExpress.WebCore.WebRestApi +using System.Text.Json.Serialization; + +namespace WebExpress.WebCore.WebRestApi { /// /// Represents an error returned by a REST API. @@ -8,16 +10,19 @@ public class RestApiError /// /// Returns or sets the error code (e.g. "VALIDATION_FAILED"). /// + [JsonPropertyName("code")] public string Code { get; init; } /// /// Returns a human-readable message. /// + [JsonPropertyName("message")] public string Message { get; init; } /// /// Returns the name of the field or parameter affected by the operation. /// + [JsonPropertyName("field")] public string Field { get; init; } /// diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiResult.cs b/src/WebExpress.WebCore/WebRestApi/RestApiResult.cs deleted file mode 100644 index 1bdb468..0000000 --- a/src/WebExpress.WebCore/WebRestApi/RestApiResult.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using WebExpress.WebCore.WebMessage; - -namespace WebExpress.WebCore.WebRestApi -{ - /// - /// Represents the result of a CRUD operation performed via a REST API. - /// - public class RestApiResult : IRestApiResult - { - private readonly List _errors = []; - private readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; - - /// - /// Returns a value indicating whether the operation was successful. - /// - [JsonIgnore()] - public bool Success => !Errors.Any(); - - /// - /// Returns the collection of error messages. - /// - [JsonPropertyName("errors")] - public IEnumerable Errors => _errors; - - /// - /// Returns or sets the data associated with the index item. - /// - [JsonPropertyName("data")] - public object Data { get; set; } - - /// - /// Returns or sets the pagination information for the current API request. - /// - [JsonPropertyName("pagination")] - public RestApiPaginationInfo Pagination { get; set; } - - /// - /// Adds one or more error messages to the result. - /// - /// An array of error messages to add. Each message should describe an issue encountered during the operation. - /// The current instance, allowing for method chaining. - public RestApiResult AddError(params RestApiError[] errors) - { - _errors.AddRange(errors); - - return this; - } - - /// - /// Adds one or more error messages to the result. - /// - /// An array of error messages to add. Each message should describe an issue encountered during the operation. - /// The current instance, allowing for method chaining. - public RestApiResult AddError(IEnumerable errors) - { - _errors.AddRange(errors); - - return this; - } - - /// - /// Converts the current instance into a object. - /// - /// A Response object representing the result of the conversion. - public virtual Response ToResponse() - { - if (Data != null) - { - var jsonData = JsonSerializer.Serialize(Data, _jsonOptions); - var content = Encoding.UTF8.GetBytes(jsonData); - - return new ResponseOK - { - Content = content - } - .AddHeaderContentType("application/json"); - } - - return new ResponseBadRequest - { - Content = Encoding.UTF8.GetBytes("No data provided.") - }; - } - - /// - /// Creates a successful result for a REST API operation, containing the specified - /// data and optional pagination information. - /// - /// The data item to include in the result. Cannot be null. - /// Optional pagination information to include in the result. If null, no pagination details are provided. - /// Containing the specified data and pagination information. - public static IRestApiResult Ok(object data, RestApiPaginationInfo pagination = null) - { - return new RestApiResult - { - Data = data, - Pagination = pagination - }; - } - - /// - /// Creates a failed result with the specified error messages. - /// - /// An array of error messages describing the failure. Cannot be null, but may be empty. - /// Containing the provided error messages. - public static IRestApiResult Fail(params RestApiError[] errors) - { - return new RestApiResult() - .AddError(errors); - } - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs b/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs new file mode 100644 index 0000000..76f01df --- /dev/null +++ b/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; +using System.Linq; + +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 class RestApiValidationResult + { + private readonly List _errors = []; + + /// + /// Returns a read-only collection of errors encountered during the API operation. + /// + public IEnumerable Errors => _errors.AsReadOnly(); + + /// + /// Returns a value indicating whether the current state is valid. + /// + /// The state is considered valid if there are no errors present. + public bool IsValid => _errors.Count == 0; + + /// + /// 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. + /// + public void Add(string message, string field = null, string code = null) + { + _errors.Add(new RestApiError(message, code, field)); + } + + /// + /// Adds one or more instances to the collection. + /// + /// + /// This method appends the specified errors to the existing collection. If + /// the array isempty, no changes are made. + /// + /// An array of error objects to add. + public void Add(params RestApiError[] errors) + { + _errors.AddRange(errors); + } + + /// + /// Adds one or more instances to the collection. + /// + /// + /// This method appends the specified errors to the existing collection. If + /// the array isempty, no changes are made. + /// + /// An array of error objects to add. + public void AddRange(IEnumerable errors) + { + if (errors != null) + { + _errors.AddRange(errors); + } + } + + /// + /// Returns a string representation of the current object, summarizing all errors. + /// + /// A semicolon-separated string of error descriptions. + public override string ToString() + { + return string.Join("; ", _errors.Select(e => e.ToString())); + } + + /// + /// 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 + /// ([]). + /// + public virtual string ToJson() + { + return System.Text.Json.JsonSerializer.Serialize(_errors.Select(e => new + { + code = e.Code, + message = e.Message, + field = e.Field + })); + } + } +} diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs b/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs new file mode 100644 index 0000000..5324d8b --- /dev/null +++ b/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs @@ -0,0 +1,590 @@ +using System; +using System.Linq; +using WebExpress.WebCore.Internationalization; +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.WebRestApi +{ + /// + /// Provides a fluent API for validating REST API request parameters. + /// + public class RestApiValidator + { + private readonly Request _request; + private readonly RestApiValidationResult _result = new(); + private bool _currentCondition = true; + + /// + /// Returns the result of the REST API validation. + /// + public RestApiValidationResult Result => _result; + + /// + /// Returns a value indicating whether the current result is valid. + /// + public bool IsValid => _result.IsValid; + + /// + /// Initializes a new instance of the class with the specified request. + /// + /// The request to be validated. + public RestApiValidator(Request request) + { + _request = request; + } + + /// + /// Specifies a condition that determines whether the current validation logic + /// should be applied. + /// + /// + /// A function that takes a request and returns true if the condition is + /// met; otherwise, false. + /// + /// The current instance, allowing for method chaining. + public RestApiValidator When(Func condition) + { + _currentCondition = condition(_request); + return this; + } + + /// + /// Ensures that the specified parameter is present and not null, empty, + /// or whitespace in the request. + /// + /// + /// If the parameter is missing or invalid, an error is added to the + /// validation result with the specified or default message. This method + /// does nothing if the current validation condition is not met. + /// + /// The name of the parameter to validate. + /// + /// An optional custom error message to include if the validation fails. + /// If not provided, a default message will be used. + /// + /// The current instance, allowing for method chaining. + public RestApiValidator Require(string parameter, string message = null) + { + if (!_currentCondition) return this; + + var value = _request.GetParameter(parameter)?.Value; + if (string.IsNullOrWhiteSpace(value)) + { + _result.Add( + message ?? I18N.Translate(_request, "webexpress.webcore:validation.required", parameter), + parameter, + "REQUIRED" + ); + } + + return this; + } + + /// + /// Validates that the specified parameter's value does not exceed the given + /// maximum length. + /// + /// + /// If the parameter's value exceeds the specified maximum length, an + /// error is added to the validation result. This method does nothing + /// if the current validation condition is not met. + /// + /// The name of the parameter to validate. + /// The maximum allowed length for the parameter's value. + /// + /// An optional custom error message to include if the validation fails. + /// If not provided, a default message will be used. + /// + /// The current instance, allowing for method chaining. + public RestApiValidator MaxLength(string parameter, int max, string message = null) + { + if (!_currentCondition) return this; + + var value = _request.GetParameter(parameter)?.Value; + if (!string.IsNullOrWhiteSpace(value) && value.Length > max) + { + _result.Add( + message ?? I18N.Translate(_request, "webexpress.webcore:validation.too_long", parameter, max.ToString()), + parameter, + "TOO_LONG" + ); + } + + return this; + } + + /// + /// Validates that the specified parameter's value meets the minimum + /// length requirement. + /// + /// + /// If the parameter's value is null, empty, or consists only of + /// whitespace, this validation is skipped. If the value is shorter + /// than the specified minimum length, an error is added to the + /// validation result. + /// + /// The name of the parameter to validate. + /// The minimum allowable length for the parameter's value. + /// + /// An optional custom error message to include if the validation fails. + /// If not provided, a default message will be used. + /// + /// The current instance, allowing for method chaining. + public RestApiValidator MinLength(string parameter, int min, string message = null) + { + if (!_currentCondition) return this; + + var value = _request.GetParameter(parameter)?.Value; + + if (string.IsNullOrWhiteSpace(value) && min > 0) + { + _result.Add( + message ?? I18N.Translate(_request, "webexpress.webcore:validation.too_short", parameter, min.ToString()), + parameter, + "TOO_SHORT" + ); + } + else if (!string.IsNullOrWhiteSpace(value) && value.Length < min) + { + _result.Add( + message ?? I18N.Translate(_request, "webexpress.webcore:validation.too_short", parameter, min.ToString()), + parameter, + "TOO_SHORT" + ); + } + + return this; + } + + /// + /// Validates that the value of a specified request parameter matches a + /// given regular expression pattern. + /// + /// + /// If the parameter's value is null, empty, or consists only of + /// whitespace, the validation is skipped. If the value does not match + /// the specified pattern, an error is added to the validation result. + /// + /// The name of the request parameter to validate. + /// + /// The regular expression pattern to match against the parameter's value. + /// + /// + /// An optional custom error message to include if the validation fails. + /// If not provided, a default message will be used. + /// + /// The current instance, allowing for method chaining. + public RestApiValidator Regex(string parameter, string pattern, string message = null) + { + if (!_currentCondition) return this; + + var value = _request.GetParameter(parameter)?.Value; + if (!string.IsNullOrWhiteSpace(value) && !System.Text.RegularExpressions.Regex.IsMatch(value, pattern)) + { + _result.Add( + message ?? I18N.Translate(_request, "webexpress.webcore:validation.regex_mismatch", parameter), + parameter, + "REGEX_MISMATCH" + ); + } + + return this; + } + + /// + /// Validates that the specified parameter's value is within the given range. + /// + /// + /// If the parameter's value is not a valid integer or falls outside the + /// specified range, an error is added to the validation result. This + /// method does nothing if the current validation condition is not active. + /// + /// The name of the parameter to validate. + /// The minimum allowable value for the parameter. + /// The maximum allowable value for the parameter. + /// + /// An optional custom error message to include if the validation fails. + /// If not provided, a default message will be used. + /// + /// The current instance, allowing for method chaining. + public RestApiValidator Range(string parameter, int min, int max, string message = null) + { + if (!_currentCondition) return this; + + var value = _request.GetParameter(parameter)?.Value; + if (int.TryParse(value, out var number)) + { + if (number < min || number > max) + { + _result.Add( + message ?? I18N.Translate(_request, "webexpress.webcore:validation.out_of_range", parameter, min.ToString(), max.ToString()), + parameter, + "OUT_OF_RANGE" + ); + } + } + + return this; + } + + /// + /// Validates that the specified parameter is a valid integer. + /// + /// + /// If the parameter value is not a valid integer, an error is added to + /// the validation result with the specified or default error message. If + /// the current condition is false, the method does not perform validation + /// and immediately returns the current instance. + /// + /// The name of the parameter to validate. + /// + /// An optional custom error message to include if the validation fails. + /// If not provided, a default message will be used. + /// + /// The current instance, allowing for method chaining. + public RestApiValidator IsInt(string parameter, string message = null) + { + if (!_currentCondition) return this; + + var value = _request.GetParameter(parameter)?.Value; + if (!int.TryParse(value, out _)) + { + _result.Add( + message ?? I18N.Translate(_request, "webexpress.webcore:validation.not_integer", parameter), + parameter, + "NOT_INTEGER" + ); + } + + return this; + } + + /// + /// Validates that the specified parameter contains a valid email address. + /// + /// + /// This method checks if the value of the specified parameter is a valid + /// email address using a regular expression. If the value is invalid, an + /// error is added to the validation result. The validation is skipped if + /// the current condition is not met. + /// + /// The name of the parameter to validate. + /// + /// An optional custom error message to include if the validation fails. + /// If not provided, a default message will be used. + /// + /// The current instance, allowing for method chaining. + public RestApiValidator Email(string parameter, string message = null) + { + if (!_currentCondition) return this; + + var value = _request.GetParameter(parameter)?.Value; + if (!string.IsNullOrWhiteSpace(value) && !System.Text.RegularExpressions.Regex.IsMatch(value, @"^[^@\s]+@[^@\s]+\.[^@\s]+$")) + { + _result.Add( + message ?? I18N.Translate(_request, "webexpress.webcore:validation.invalid_email", parameter), + parameter, + "INVALID_EMAIL" + ); + } + + return this; + } + + /// + /// Validates that the specified parameter in the request equals the expected value. + /// + /// + /// This method performs a case-insensitive comparison of the parameter's value + /// against the expected value. If the values do not match, an error is added to + /// the validation result. + /// + /// The name of the parameter to validate. + /// The expected value of the parameter. + /// + /// An optional custom error message to include if the validation fails. + /// If not provided, a default message will be used. + /// + /// The current instance, allowing for method chaining. + public RestApiValidator EqualTo(string parameter, string expected, string message = null) + { + if (!_currentCondition) return this; + + var value = _request.GetParameter(parameter)?.Value; + if (!string.Equals(value, expected, StringComparison.OrdinalIgnoreCase)) + { + _result.Add( + message ?? I18N.Translate(_request, "webexpress.webcore:validation.mismatch", parameter, expected), + parameter, + "MISMATCH" + ); + } + + return this; + } + + /// + /// Validates that the specified parameter's value is not equal to the provided value. + /// + /// + /// This method performs a case-insensitive comparison of the parameter's value + /// against the specified value. If the values are equal, an error is added + /// to the validation result. + /// + /// The name of the parameter to validate. + /// The value that the parameter's value must not match. + /// + /// An optional custom error message to include if the validation fails. + /// If not provided, a default message will be used. + /// + /// The current instance, allowing for method chaining. + public RestApiValidator NotEqualTo(string parameter, string notExpected, string message = null) + { + if (!_currentCondition) return this; + + var value = _request.GetParameter(parameter)?.Value; + if (string.Equals(value, notExpected, StringComparison.OrdinalIgnoreCase)) + { + _result.Add( + message ?? I18N.Translate(_request, "webexpress.webcore:validation.must_differ", parameter, notExpected), + parameter, + "MUST_DIFFER" + ); + } + + return this; + } + + /// + /// Validates that the value of the specified parameter starts with the given prefix. + /// + /// + /// If the parameter value does not start with the specified prefix, an error + /// is added to the validation result. This method does nothing if the current + /// validation condition is not active. + /// + /// The name of the parameter to validate. + /// The prefix that the parameter value must start with. + /// + /// An optional custom error message to include if the validation fails. + /// If not provided, a default message will be used. + /// + /// The current instance, allowing for method chaining. + public RestApiValidator StartsWith(string parameter, string prefix, string message = null) + { + if (!_currentCondition) return this; + + var value = _request.GetParameter(parameter)?.Value; + if (!string.IsNullOrWhiteSpace(value) && !value.StartsWith(prefix)) + { + _result.Add( + message ?? I18N.Translate(_request, "webexpress.webcore:validation.prefix_mismatch", parameter, prefix), + parameter, + "PREFIX_MISMATCH" + ); + } + + return this; + } + + /// + /// Validates that the specified parameter's value is one of the allowed values. + /// + /// + /// If the parameter's value is not one of the allowed values, an error is + /// added to the validation result. This method does nothing if the current + /// condition is not met. + /// + /// The name of the parameter to validate. + /// + /// An array of allowed values for the parameter. Validation is + /// case-insensitive. + /// + /// The current instance, allowing for method chaining. + public RestApiValidator In(string parameter, params string[] allowedValues) + { + if (!_currentCondition) return this; + + var value = _request.GetParameter(parameter)?.Value; + if (!string.IsNullOrWhiteSpace(value) && + !allowedValues.Contains(value, StringComparer.OrdinalIgnoreCase)) + { + _result.Add( + I18N.Translate(_request, "webexpress.webcore:validation.invalid_choice", parameter, string.Join(", ", allowedValues)), + parameter, + "INVALID_CHOICE" + ); + } + + return this; + } + + /// + /// Validates that the specified parameter contains the given text. + /// + /// + /// If the parameter's value is null, empty, or does not contain the + /// specified text, an error is added to the validation result. + /// + /// The name of the parameter to validate. + /// The text that the parameter's value must contain. + /// + /// An optional custom error message to include if the validation fails. + /// If not provided, a default message will be used. + /// + /// The current instance, allowing for method chaining. + public RestApiValidator Contains(string parameter, string text, string message = null) + { + if (!_currentCondition) return this; + + var value = _request.GetParameter(parameter)?.Value; + if (string.IsNullOrWhiteSpace(value) || !value.Contains(text)) + { + _result.Add( + message ?? I18N.Translate(_request, "webexpress.webcore:validation.missing_fragment", parameter, text), + parameter, + "MISSING_FRAGMENT" + ); + } + + return this; + } + + /// + /// Validates that the value of the specified parameter ends with the given suffix. + /// + /// + /// If the parameter value does not end with the specified suffix, an error + /// is added to the validation result. This method does nothing if the current + /// validation condition is not met. + /// + /// The name of the parameter to validate. + /// The required suffix that the parameter value must end with. + /// + /// An optional custom error message to include if the validation fails. + /// If not provided, a default message will be used. + /// + /// The current instance, allowing for method chaining. + public RestApiValidator EndsWith(string parameter, string suffix, string message = null) + { + if (!_currentCondition) return this; + + var value = _request.GetParameter(parameter)?.Value; + if (!string.IsNullOrWhiteSpace(value) && !value.EndsWith(suffix)) + { + _result.Add( + message ?? I18N.Translate(_request, "webexpress.webcore:validation.suffix_mismatch", parameter, suffix), + parameter, + "SUFFIX_MISMATCH" + ); + } + + return this; + } + + /// + /// Validates that the specified parameter value matches a valid value of + /// the specified enumeration type. + /// + /// + /// This method checks whether the value of the specified parameter can be + /// parsed as a valid value of the given enumeration type. If the value is + /// invalid, an error is added to the validation result. + /// + /// + /// The enumeration type to validate against. Must be a non-nullable enum. + /// + /// The name of the parameter to validate. + /// + /// An optional custom error message to include if the validation fails. + /// If not provided, a default message will be used. + /// + /// The current instance, allowing for method chaining. + public RestApiValidator MatchesEnum(string parameter, string message = null) + where T : struct, Enum + { + if (!_currentCondition) return this; + + var value = _request.GetParameter(parameter)?.Value; + if (!Enum.TryParse(value, true, out _)) + { + _result.Add( + message ?? I18N.Translate(_request, "webexpress.webcore:validation.invalid_enum", parameter, typeof(T).Name), + parameter, + "INVALID_ENUM" + ); + } + + return this; + } + + /// + /// Validates that the specified parameter is a valid date. + /// + /// + /// If the parameter value cannot be parsed as a valid date, an error + /// is added to the validation result. This method does nothing if the + /// current condition is not met. + /// + /// The name of the parameter to validate. + /// + /// An optional custom error message to include if the validation fails. + /// If not provided, a default message will be used. + /// + /// The current instance, allowing for method chaining. + public RestApiValidator IsDate(string parameter, string message = null) + { + if (!_currentCondition) return this; + + var value = _request.GetParameter(parameter)?.Value; + if (!DateTime.TryParse(value, out _)) + { + _result.Add( + message ?? I18N.Translate(_request, "webexpress.webcore:validation.invalid_date", parameter), + parameter, + "INVALID_DATE" + ); + } + + return this; + } + + /// + /// Adds a custom validation rule to the current request. + /// + /// + /// This method only applies the custom validation rule if the current + /// condition is active. If the condition evaluates to false, the + /// specified error message, parameter, and code are added to the + /// validation result. + /// + /// + /// A function that evaluates the request and returns true" if the + /// condition is met; otherwise,false. + /// + /// + /// The error message to associate with the validation failure if + /// the condition is not met. + /// + /// + /// The name of the parameter associated with the validation failure, + /// or null if not applicable. This parameter is optional. + /// + /// + /// A custom error code to associate with the validation failure. + /// 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") + { + if (!_currentCondition) return this; + + if (!condition(_request)) + { + _result.Add(message, parameter, code); + } + + return this; + } + } +} From ed2ba1deaf6d5e51b2ebae64814f3a2ff95b36f9 Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Mon, 23 Jun 2025 20:23:56 +0200 Subject: [PATCH 18/51] feat: updated AssetManager - now registers each individual asset instead of just the base path --- .../Assets/css/my-css.css | 14 ++ .../Fixture/UnitTestFixture.cs | 5 +- .../Manager/UnitTestAssetManager.cs | 83 ++++++----- .../Manager/UnitTestSitemapManager.cs | 20 +-- .../WebExpress.WebCore.Test.csproj | 130 +++++++++--------- .../WebAsset/AssetManager.cs | 62 +++------ 6 files changed, 151 insertions(+), 163 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/Assets/css/my-css.css diff --git a/src/WebExpress.WebCore.Test/Assets/css/my-css.css b/src/WebExpress.WebCore.Test/Assets/css/my-css.css new file mode 100644 index 0000000..d0446e5 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Assets/css/my-css.css @@ -0,0 +1,14 @@ +/* This CSS file styles the "Hello World" text */ + +/* Style for the body */ +body { + background-color: #f0f0f0; /* Light grey background for the entire page */ + font-family: Arial, sans-serif; /* Set font to Arial */ +} + +/* Style for the heading */ +h1 { + color: #333; /* Dark grey color for the text */ + text-align: center; /* Center the text */ + margin-top: 20%; /* Add space at the top */ +} diff --git a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs index 027f651..af13749 100644 --- a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs +++ b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs @@ -216,8 +216,9 @@ public static PageContext CreratePageContextMock(IApplicationContext application public static string GetEmbeddedResource(string fileName) { var assembly = typeof(UnitTestFixture).Assembly; - var resourceName = assembly.GetManifestResourceNames() - .FirstOrDefault(name => name.EndsWith(fileName, StringComparison.OrdinalIgnoreCase)); + var resources = assembly.GetManifestResourceNames(); + var resourceName = resources + .FirstOrDefault(name => name.Replace('\\', '/').EndsWith(fileName.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase)); using var stream = assembly.GetManifestResourceStream(resourceName); using var memoryStream = new MemoryStream(); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs index 9b39472..1974d85 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs @@ -23,7 +23,7 @@ public void Register() var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); // test execution - Assert.Equal(9, componentHub.AssetManager.Assets.Count()); + Assert.Equal(12, componentHub.AssetManager.Assets.Count()); } /// @@ -47,15 +47,16 @@ public void Remove() /// Test the id property of the asset. /// [Theory] - [InlineData(typeof(TestApplicationA), "css.mycss.css")] - [InlineData(typeof(TestApplicationA), "js.myjavascript.js")] - [InlineData(typeof(TestApplicationA), "js.myjavascript.mini.js")] - [InlineData(typeof(TestApplicationB), "css.mycss.css")] - [InlineData(typeof(TestApplicationB), "js.myjavascript.js")] - [InlineData(typeof(TestApplicationB), "js.myjavascript.mini.js")] - [InlineData(typeof(TestApplicationC), "css.mycss.css")] - [InlineData(typeof(TestApplicationC), "js.myjavascript.js")] - [InlineData(typeof(TestApplicationC), "js.myjavascript.mini.js")] + [InlineData(typeof(TestApplicationA), "webexpress.webcore.test.css.mycss.css")] + [InlineData(typeof(TestApplicationA), "webexpress.webcore.test.css.my-css.css")] + [InlineData(typeof(TestApplicationA), "webexpress.webcore.test.js.myjavascript.js")] + [InlineData(typeof(TestApplicationA), "webexpress.webcore.test.js.myjavascript.mini.js")] + [InlineData(typeof(TestApplicationB), "webexpress.webcore.test.css.mycss.css")] + [InlineData(typeof(TestApplicationB), "webexpress.webcore.test.js.myjavascript.js")] + [InlineData(typeof(TestApplicationB), "webexpress.webcore.test.js.myjavascript.mini.js")] + [InlineData(typeof(TestApplicationC), "webexpress.webcore.test.css.mycss.css")] + [InlineData(typeof(TestApplicationC), "webexpress.webcore.test.js.myjavascript.js")] + [InlineData(typeof(TestApplicationC), "webexpress.webcore.test.js.myjavascript.mini.js")] public void Id(Type applicationType, string id) { // preconditions @@ -71,49 +72,43 @@ public void Id(Type applicationType, string id) /// Test the uri property of the asset. /// [Theory] - [InlineData(typeof(TestApplicationA), "/server/appa/assets/css.mycss.css")] - [InlineData(typeof(TestApplicationA), "/server/appa/assets/js.myjavascript.js")] - [InlineData(typeof(TestApplicationA), "/server/appa/assets/js.myjavascript.mini.js")] - [InlineData(typeof(TestApplicationB), "/server/appb/assets/css.mycss.css")] - [InlineData(typeof(TestApplicationB), "/server/appb/assets/js.myjavascript.js")] - [InlineData(typeof(TestApplicationB), "/server/appb/assets/js.myjavascript.mini.js")] - [InlineData(typeof(TestApplicationC), "/server/assets/css.mycss.css")] - [InlineData(typeof(TestApplicationC), "/server/assets/js.myjavascript.js")] - [InlineData(typeof(TestApplicationC), "/server/assets/js.myjavascript.mini.js")] - public void Uri(Type applicationType, string uri) + [InlineData(typeof(TestApplicationA), "/server/appa/assets/css/mycss.css")] + [InlineData(typeof(TestApplicationA), "/server/appa/assets/css/my-css.css")] + [InlineData(typeof(TestApplicationA), "/server/appa/assets/js/myjavascript.js")] + [InlineData(typeof(TestApplicationA), "/server/appa/assets/js/myjavascript.mini.js")] + [InlineData(typeof(TestApplicationB), "/server/appb/assets/css/mycss.css")] + [InlineData(typeof(TestApplicationB), "/server/appb/assets/js/myjavascript.js")] + [InlineData(typeof(TestApplicationB), "/server/appb/assets/js/myjavascript.mini.js")] + [InlineData(typeof(TestApplicationC), "/server/assets/css/mycss.css")] + [InlineData(typeof(TestApplicationC), "/server/assets/js/myjavascript.js")] + [InlineData(typeof(TestApplicationC), "/server/assets/js/myjavascript.mini.js")] + public void Uri(Type applicationType, string route) { // preconditions var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); - var asset = componentHub.AssetManager.GetAssets(application)?.FirstOrDefault(x => x.EndpointId.ToString() == Path.GetFileName(uri)); + var asset = componentHub.AssetManager.GetAssets(application)? + .FirstOrDefault(x => x.Route.ToString() == route); // test execution - Assert.Equal(uri, asset?.Route.ToString()); + Assert.Equal(route, asset?.Route.ToString()); } /// /// Test the request of the asset. /// [Theory] - [InlineData("http://localhost:8080/server/appa/assets/css/mycss.css", "webexpress.webcore.asset", "css.mycss.css")] - [InlineData("http://localhost:8080/server/appa/assets/js/myjavascript.js", "webexpress.webcore.asset", "js.myjavascript.js")] - [InlineData("http://localhost:8080/server/appa/assets/js/myjavascript.mini.js", "webexpress.webcore.asset", "js.myjavascript.mini.js")] - [InlineData("http://localhost:8080/server/appa/assets/css.mycss.css", "webexpress.webcore.asset", "css.mycss.css")] - [InlineData("http://localhost:8080/server/appa/assets/js.myjavascript.js", "webexpress.webcore.asset", "js.myjavascript.js")] - [InlineData("http://localhost:8080/server/appa/assets/js.myjavascript.mini.js", "webexpress.webcore.asset", "js.myjavascript.mini.js")] - [InlineData("http://localhost:8080/server/appb/assets/css/mycss.css", "webexpress.webcore.asset", "css.mycss.css")] - [InlineData("http://localhost:8080/server/appb/assets/js/myjavascript.js", "webexpress.webcore.asset", "js.myjavascript.js")] - [InlineData("http://localhost:8080/server/appb/assets/js/myjavascript.mini.js", "webexpress.webcore.asset", "js.myjavascript.mini.js")] - [InlineData("http://localhost:8080/server/appb/assets/css.mycss.css", "webexpress.webcore.asset", "css.mycss.css")] - [InlineData("http://localhost:8080/server/appb/assets/js.myjavascript.js", "webexpress.webcore.asset", "js.myjavascript.js")] - [InlineData("http://localhost:8080/server/appb/assets/js.myjavascript.mini.js", "webexpress.webcore.asset", "js.myjavascript.mini.js")] - [InlineData("http://localhost:8080/server/assets/css/mycss.css", "webexpress.webcore.asset", "css.mycss.css")] - [InlineData("http://localhost:8080/server/assets/js/myjavascript.js", "webexpress.webcore.asset", "js.myjavascript.js")] - [InlineData("http://localhost:8080/server/assets/js/myjavascript.mini.js", "webexpress.webcore.asset", "js.myjavascript.mini.js")] - [InlineData("http://localhost:8080/server/assets/css.mycss.css", "webexpress.webcore.asset", "css.mycss.css")] - [InlineData("http://localhost:8080/server/assets/js.myjavascript.js", "webexpress.webcore.asset", "js.myjavascript.js")] - [InlineData("http://localhost:8080/server/assets/js.myjavascript.mini.js", "webexpress.webcore.asset", "js.myjavascript.mini.js")] - public void Request(string uri, string id, string resource) + [InlineData("http://localhost:8080/server/appa/assets/css/mycss.css", "css/mycss.css")] + [InlineData("http://localhost:8080/server/appa/assets/css/my-css.css", "css/my-css.css")] + [InlineData("http://localhost:8080/server/appa/assets/js/myjavascript.js", "js/myjavascript.js")] + [InlineData("http://localhost:8080/server/appa/assets/js/myjavascript.mini.js", "js/myjavascript.mini.js")] + [InlineData("http://localhost:8080/server/appb/assets/css/mycss.css", "css/mycss.css")] + [InlineData("http://localhost:8080/server/appb/assets/js/myjavascript.js", "js/myjavascript.js")] + [InlineData("http://localhost:8080/server/appb/assets/js/myjavascript.mini.js", "js/myjavascript.mini.js")] + [InlineData("http://localhost:8080/server/assets/css/mycss.css", "css/mycss.css")] + [InlineData("http://localhost:8080/server/assets/js/myjavascript.js", "js/myjavascript.js")] + [InlineData("http://localhost:8080/server/assets/js/myjavascript.mini.js", "js/myjavascript.mini.js")] + public void Request(string uri, string resource) { // preconditions var embeddedResource = UnitTestFixture.GetEmbeddedResource(resource); @@ -130,9 +125,11 @@ public void Request(string uri, string id, string resource) HttpContext = context }); - var response = componentHub.EndpointManager.HandleRequest(UnitTestFixture.CrerateRequestMock("", uri), searchResult.EndpointContext); + var response = componentHub + .EndpointManager + .HandleRequest(UnitTestFixture.CrerateRequestMock("", uri), searchResult.EndpointContext); - Assert.Equal(id, searchResult?.EndpointContext?.EndpointId.ToString()); + Assert.Equal($"webexpress.webcore.test.{resource.Replace('/', '.')}", searchResult?.EndpointContext?.EndpointId.ToString()); Assert.IsNotType(response); Assert.Equal(embeddedResource, Encoding.UTF8.GetString(response.Content as byte[])); } diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs index bd10883..3ec7ab8 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs @@ -27,7 +27,7 @@ public void Refresh() // test execution componentManager.SitemapManager.Refresh(); - Assert.Equal(79, componentManager.SitemapManager.SiteMap.Count()); + Assert.Equal(97, componentManager.SitemapManager.SiteMap.Count()); } /// @@ -58,12 +58,9 @@ public void Refresh() [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/assets/css/mycss.css", "webexpress.webcore.asset")] - [InlineData("http://localhost:8080/server/appa/assets/js/myjavascript.js", "webexpress.webcore.asset")] - [InlineData("http://localhost:8080/server/appa/assets/js/myjavascript.mini.js", "webexpress.webcore.asset")] - [InlineData("http://localhost:8080/server/appa/assets/css.mycss.css", "webexpress.webcore.asset")] - [InlineData("http://localhost:8080/server/appa/assets/js.myjavascript.js", "webexpress.webcore.asset")] - [InlineData("http://localhost:8080/server/appa/assets/js.myjavascript.mini.js", "webexpress.webcore.asset")] + [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/uri/does/not/exist", null)] public void SearchResource(string uri, string id) { @@ -133,12 +130,9 @@ public void GetUri(Type applicationType, Type resourceType, int? param, string e [InlineData("http://localhost:8080/server/appa/resources/testresourceb", "webexpress.webcore.test.www.resources.testresourceb")] [InlineData("http://localhost:8080/server/appa/resources/testresourcec", "webexpress.webcore.test.www.resources.testresourcec")] [InlineData("http://localhost:8080/server/appa/resources/testresourced", "webexpress.webcore.test.www.resources.testresourced")] - [InlineData("http://localhost:8080/server/appa/assets/css/mycss.css", "webexpress.webcore.asset")] - [InlineData("http://localhost:8080/server/appa/assets/js/myjavascript.js", "webexpress.webcore.asset")] - [InlineData("http://localhost:8080/server/appa/assets/js/myjavascript.mini.js", "webexpress.webcore.asset")] - [InlineData("http://localhost:8080/server/appa/assets/css.mycss.css", "webexpress.webcore.asset")] - [InlineData("http://localhost:8080/server/appa/assets/js.myjavascript.js", "webexpress.webcore.asset")] - [InlineData("http://localhost:8080/server/appa/assets/js.myjavascript.mini.js", "webexpress.webcore.asset")] + [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/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")] diff --git a/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj b/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj index aa17a51..a3a6732 100644 --- a/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj +++ b/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj @@ -1,73 +1,77 @@  - - net9.0 - enable - disable + + net9.0 + enable + disable - false - true - + false + true + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - + + + $(MSBuildProjectName).Assets.%(RecursiveDir)%(Filename)%(Extension) + + - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/src/WebExpress.WebCore/WebAsset/AssetManager.cs b/src/WebExpress.WebCore/WebAsset/AssetManager.cs index 6d5e148..6eb78ed 100644 --- a/src/WebExpress.WebCore/WebAsset/AssetManager.cs +++ b/src/WebExpress.WebCore/WebAsset/AssetManager.cs @@ -21,7 +21,6 @@ public sealed class AssetManager : IAssetManager, ISystemComponent private readonly IComponentHub _componentHub; private readonly IHttpServerContext _httpServerContext; private readonly AssetItemDictionary _itemDictionary = new(); - private readonly AssetEndpointDictionary _endpointDictionary = []; /// /// An event that fires when an asset is added. @@ -55,19 +54,21 @@ private AssetManager(IComponentHub componentHub, IHttpServerContext httpServerCo var endpointtRegistration = new EndpointRegistration() { - EndpointResolver = (type, applicationContext) => _endpointDictionary - .Where(x => x.Key == applicationContext) - .Select(x => x.Value) - .Where(x => x.Item2.GetType() == type) - .Select(x => x.Item1), - EndpointsResolver = () => _endpointDictionary - .Select(x => x.Value) - .Select(x => x.Item1), + EndpointResolver = (type, applicationContext) => [], + EndpointsResolver = () => Assets, HandleRequest = (request, endpointContext) => { var assetContext = endpointContext as IAssetContext; var asset = _itemDictionary.All - .FirstOrDefault(x => request.Uri.ToString().ToLower().Replace('/', '.').EndsWith(x.AssetContext.EndpointId.ToString())); + .FirstOrDefault + ( + x => + request.Uri + .ToString() + .ToLower() + .Replace("/", ".") + .EndsWith(x.AssetContext.Route.ToString().Replace("/", ".")) + ); if (asset != null) { @@ -137,26 +138,25 @@ private void Register(IPluginContext pluginContext, IEnumerable(typeof(Asset), context, _httpServerContext, _componentHub); - - _endpointDictionary.TryAdd(e, (context, asset)); } /// @@ -310,8 +290,6 @@ private void OnAddApplication(object sender, IApplicationContext e) private void OnRemoveApplication(object sender, IApplicationContext e) { Remove(e); - - _endpointDictionary.Remove(e); } /// From 41531cf88dc816d886a7660fb9426f8fff8c4621 Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Sun, 6 Jul 2025 08:06:33 +0200 Subject: [PATCH 19/51] add: support for DebugLocal configuration --- .../WebExpress.WebCore.Test.csproj | 27 +++---------------- .../WebExpress.WebCore.csproj | 11 +------- 2 files changed, 4 insertions(+), 34 deletions(-) diff --git a/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj b/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj index a3a6732..ad37477 100644 --- a/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj +++ b/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj @@ -7,30 +7,9 @@ false true + Debug;Release;DebugLocal - - - - - - - - - - - - - - - - - - - - - - @@ -62,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 7e5c5fe..f8a202d 100644 --- a/src/WebExpress.WebCore/WebExpress.WebCore.csproj +++ b/src/WebExpress.WebCore/WebExpress.WebCore.csproj @@ -23,23 +23,14 @@ True true true + Debug;Release;DebugLocal - - - - - - - - - - True From 789cac1a76cc88edabbb19d415db5f3298d46206 Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Sun, 13 Jul 2025 12:32:38 +0200 Subject: [PATCH 20/51] feat: removed Markdig dependency and implemented custom markdown --- src/WebExpress.WebCore/WebHtml/HtmlElement.cs | 62 ++++++++++++++++--- .../WebHtml/HtmlElementTextContentOl.cs | 10 +++ .../WebHtml/HtmlElementTextContentUl.cs | 10 +++ src/WebExpress.WebCore/WebHtml/HtmlList.cs | 2 +- 4 files changed, 73 insertions(+), 11 deletions(-) diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs index 2b7609f..6a48ae6 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs @@ -115,7 +115,7 @@ public string OnClick /// Initializes a new instance of the class. /// /// The name of the HTML element. - /// A boolean value indicating whether the element requires a closing tag. Default is true. + /// A boolean value indicating whether the element requires a self closing tag. Default is true. public HtmlElement(string name, bool closeTag = true) { ElementName = name; @@ -413,19 +413,23 @@ public virtual void ToString(StringBuilder builder, int deep) ToPreString(builder, deep); - if (_elements.Count == 1 && Elements.First() is HtmlText) + if (_elements.Count == 0) { - closeTag = true; + nl = false; + } + else if (ContainsOnlyTextNodes(_elements, out var text)) + { + closeTag = CloseTag; nl = false; - _elements.First().ToString(builder, 0); + builder.Append(text); } - else if (_elements.Count > 0) + else { closeTag = true; var count = builder.Length; - foreach (var v in Elements.Where(x => x != null)) + foreach (var v in _elements.Where(x => x != null)) { v.ToString(builder, deep + 1); } @@ -435,10 +439,6 @@ public virtual void ToString(StringBuilder builder, int deep) nl = false; } } - else if (_elements.Count == 0) - { - nl = false; - } if (closeTag || CloseTag) { @@ -489,6 +489,48 @@ protected virtual void ToPostString(StringBuilder builder, int deep, bool nl = t builder.Append('>'); } + /// + /// Determines whether the collection of IHtmlNode instances (including nested HtmlElement children) + /// contains only HtmlText nodes. If so, combines their text content and returns it via an out parameter. + /// + /// A collection of IHtmlNode instances to inspect. + /// The combined text content if all nodes are HtmlText; otherwise, null. + /// + /// True if all nodes (and their descendants) are HtmlText; otherwise, false. + /// + public bool ContainsOnlyTextNodes(IEnumerable elements, out string combinedText) + { + var builder = new StringBuilder(); + + foreach (var node in elements) + { + switch (node) + { + case HtmlText text: + builder.Append(text.Value); + break; + case HtmlList list: + if (!ContainsOnlyTextNodes(list.Elements, out var nestedListText)) + { + combinedText = null; + return false; + } + builder.Append(nestedListText); + break; + case HtmlElement element: + combinedText = null; + return false; + default: + combinedText = null; + return false; + } + } + + combinedText = builder.ToString(); + return true; + } + + /// /// Sets the valueless user-defined attribute. /// diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentOl.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentOl.cs index bc0106e..3ba4bca 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentOl.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentOl.cs @@ -35,6 +35,16 @@ public HtmlElementTextContentOl(params HtmlElementTextContentLi[] nodes) Add(nodes); } + /// + /// Initializes a new instance of the class. + /// + /// The content of the html element. + public HtmlElementTextContentOl(IEnumerable nodes) + : this() + { + Add(nodes); + } + /// /// Convert to a string using a StringBuilder. /// diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentUl.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentUl.cs index 9be3229..06f2870 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentUl.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentUl.cs @@ -30,5 +30,15 @@ public HtmlElementTextContentUl(params IHtmlNode[] nodes) { Add(nodes); } + + /// + /// Initializes a new instance of the class. + /// + /// The content of the html element. + public HtmlElementTextContentUl(IEnumerable nodes) + : this() + { + Add(nodes); + } } } diff --git a/src/WebExpress.WebCore/WebHtml/HtmlList.cs b/src/WebExpress.WebCore/WebHtml/HtmlList.cs index 5126f87..07e4560 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlList.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlList.cs @@ -71,7 +71,7 @@ public HtmlList(IHtmlNode firstNode, IEnumerable followingNodes) /// /// The elements to add. /// The current instance for method chaining. - protected HtmlList Add(params IHtmlNode[] elements) + public HtmlList Add(params IHtmlNode[] elements) { _elements.AddRange(elements); From 1246aa9e9581c0bd4423b8fb4842ba7b7e2adf8b Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Thu, 31 Jul 2025 20:41:09 +0200 Subject: [PATCH 21/51] add: smart editcontrol --- src/WebExpress.WebCore/WebPage/IRenderContext.cs | 6 ++++++ src/WebExpress.WebCore/WebPage/RenderContext.cs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/WebExpress.WebCore/WebPage/IRenderContext.cs b/src/WebExpress.WebCore/WebPage/IRenderContext.cs index e55b918..a9b3be0 100644 --- a/src/WebExpress.WebCore/WebPage/IRenderContext.cs +++ b/src/WebExpress.WebCore/WebPage/IRenderContext.cs @@ -1,5 +1,6 @@ using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebPage { @@ -18,6 +19,11 @@ public interface IRenderContext /// IPageContext PageContext { get; } + /// + /// The uri of the request. + /// + public IUri Uri => Request?.Uri; + /// /// Returns the request. /// diff --git a/src/WebExpress.WebCore/WebPage/RenderContext.cs b/src/WebExpress.WebCore/WebPage/RenderContext.cs index dd11745..13cd87f 100644 --- a/src/WebExpress.WebCore/WebPage/RenderContext.cs +++ b/src/WebExpress.WebCore/WebPage/RenderContext.cs @@ -23,7 +23,7 @@ public class RenderContext : IRenderContext /// /// The uri of the request. /// - public UriEndpoint Uri => Request?.Uri; + public IUri Uri => Request?.Uri; /// /// Returns the culture. From 7492a8df485e3a3599d743ee4a1b12f3360f8956 Mon Sep 17 00:00:00 2001 From: ReneSchwarzer Date: Wed, 27 Aug 2025 16:05:07 +0200 Subject: [PATCH 22/51] update: dependent package --- src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj b/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj index ad37477..a2ce933 100644 --- a/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj +++ b/src/WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj @@ -43,7 +43,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all From 2706f0373f36acbec9892f4bdbf1c2d66559146b Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 7 Sep 2025 17:54:45 +0200 Subject: [PATCH 23/51] feat: improved breadcrumb control and minor bugs --- .../WebAsset/AssetManager.cs | 20 ------------------- .../WebEndpoint/EndpointManager.cs | 10 +++------- .../WebSitemap/ISitemapManager.cs | 2 +- .../WebSitemap/SitemapManager.cs | 10 ++++++++-- 4 files changed, 12 insertions(+), 30 deletions(-) diff --git a/src/WebExpress.WebCore/WebAsset/AssetManager.cs b/src/WebExpress.WebCore/WebAsset/AssetManager.cs index 6eb78ed..8d7239d 100644 --- a/src/WebExpress.WebCore/WebAsset/AssetManager.cs +++ b/src/WebExpress.WebCore/WebAsset/AssetManager.cs @@ -292,26 +292,6 @@ private void OnRemoveApplication(object sender, IApplicationContext e) Remove(e); } - /// - /// Information about the component is collected and prepared for output in the log. - /// - private void Log() - { - //foreach (var resourcenItem in GetResorceItems(pluginContext)) - //{ - // output.Add - // ( - // string.Empty.PadRight(deep) + - // I18N.Translate - // ( - // "webexpress.webcore:resourcemanager.resource", - // resourcenItem?.ResourceContext?.EndpointId, - // string.Join(",", resourcenItem.ResourceContext?.ApplicationContext?.ApplicationId) - // ) - // ); - //} - } - /// /// Release of unmanaged resources reserved during use. /// diff --git a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs index 97c8d45..461dcd8 100644 --- a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs +++ b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs @@ -42,13 +42,10 @@ public sealed class EndpointManager : IEndpointManager, ISystemComponent /// /// 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 EndpointManager(IComponentHub componentHub, IHttpServerContext httpServerContext) + private EndpointManager(IHttpServerContext httpServerContext) { - //_componentHub = componentHub; - _httpServerContext = httpServerContext; _httpServerContext.Log.Debug @@ -271,9 +268,8 @@ public static IEnumerable GetAttributeInstances(IEnumerable(IEndpointContext endpointContext) /// /// The URI resource to search for. /// The endpoint context if found, otherwise null. - IEndpointContext GetEndpoint(UriEndpoint uri); + IEndpointContext GetEndpoint(IUri uri); } } diff --git a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs index 0562bba..7ae21c5 100644 --- a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs +++ b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs @@ -188,8 +188,13 @@ public IUri GetUri(IEndpointContext endpointContext) /// /// The URI resource to search for. /// The endpoint context if found, otherwise null. - public IEndpointContext GetEndpoint(UriEndpoint uri) + public IEndpointContext GetEndpoint(IUri uri) { + if (uri == null || uri.Empty) + { + return null; + } + var variables = new Dictionary(); var result = SearchNode ( @@ -198,6 +203,7 @@ public IEndpointContext GetEndpoint(UriEndpoint uri) new Queue(), new SearchContext() ); + return result?.EndpointContext; } @@ -374,7 +380,7 @@ private static void MergeSitemap(SitemapNode first, SitemapNode second) /// The path segments. /// The search context. /// The search result with the found resource - private SearchResult SearchNode + private static SearchResult SearchNode ( SitemapNode node, Queue inPathSegments, From 6ca7dc15f92f06b1b15f0023420e26cbe4155a4f Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 7 Sep 2025 22:30:24 +0200 Subject: [PATCH 24/51] feat: general improvements and minor bugs --- .../WebAttribute/WebIconAttribute.cs | 2 +- .../WebComponent/ComponentActivator.cs | 5 + .../WebFragment/FragmentManager.cs | 22 ++ .../WebFragment/IFragmentManager.cs | 13 + .../WebFragment/Model/FragmentItem.cs | 351 +++++++++--------- .../WebPage/IPageContext.cs | 6 + src/WebExpress.WebCore/WebPage/PageContext.cs | 6 + src/WebExpress.WebCore/WebPage/PageManager.cs | 10 +- .../WebSettingPage/ISettingPageContext.cs | 5 - .../WebSettingPage/SettingPageContext.cs | 5 - .../WebSettingPage/SettingPageManager.cs | 6 +- 11 files changed, 246 insertions(+), 185 deletions(-) diff --git a/src/WebExpress.WebCore/WebAttribute/WebIconAttribute.cs b/src/WebExpress.WebCore/WebAttribute/WebIconAttribute.cs index a110a65..b4ac014 100644 --- a/src/WebExpress.WebCore/WebAttribute/WebIconAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/WebIconAttribute.cs @@ -8,7 +8,7 @@ namespace WebExpress.WebCore.WebAttribute /// /// The type of the icon, which must implement the interface. [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] - public class WebIconAttribute : Attribute, ISettingPageAttribute, ISettingCategoryAttribute, ISettingGroupAttribute + public class WebIconAttribute : Attribute, ISettingPageAttribute, IPageAttribute, ISettingCategoryAttribute, ISettingGroupAttribute where TIcon : IIcon { /// diff --git a/src/WebExpress.WebCore/WebComponent/ComponentActivator.cs b/src/WebExpress.WebCore/WebComponent/ComponentActivator.cs index ed44dfd..6787bbc 100644 --- a/src/WebExpress.WebCore/WebComponent/ComponentActivator.cs +++ b/src/WebExpress.WebCore/WebComponent/ComponentActivator.cs @@ -3,6 +3,7 @@ using System.Reflection; using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebPage; using WebExpress.WebCore.WebStatusPage; namespace WebExpress.WebCore.WebComponent @@ -261,6 +262,10 @@ public static TComponent CreateInstance(Type componentType ( parameter.ParameterType == typeof(IApplicationContext) && x.GetType().GetInterfaces().Any(x => x == typeof(IApplicationContext)) + ) || + ( + parameter.ParameterType == typeof(IPageContext) && + x.GetType().GetInterfaces().Any(x => x == typeof(IPageContext)) ) ) .FirstOrDefault() ?? null diff --git a/src/WebExpress.WebCore/WebFragment/FragmentManager.cs b/src/WebExpress.WebCore/WebFragment/FragmentManager.cs index e509279..bf5d45b 100644 --- a/src/WebExpress.WebCore/WebFragment/FragmentManager.cs +++ b/src/WebExpress.WebCore/WebFragment/FragmentManager.cs @@ -414,6 +414,28 @@ public IEnumerable GetFragments(IApplicationCont } } + /// + /// Returns all fragments that belong to a given page. + /// + /// The fragment type. + /// The section where the fragment is embedded. + /// The page context. + /// An enumeration of the filtered fragments. + public IEnumerable GetFragments(IPageContext pageContext) + where TFragment : IFragmentBase + where TSection : ISection + { + var applicationContext = pageContext?.ApplicationContext; + var scopes = pageContext?.Scopes ?? [typeof(IScope)]; + + var effectiveScopes = (scopes?.Any() == true) ? scopes : [typeof(IScope)]; + + foreach (var item in _dictionary.GetFragmentItems(applicationContext, typeof(TFragment), typeof(TSection), effectiveScopes)) + { + yield return item.CreateInstance(pageContext); + } + } + /// /// Returns all fragment contexts that belong to a given application. /// diff --git a/src/WebExpress.WebCore/WebFragment/IFragmentManager.cs b/src/WebExpress.WebCore/WebFragment/IFragmentManager.cs index 540524b..348fd28 100644 --- a/src/WebExpress.WebCore/WebFragment/IFragmentManager.cs +++ b/src/WebExpress.WebCore/WebFragment/IFragmentManager.cs @@ -1,5 +1,7 @@ using System; +using System.Collections; using System.Collections.Generic; +using System.Linq; using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebHtml; @@ -92,6 +94,17 @@ public IEnumerable GetFragments(IApplicationCont where TFragment : IFragmentBase where TSection : ISection; + /// + /// Returns all fragments that belong to a given page. + /// + /// The fragment type. + /// The section where the fragment is embedded. + /// The page context. + /// An enumeration of the filtered fragments. + IEnumerable GetFragments(IPageContext pageContext) + where TFragment : IFragmentBase + where TSection : ISection; + /// /// Returns all fragment contexts that belong to a given application. /// diff --git a/src/WebExpress.WebCore/WebFragment/Model/FragmentItem.cs b/src/WebExpress.WebCore/WebFragment/Model/FragmentItem.cs index 4cc5cfb..b2a5463 100644 --- a/src/WebExpress.WebCore/WebFragment/Model/FragmentItem.cs +++ b/src/WebExpress.WebCore/WebFragment/Model/FragmentItem.cs @@ -1,170 +1,181 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using WebExpress.WebCore.WebApplication; -using WebExpress.WebCore.WebComponent; -using WebExpress.WebCore.WebCondition; -using WebExpress.WebCore.WebHtml; -using WebExpress.WebCore.WebMessage; -using WebExpress.WebCore.WebPage; -using WebExpress.WebCore.WebPlugin; - -namespace WebExpress.WebCore.WebFragment.Model -{ - /// - /// Fragments are components that can be integrated into pages to dynamically expand functionalities. - /// - internal class FragmentItem : IDisposable - { - private IFragmentBase _instance; - private readonly IComponentHub _componentHub; - private readonly IHttpServerContext _httpServerContext; - private static readonly Dictionary _delegateCache = []; - - /// - /// Returns the context of the associated plugin. - /// - public IPluginContext PluginContext { get; set; } - - /// - /// Returns the application context. - /// - public IApplicationContext ApplicationContext { get; set; } - - /// - /// Returns the fragment context. - /// - public IFragmentContext FragmentContext { get; set; } - - /// - /// The type of fragment. - /// - public Type FragmentClass { get; set; } - - /// - /// Returns the section. - /// - public Type Section { get; set; } - - /// - /// Returns the scope. - /// - public Type Scope { get; set; } - - /// - /// Returns the conditions that must be met for the component to be active. - /// - public ICollection Conditions { get; set; } - - /// - /// The order of the fragment. - /// - public int Order { get; set; } - - /// - /// Determines whether the component is created once and reused on each execution. - /// - public bool Cache { get; set; } - - /// - /// Initializes a new instance of the class. - /// - /// The component hub. - /// The context of the HTTP server. - public FragmentItem(IComponentHub componentHub, IHttpServerContext httpServerContext) - { - _componentHub = componentHub; - _httpServerContext = httpServerContext; - } - - /// - /// Create the instance of the component. - /// - public TFragment CreateInstance() where TFragment : IFragmentBase - { - var instance = _instance; - - instance ??= ComponentActivator.CreateInstance(FragmentClass, FragmentContext, _httpServerContext, _componentHub, FragmentContext); - - if (Cache) - { - _instance = instance; - } - - return (TFragment)instance; - } - - /// - /// Processes the fragments for a given section within the specified render context. - /// - /// The type of the render context. - /// The type of the visual tree. - /// The context in which rendering occurs. - /// The visual tree to be rendered. - /// An HTML node representing the rendered fragments. Can be null if no nodes are present. - public IHtmlNode Render(TRenderContext renderContext, TVisualTree visualTree) - where TRenderContext : IRenderContext - where TVisualTree : IVisualTree - { - var instance = CreateInstance(); - - if (CheckConditions(renderContext?.Request)) - { - if (!_delegateCache.TryGetValue(FragmentClass, out var del)) - { - // create and compile the expression - var renderContextType = FragmentClass.GetInterface(typeof(IFragment<,>).Name).GetGenericArguments()[0]; - var visualTreeType = FragmentClass.GetInterface(typeof(IFragment<,>).Name).GetGenericArguments()[1]; - var renderContextParam = Expression.Parameter(renderContextType, "renderContext"); - var visualTreeParam = Expression.Parameter(visualTreeType, "visualTree"); - var renderMethod = FragmentClass.GetMethod("Render", [renderContextType, visualTreeType]); - var callProzessMethod = Expression.Call - ( - Expression.Constant(instance), - renderMethod, - renderContextParam, - visualTreeParam - ); - var lambda = Expression.Lambda(callProzessMethod, renderContextParam, visualTreeParam) - .Compile(); - - _delegateCache[FragmentClass] = lambda; - del = lambda; - } - - // execute the cached delegate - var html = del.DynamicInvoke(renderContext, visualTree) as IHtmlNode; - - return html; - } - - return null; - } - - /// - /// Checks the component to see if they are displayed or disabled. - /// - /// The request. - /// True if the fragment is active, false otherwise. - public bool CheckConditions(Request request) - { - return !FragmentContext.Conditions.Any() || FragmentContext.Conditions.All(x => x.Fulfillment(request)); - } - /// - /// 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 $"Fragment: '{FragmentContext.FragmentId}'"; - } - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebCondition; +using WebExpress.WebCore.WebHtml; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebPage; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.WebFragment.Model +{ + /// + /// Fragments are components that can be integrated into pages to dynamically expand functionalities. + /// + internal class FragmentItem : IDisposable + { + private IFragmentBase _instance; + private readonly IComponentHub _componentHub; + private readonly IHttpServerContext _httpServerContext; + private static readonly Dictionary _delegateCache = []; + + /// + /// Returns the context of the associated plugin. + /// + public IPluginContext PluginContext { get; set; } + + /// + /// Returns the application context. + /// + public IApplicationContext ApplicationContext { get; set; } + + /// + /// Returns the fragment context. + /// + public IFragmentContext FragmentContext { get; set; } + + /// + /// The type of fragment. + /// + public Type FragmentClass { get; set; } + + /// + /// Returns the section. + /// + public Type Section { get; set; } + + /// + /// Returns the scope. + /// + public Type Scope { get; set; } + + /// + /// Returns the conditions that must be met for the component to be active. + /// + public ICollection Conditions { get; set; } + + /// + /// The order of the fragment. + /// + public int Order { get; set; } + + /// + /// Determines whether the component is created once and reused on each execution. + /// + public bool Cache { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The component hub. + /// The context of the HTTP server. + public FragmentItem(IComponentHub componentHub, IHttpServerContext httpServerContext) + { + _componentHub = componentHub; + _httpServerContext = httpServerContext; + } + + /// + /// Create the instance of the component. + /// + /// The page context. + public TFragment CreateInstance(IPageContext pageContext = null) + where TFragment : IFragmentBase + { + var instance = _instance; + + instance ??= ComponentActivator.CreateInstance + ( + FragmentClass, + FragmentContext, + _httpServerContext, + _componentHub, + FragmentContext, + pageContext + ); + + if (Cache) + { + _instance = instance; + } + + return (TFragment)instance; + } + + /// + /// Processes the fragments for a given section within the specified render context. + /// + /// The type of the render context. + /// The type of the visual tree. + /// The context in which rendering occurs. + /// The visual tree to be rendered. + /// An HTML node representing the rendered fragments. Can be null if no nodes are present. + public IHtmlNode Render(TRenderContext renderContext, TVisualTree visualTree) + where TRenderContext : IRenderContext + where TVisualTree : IVisualTree + { + var instance = CreateInstance(); + + if (CheckConditions(renderContext?.Request)) + { + if (!_delegateCache.TryGetValue(FragmentClass, out var del)) + { + // create and compile the expression + var renderContextType = FragmentClass.GetInterface(typeof(IFragment<,>).Name).GetGenericArguments()[0]; + var visualTreeType = FragmentClass.GetInterface(typeof(IFragment<,>).Name).GetGenericArguments()[1]; + var renderContextParam = Expression.Parameter(renderContextType, "renderContext"); + var visualTreeParam = Expression.Parameter(visualTreeType, "visualTree"); + var renderMethod = FragmentClass.GetMethod("Render", [renderContextType, visualTreeType]); + var callProzessMethod = Expression.Call + ( + Expression.Constant(instance), + renderMethod, + renderContextParam, + visualTreeParam + ); + var lambda = Expression.Lambda(callProzessMethod, renderContextParam, visualTreeParam) + .Compile(); + + _delegateCache[FragmentClass] = lambda; + del = lambda; + } + + // execute the cached delegate + var html = del.DynamicInvoke(renderContext, visualTree) as IHtmlNode; + + return html; + } + + return null; + } + + /// + /// Checks the component to see if they are displayed or disabled. + /// + /// The request. + /// True if the fragment is active, false otherwise. + public bool CheckConditions(Request request) + { + return !FragmentContext.Conditions.Any() || FragmentContext.Conditions.All(x => x.Fulfillment(request)); + } + + /// + /// 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 $"Fragment: '{FragmentContext.FragmentId}'"; + } + } +} diff --git a/src/WebExpress.WebCore/WebPage/IPageContext.cs b/src/WebExpress.WebCore/WebPage/IPageContext.cs index 12e91b0..0644061 100644 --- a/src/WebExpress.WebCore/WebPage/IPageContext.cs +++ b/src/WebExpress.WebCore/WebPage/IPageContext.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.WebIcon; namespace WebExpress.WebCore.WebPage { @@ -14,6 +15,11 @@ public interface IPageContext : IEndpointContext /// string PageTitle { get; } + /// + /// Returns the page icon. + /// + IIcon PageIcon { get; } + /// /// Returns the scope names that provides the page. The scope name /// is a string with a name (e.g. global, admin), which can be used by elements to diff --git a/src/WebExpress.WebCore/WebPage/PageContext.cs b/src/WebExpress.WebCore/WebPage/PageContext.cs index f4dbca2..c921510 100644 --- a/src/WebExpress.WebCore/WebPage/PageContext.cs +++ b/src/WebExpress.WebCore/WebPage/PageContext.cs @@ -4,6 +4,7 @@ using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebCondition; using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.WebIcon; using WebExpress.WebCore.WebPlugin; using WebExpress.WebCore.WebUri; @@ -46,6 +47,11 @@ public class PageContext : IPageContext /// public string PageTitle { get; internal set; } + /// + /// Returns the page icon. + /// + public IIcon PageIcon { get; internal set; } + /// /// Returns whether the resource is created once and reused each time it is called. /// diff --git a/src/WebExpress.WebCore/WebPage/PageManager.cs b/src/WebExpress.WebCore/WebPage/PageManager.cs index 8664878..066b472 100644 --- a/src/WebExpress.WebCore/WebPage/PageManager.cs +++ b/src/WebExpress.WebCore/WebPage/PageManager.cs @@ -10,6 +10,7 @@ using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebCondition; using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.WebIcon; using WebExpress.WebCore.WebPage.Model; using WebExpress.WebCore.WebPlugin; using WebExpress.WebCore.WebScope; @@ -297,6 +298,7 @@ private void Register(IPluginContext pluginContext, IEnumerable(); @@ -331,7 +333,12 @@ private void Register(IPluginContext pluginContext, IEnumerable x.AttributeType.GetInterfaces().Contains(typeof(IPageAttribute)))) { - if (customAttribute.AttributeType == typeof(TitleAttribute)) + if (customAttribute.AttributeType.IsGenericType && customAttribute.AttributeType.GetGenericTypeDefinition() == typeof(WebIconAttribute<>)) + { + var type = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); + icon ??= Activator.CreateInstance(type) as IIcon; + } + else if (customAttribute.AttributeType == typeof(TitleAttribute)) { title = customAttribute.ConstructorArguments.FirstOrDefault().Value?.ToString(); } @@ -362,6 +369,7 @@ private void Register(IPluginContext pluginContext, IEnumerable public interface ISettingPageContext : IPageContext { - /// - /// Returns the icon. - /// - IIcon Icon { get; } - /// /// Returns the setting category context to which the setting page belongs. /// diff --git a/src/WebExpress.WebCore/WebSettingPage/SettingPageContext.cs b/src/WebExpress.WebCore/WebSettingPage/SettingPageContext.cs index f429492..8858a6b 100644 --- a/src/WebExpress.WebCore/WebSettingPage/SettingPageContext.cs +++ b/src/WebExpress.WebCore/WebSettingPage/SettingPageContext.cs @@ -9,11 +9,6 @@ namespace WebExpress.WebCore.WebSettingPage /// public class SettingPageContext : PageContext, ISettingPageContext { - /// - /// Returns the icon. - /// - public IIcon Icon { get; internal set; } - /// /// Returns the setting category context to which the setting page belongs. /// diff --git a/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs b/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs index 440dd18..3922b39 100644 --- a/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs +++ b/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs @@ -537,12 +537,12 @@ private void RegisterPage(IPluginContext pluginContext, IEnumerable Date: Wed, 10 Sep 2025 23:06:09 +0200 Subject: [PATCH 25/51] feat: general improvements and minor bugs --- .../WebSettingPage/SettingPageContext.cs | 3 +- .../WebSettingPage/SettingPageManager.cs | 62 +++++++++++++++---- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/WebExpress.WebCore/WebSettingPage/SettingPageContext.cs b/src/WebExpress.WebCore/WebSettingPage/SettingPageContext.cs index 8858a6b..2e15b4f 100644 --- a/src/WebExpress.WebCore/WebSettingPage/SettingPageContext.cs +++ b/src/WebExpress.WebCore/WebSettingPage/SettingPageContext.cs @@ -1,5 +1,4 @@ -using WebExpress.WebCore.WebIcon; -using WebExpress.WebCore.WebPage; +using WebExpress.WebCore.WebPage; namespace WebExpress.WebCore.WebSettingPage { diff --git a/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs b/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs index 3922b39..81d2fd4 100644 --- a/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs +++ b/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs @@ -455,9 +455,15 @@ private void RegisterPage(IPluginContext pluginContext, IEnumerable)) + else if + ( + customAttribute.AttributeType.IsGenericType && + customAttribute.AttributeType.GetGenericTypeDefinition() == typeof(SettingGroupAttribute<>) + ) { - group = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); + group = customAttribute.AttributeType + .GenericTypeArguments + .FirstOrDefault(); } else if (customAttribute.AttributeType == typeof(SettingSectionAttribute)) { @@ -480,9 +486,15 @@ private void RegisterPage(IPluginContext pluginContext, IEnumerable).Name && customAttribute.AttributeType.Namespace == typeof(ConditionAttribute<>).Namespace) + else if + ( + customAttribute.AttributeType.Name == typeof(ConditionAttribute<>).Name && + customAttribute.AttributeType.Namespace == typeof(ConditionAttribute<>).Namespace + ) { - var condition = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); + var condition = customAttribute.AttributeType + .GenericTypeArguments + .FirstOrDefault(); conditions.Add(Activator.CreateInstance(condition) as ICondition); } } @@ -499,16 +511,27 @@ private void RegisterPage(IPluginContext pluginContext, IEnumerable 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) + else if + ( + customAttribute.AttributeType.Name == typeof(ScopeAttribute<>).Name && + customAttribute.AttributeType.Namespace == typeof(ScopeAttribute<>).Namespace + ) { - scopes.Add(customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault()); + scopes.Add(customAttribute.AttributeType + .GenericTypeArguments + .FirstOrDefault()); } } @@ -526,7 +549,14 @@ private void RegisterPage(IPluginContext pluginContext, IEnumerable x.AttributeType) @@ -652,7 +687,8 @@ public IEnumerable GetSettingPages(IApplicationContext appl } /// - /// Returns an enumeration of setting page contexts for the specified application context, category, and group. + /// Returns an enumeration of setting page contexts for the specified + /// application context, category, and group. /// /// The context of the application. /// The group for which to retrieve setting pages. From c2b6326a70c594500c27864c9b0ca2b5a0b08330 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 14 Sep 2025 14:22:04 +0200 Subject: [PATCH 26/51] feat: add avatar input control, general improvements and minor bugs --- .../WebMessage/ContentType.cs | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 src/WebExpress.WebCore/WebMessage/ContentType.cs diff --git a/src/WebExpress.WebCore/WebMessage/ContentType.cs b/src/WebExpress.WebCore/WebMessage/ContentType.cs new file mode 100644 index 0000000..7505171 --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/ContentType.cs @@ -0,0 +1,295 @@ +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Represents supported content types for file handling and MIME mapping. + /// + public enum ContentType + { + /// + /// Unknown or unsupported content type. + /// + Unknown, + + /// + /// Portable Document Format (.pdf). + /// + Pdf, + + /// + /// Plain text file (.txt). + /// + Txt, + + /// + /// Cascading Style Sheets (.css). + /// + Css, + + /// + /// JavaScript file (.js). + /// + Js, + + /// + /// XML file (.xml). + /// + Xml, + + /// + /// HTML file (.htm). + /// + Htm, + + /// + /// HTML file (.html). + /// + Html, + + /// + /// ZIP archive (.zip). + /// + Zip, + + /// + /// Microsoft Word document (.doc). + /// + Doc, + + /// + /// Microsoft Word Open XML document (.docx). + /// + Docx, + + /// + /// Microsoft Excel spreadsheet (.xls). + /// + Xls, + + /// + /// Microsoft Excel Open XML spreadsheet (.xlx). + /// + Xlx, + + /// + /// Microsoft PowerPoint presentation (.ppt). + /// + Ppt, + + /// + /// Graphics Interchange Format image (.gif). + /// + Gif, + + /// + /// Portable Network Graphics image (.png). + /// + Png, + + /// + /// Scalable Vector Graphics image (.svg). + /// + Svg, + + /// + /// JPEG image (.jpeg). + /// + Jpeg, + + /// + /// JPEG image (.jpg). + /// + Jpg, + + /// + /// Icon file (.ico). + /// + Ico, + + /// + /// WebP image format. + /// + WebP, + + /// + /// MPEG audio file (.mp3). + /// + Mp3, + + /// + /// MPEG-4 video file (.mp4). + /// + Mp4 + } + + /// + /// Provides extension methods for converting file extensions and MIME types to values. + /// + /// + /// This class includes utility methods for mapping common file formats and media types to their corresponding ContentType enumeration. + /// It supports both extension-based (e.g. ".png") and MIME-based (e.g. "image/png") conversions. + /// + + public static class ContentTypeExtensions + { + /// + /// Converts a file extension (e.g. ".png") to a ContentType enum. + /// + public static ContentType ToContentType(string extension) + { + if (string.IsNullOrWhiteSpace(extension)) + { + return ContentType.Unknown; + } + + extension = extension.Trim().ToLowerInvariant(); + if (!extension.StartsWith(".")) + { + extension = "." + extension; + } + + return extension switch + { + ".pdf" => ContentType.Pdf, + ".txt" => ContentType.Txt, + ".css" => ContentType.Css, + ".js" => ContentType.Js, + ".xml" => ContentType.Xml, + ".html" => ContentType.Html, + ".htm" => ContentType.Htm, + ".zip" => ContentType.Zip, + ".doc" => ContentType.Doc, + ".docx" => ContentType.Docx, + ".xls" => ContentType.Xls, + ".xlx" => ContentType.Xlx, + ".ppt" => ContentType.Ppt, + ".gif" => ContentType.Gif, + ".png" => ContentType.Png, + ".svg" => ContentType.Svg, + ".jpeg" => ContentType.Jpeg, + ".jpg" => ContentType.Jpg, + ".ico" => ContentType.Ico, + ".webp" => ContentType.WebP, + ".mp3" => ContentType.Mp3, + ".mp4" => ContentType.Mp4, + _ => ContentType.Unknown, + }; + } + + /// + /// Converts a MIME type string (e.g. "application/pdf") to a ContentType enum. + /// + public static ContentType ToContentTypeFromMime(string mimeType) + { + if (string.IsNullOrWhiteSpace(mimeType)) + { + return ContentType.Unknown; + } + + mimeType = mimeType.Trim().ToLowerInvariant(); + + return mimeType switch + { + "application/pdf" => ContentType.Pdf, + "text/plain" => ContentType.Txt, + "text/css" => ContentType.Css, + "application/javascript" => ContentType.Js, + "application/xml" => ContentType.Xml, + "text/xml" => ContentType.Xml, + "text/html" => ContentType.Html, + "application/zip" => ContentType.Zip, + "application/msword" => ContentType.Doc, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => ContentType.Docx, + "application/vnd.ms-excel" => ContentType.Xls, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => ContentType.Xlx, + "application/vnd.ms-powerpoint" => ContentType.Ppt, + "image/gif" => ContentType.Gif, + "image/png" => ContentType.Png, + "image/svg+xml" => ContentType.Svg, + "image/jpeg" => ContentType.Jpeg, + "image/jpg" => ContentType.Jpg, + "image/x-icon" => ContentType.Ico, + "image/webp" => ContentType.WebP, + "audio/mpeg" => ContentType.Mp3, + "video/mp4" => ContentType.Mp4, + _ => ContentType.Unknown, + }; + } + + /// + /// Returns the MIME type string associated with the specified value. + /// + /// The value to convert. + /// + /// A MIME type string such as "image/png" or "application/pdf". + /// If the content type is , an empty string is returned. + /// + public static string GetMimeType(this ContentType extension) + { + return extension switch + { + ContentType.Pdf => "application/pdf", + ContentType.Txt => "text/plain", + ContentType.Css => "text/css", + ContentType.Js => "application/javascript", + ContentType.Xml => "text/xml", + ContentType.Html => "text/html", + ContentType.Htm => "text/html", + ContentType.Zip => "application/zip", + ContentType.Doc => "application/msword", + ContentType.Docx => "application/msword", + ContentType.Xls => "application/vnd.ms-excel", + ContentType.Xlx => "application/vnd.ms-excel", + ContentType.Ppt => "application/vnd.ms-powerpoint", + ContentType.Gif => "image/gif", + ContentType.Png => "image/png", + ContentType.Svg => "image/svg+xml", + ContentType.Jpeg => "image/jpeg", + ContentType.Jpg => "image/jpeg", + ContentType.Ico => "image/x-icon", + ContentType.WebP => "image/webp", + ContentType.Mp3 => "audio/mpeg", + ContentType.Mp4 => "video/mp4", + _ => "application/octet-stream", + }; + } + + /// + /// Returns a file search pattern (e.g. "*.png") associated with the specified value. + /// + /// The value to convert. + /// + /// A file pattern string such as "*.pdf" or "*.jpg". + /// If the content type is , an empty string is returned. + /// + + public static string GetFilePattern(this ContentType extension) + { + return extension switch + { + ContentType.Pdf => "*.pdf", + ContentType.Txt => "*.txt", + ContentType.Css => "*.css", + ContentType.Js => "*.js", + ContentType.Xml => "*.xml", + ContentType.Html => "*.html", + ContentType.Htm => "*.htm", + ContentType.Zip => "*.zip", + ContentType.Doc => "*.doc", + ContentType.Docx => "*.docx", + ContentType.Xls => "*.xls", + ContentType.Xlx => "*.xlx", + ContentType.Ppt => "*.ppt", + ContentType.Gif => "*.gif", + ContentType.Png => "*.png", + ContentType.Svg => "*.svg", + ContentType.Jpeg => "*.jpeg", + ContentType.Jpg => "*.jpg", + ContentType.Ico => "*.ico", + ContentType.WebP => ".webp", + ContentType.Mp3 => "*.mp3", + ContentType.Mp4 => "*.mp4", + _ => "*.*", + }; + } + } +} From d98294070f9486f4545aefa87ceee6560596f9a5 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Mon, 15 Sep 2025 21:39:56 +0200 Subject: [PATCH 27/51] feat: add RestProgressTask control, general improvements and minor bugs --- src/WebExpress.WebCore/WebTask/Task.cs | 9 ++++++--- src/WebExpress.WebCore/WebTask/TaskManager.cs | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/WebExpress.WebCore/WebTask/Task.cs b/src/WebExpress.WebCore/WebTask/Task.cs index c65416f..c40e585 100644 --- a/src/WebExpress.WebCore/WebTask/Task.cs +++ b/src/WebExpress.WebCore/WebTask/Task.cs @@ -87,7 +87,7 @@ protected virtual void OnFinish() /// public void Run() { - System.Threading.Tasks.Task.Factory.StartNew((Action)(() => + System.Threading.Tasks.Task.Factory.StartNew(() => { State = TaskState.Run; @@ -101,9 +101,12 @@ public void Run() OnFinish(); - WebEx.ComponentHub.TaskManager.RemoveTask(this); + System.Threading.Tasks.Task.Delay(30000).ContinueWith(_ => + { + WebEx.ComponentHub.TaskManager.RemoveTask(this); + }); - }), TokenSource.Token); + }, TokenSource.Token); } /// diff --git a/src/WebExpress.WebCore/WebTask/TaskManager.cs b/src/WebExpress.WebCore/WebTask/TaskManager.cs index 86f38b4..f15081a 100644 --- a/src/WebExpress.WebCore/WebTask/TaskManager.cs +++ b/src/WebExpress.WebCore/WebTask/TaskManager.cs @@ -102,8 +102,10 @@ public ITask CreateTask(string id, EventHandler handler, params o /// The id of the task. /// The event handler. /// The event argument. + /// The type of the task.- /// The task or null. - public ITask CreateTask(string id, EventHandler handler, params object[] args) where T : Task + public ITask CreateTask(string id, EventHandler handler, params object[] args) + where TTask : Task { var key = id?.ToLower(); @@ -112,7 +114,7 @@ public ITask CreateTask(string id, EventHandler handler, param return value; } - var task = ComponentActivator.CreateInstance(_httpServerContext, _componentHub, [id, args]); + var task = ComponentActivator.CreateInstance(_httpServerContext, _componentHub, [id, args]); _dictionary.Add(key, task); task.Process += handler; From 20ee76f91ec394f9df17cc31ff44461a57768e26 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Wed, 17 Sep 2025 22:26:36 +0200 Subject: [PATCH 28/51] feat: add fragment page, general improvements and minor bugs --- src/WebExpress.WebCore/WebAttribute/ScopeAttribute.cs | 10 ++++++++-- .../WebAttribute/SectionAttribute.cs | 9 +++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/WebExpress.WebCore/WebAttribute/ScopeAttribute.cs b/src/WebExpress.WebCore/WebAttribute/ScopeAttribute.cs index a2dd838..ebcae9a 100644 --- a/src/WebExpress.WebCore/WebAttribute/ScopeAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/ScopeAttribute.cs @@ -4,10 +4,16 @@ namespace WebExpress.WebCore.WebAttribute { /// - /// The range in which the component is valid. + /// Specifies the scope type associated with a class. This attribute can be applied to classes + /// to define their scope, allowing for contextual behavior or configuration based on the + /// specified scope. /// + /// + /// The type of the scope. Must be a class that implements the interface. + /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] - public class ScopeAttribute : Attribute, IPageAttribute, ISettingPageAttribute where T : class, IScope + public class ScopeAttribute : Attribute, IPageAttribute, ISettingPageAttribute + where TScope : class, IScope { /// /// Initializes a new instance of the class. diff --git a/src/WebExpress.WebCore/WebAttribute/SectionAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SectionAttribute.cs index 5445fd6..7d5a058 100644 --- a/src/WebExpress.WebCore/WebAttribute/SectionAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SectionAttribute.cs @@ -4,10 +4,15 @@ namespace WebExpress.WebCore.WebAttribute { /// - /// Attribute to identify a section. + /// Specifies a section type for a class, allowing the class to be associated with a + /// specific configuration or settings section. /// + /// + /// The type of the section associated with the class. Must be a reference type that + /// implements . + /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] - public class SectionAttribute : Attribute, IFragmentAttribute, ISettingCategoryAttribute, ISettingGroupAttribute where T : class, ISection + public class SectionAttribute : Attribute, IFragmentAttribute, ISettingCategoryAttribute, ISettingGroupAttribute where TSection : class, ISection { /// /// Initializes a new instance of the class. From 58ebab6ef14e1498b3cb8bc494fd7d40a5cb3c50 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sat, 20 Sep 2025 09:27:48 +0200 Subject: [PATCH 29/51] - refactor: restructure WebExpress --- README.md | 26 +++++++++---------- docs/index.md | 14 +++++----- docs/tutorials.md | 10 +++---- docs/user-guide.md | 6 ++--- .../WebExpress.WebCore.csproj | 4 +-- 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index d395969..5e88a45 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![WebExpress](https://raw.githubusercontent.com/ReneSchwarzer/WebExpress/main/assets/banner.png) +![WebExpress-Framework](https://raw.githubusercontent.com/webexpress-framework/.github/main/docs/assets/img/banner.png) # WebExpress `WebExpress` is a lightweight web server optimized for use in low-performance environments (e.g. Raspberry PI). By providing a powerful plugin system and a comprehensive API, web applications can be easily and quickly integrated into a .net language (e.g. C#). Some advantages of `WebExpress` are: @@ -10,23 +10,23 @@ The `WebExpress` family includes the following projects: -- [WebExpress](https://github.com/ReneSchwarzer/WebExpress#readme) - The web server for `WebExpress` applications and the documentation. -- [WebExpress.WebCore](https://github.com/ReneSchwarzer/WebExpress.WebCore#readme) - The core for `WebExpress` applications. -- [WebExpress.WebUI](https://github.com/ReneSchwarzer/WebExpress.WebUI#readme) - Common templates and controls for `WebExpress` applications. -- [WebExpress.WebIndex](https://github.com/ReneSchwarzer/WebExpress.WebIndex#readme) - Reverse index for `WebExpress` applications. -- [WebExpress.WebApp](https://github.com/ReneSchwarzer/WebExpress.WebApp#readme) - Business application template for `WebExpress` applications. +- [WebExpress](https://github.com/webexpress-framework/WebExpress#readme) - The web server for `WebExpress` applications and the documentation. +- [WebExpress.WebCore](https://github.com/webexpress-framework/WebExpress.WebCore#readme) - The core for `WebExpress` applications. +- [WebExpress.WebUI](https://github.com/webexpress-framework/WebExpress.WebUI#readme) - Common templates and controls for `WebExpress` applications. +- [WebExpress.WebIndex](https://github.com/webexpress-framework/WebExpress.WebIndex#readme) - Reverse index for `WebExpress` applications. +- [WebExpress.WebApp](https://github.com/webexpress-framework/WebExpress.WebApp#readme) - Business application template for `WebExpress` applications. # WebExpress.WebCore `WebCore` is part of the `WebExpress` family and includes the basic elements of a `WebExpress` application. # Download -The current binaries are available for download [here](https://github.com/ReneSchwarzer/WebExpress/releases). +The current binaries are available for download [here](https://github.com/webexpress-framework/WebExpress/releases). # Start If you're looking to get started with `WebExpress`, we would recommend using the following documentation. It can help you understand the platform. -- [Installation Guide](https://github.com/ReneSchwarzer/WebExpress/blob/main/doc/installation_guide.md) -- [Development Guide](https://github.com/ReneSchwarzer/WebExpress/blob/main/doc/development_guide.md) +- [Installation Guide](https://github.com/webexpress-framework/WebExpress/blob/main/doc/installation_guide.md) +- [Development Guide](https://github.com/webexpress-framework/WebExpress/blob/main/doc/development_guide.md) - [WebExpress.WebCore API Documentation](https://reneschwarzer.github.io/WebExpress.WebCore/) - [WebExpress.WebUI API Documentation](https://reneschwarzer.github.io/WebExpress.WebUI/) - [WebExpress.WebApp API Documentation](https://reneschwarzer.github.io/WebExpress.WebApp/) @@ -35,10 +35,10 @@ If you're looking to get started with `WebExpress`, we would recommend using the # Learning The following tutorials illustrate the essential techniques of `WebExpress`. These tutorials are designed to assist you, as a developer, in understanding the various aspects of `WebExpress`. Each tutorial provides a detailed, step-by-step guide that you can work through using an example. If you re interested in beginning the development of `WebExpress` components, we would recommend you to complete some of these tutorials. -- [HelloWorld](https://github.com/ReneSchwarzer/WebExpress.Tutorial.HelloWorld#readme) -- [WebUI](https://github.com/ReneSchwarzer/WebExpress.Tutorial.WebUI#readme) -- [WebApp](https://github.com/ReneSchwarzer/WebExpress.Tutorial.WebApp#readme) -- [WebIndex](https://github.com/ReneSchwarzer/WebExpress.Tutorial.WebIndex#readme) +- [HelloWorld](https://github.com/webexpress-framework/WebExpress.Tutorial.HelloWorld#readme) +- [WebUI](https://github.com/webexpress-framework/WebExpress.Tutorial.WebUI#readme) +- [WebApp](https://github.com/webexpress-framework/WebExpress.Tutorial.WebApp#readme) +- [WebIndex](https://github.com/webexpress-framework/WebExpress.Tutorial.WebIndex#readme) # Tags #WebCore #WebExpress #DotNet #NETCore diff --git a/docs/index.md b/docs/index.md index 6a2e7de..caf4880 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,4 @@ -![WebExpress](https://raw.githubusercontent.com/ReneSchwarzer/WebExpress/main/assets/banner.png) +![WebExpress](https://raw.githubusercontent.com/webexpress-framework/WebExpress/main/assets/banner.png) # WebExpress WebExpress is a lightweight web server optimized for use in low-performance environments (e.g. Raspberry PI). By providing @@ -12,17 +12,17 @@ language (e.g. C#). Some advantages of WebExpress are: The `WebExpress` family includes the following projects: -- [WebExpress](https://github.com/ReneSchwarzer/WebExpress#readme) - The web server for `WebExpress` applications and the documentation. -- [WebExpress.WebCore](https://github.com/ReneSchwarzer/WebExpress.WebCore#readme) - The core for `WebExpress` applications. -- [WebExpress.WebUI](https://github.com/ReneSchwarzer/WebExpress.WebUI#readme) - Common templates and controls for `WebExpress` applications. -- [WebExpress.WebIndex](https://github.com/ReneSchwarzer/WebExpress.WebIndex#readme) - Reverse index for `WebExpress` applications. -- [WebExpress.WebApp](https://github.com/ReneSchwarzer/WebExpress.WebApp#readme) - Business application template for `WebExpress` applications. +- [WebExpress](https://github.com/webexpress-framework/WebExpress#readme) - The web server for `WebExpress` applications and the documentation. +- [WebExpress.WebCore](https://github.com/webexpress-framework/WebExpress.WebCore#readme) - The core for `WebExpress` applications. +- [WebExpress.WebUI](https://github.com/webexpress-framework/WebExpress.WebUI#readme) - Common templates and controls for `WebExpress` applications. +- [WebExpress.WebIndex](https://github.com/webexpress-framework/WebExpress.WebIndex#readme) - Reverse index for `WebExpress` applications. +- [WebExpress.WebApp](https://github.com/webexpress-framework/WebExpress.WebApp#readme) - Business application template for `WebExpress` applications. # WebExpress.WebCore WebCore is part of the WebExpress family and includes the basic elements of a WebExpress application. # Download -The current binaries are available for download [here](https://github.com/ReneSchwarzer/WebExpress/releases). +The current binaries are available for download [here](https://github.com/webexpress-framework/WebExpress/releases). # Tags #Raspberry #Raspbian #IoT #NETCore #WebExpress diff --git a/docs/tutorials.md b/docs/tutorials.md index 47b1f60..f7e2e41 100644 --- a/docs/tutorials.md +++ b/docs/tutorials.md @@ -1,4 +1,4 @@ -![WebExpress](https://raw.githubusercontent.com/ReneSchwarzer/WebExpress/main/assets/banner.png) +![WebExpress](https://raw.githubusercontent.com/webexpress-framework/WebExpress/main/assets/banner.png) # Tutorials Welcome to the `WebExpress` Tutorials! Here, you'll find step-by-step guides and helpful resources to get the most out @@ -7,10 +7,10 @@ our tutorials offer something for everyone. # Getting Started Begin with our basic tutorial: -- [HelloWorld](https://github.com/ReneSchwarzer/WebExpress.Tutorial.HelloWorld#readme) -- [WebUI](https://github.com/ReneSchwarzer/WebExpress.Tutorial.WebUI#readme) -- [WebApp](https://github.com/ReneSchwarzer/WebExpress.Tutorial.WebApp#readme) -- [WebIndex](https://github.com/ReneSchwarzer/WebExpress.Tutorial.WebIndex#readme) +- [HelloWorld](https://github.com/webexpress-framework/WebExpress.Tutorial.HelloWorld#readme) +- [WebUI](https://github.com/webexpress-framework/WebExpress.Tutorial.WebUI#readme) +- [WebApp](https://github.com/webexpress-framework/WebExpress.Tutorial.WebApp#readme) +- [WebIndex](https://github.com/webexpress-framework/WebExpress.Tutorial.WebIndex#readme) This tutorial will guide you through the initial steps of creating and running your first `WebExpress` application. diff --git a/docs/user-guide.md b/docs/user-guide.md index 3e68bfb..38304af 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1,4 +1,4 @@ -![WebExpress](https://raw.githubusercontent.com/ReneSchwarzer/WebExpress/main/assets/banner.png) +![WebExpress](https://raw.githubusercontent.com/webexpress-framework/WebExpress/main/assets/banner.png) # User guide Welcome to the `WebExpress.WebCore` User Guide. This guide will help you get started with `WebExpress.WebCore` and make the most out of its @@ -7,8 +7,8 @@ features. Follow the links below to begin your journey. # Getting started To get started with `WebExpress.WebCore`, use the following guides: -- [Installation Guide](https://github.com/ReneSchwarzer/WebExpress/blob/main/doc/installation_guide.md) -- [Development Guide](https://github.com/ReneSchwarzer/WebExpress/blob/main/doc/development_guide.md) +- [Installation Guide](https://github.com/webexpress-framework/WebExpress/blob/main/doc/installation_guide.md) +- [Development Guide](https://github.com/webexpress-framework/WebExpress/blob/main/doc/development_guide.md) - [WebExpress.WebCore API Documentation](https://reneschwarzer.github.io/WebExpress.WebCore/) - [WebExpress.WebUI API Documentation](https://reneschwarzer.github.io/WebExpress.WebUI/) - [WebExpress.WebApp API Documentation](https://reneschwarzer.github.io/WebExpress.WebApp/) diff --git a/src/WebExpress.WebCore/WebExpress.WebCore.csproj b/src/WebExpress.WebCore/WebExpress.WebCore.csproj index f8a202d..7c2b3bb 100644 --- a/src/WebExpress.WebCore/WebExpress.WebCore.csproj +++ b/src/WebExpress.WebCore/WebExpress.WebCore.csproj @@ -7,7 +7,7 @@ 0.0.9.0 net9.0 any - https://github.com/ReneSchwarzer/WebExpress.git + https://github.com/webexpress-framework/WebExpress.git Rene_Schwarzer@hotmail.de MIT Rene_Schwarzer@hotmail.de @@ -15,7 +15,7 @@ True Core library of the WebExpress web server. 0.0.9-alpha - https://github.com/ReneSchwarzer/WebExpress + https://github.com/webexpress-framework/WebExpress icon.png README.md git From d6688e5b9ded96438ad7a486dcaa794963301ed6 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sat, 20 Sep 2025 09:46:37 +0200 Subject: [PATCH 30/51] refactor: restructure WebExpress --- README.md | 8 ++++---- docs/user-guide.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5e88a45..ae3401c 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,10 @@ If you're looking to get started with `WebExpress`, we would recommend using the - [Installation Guide](https://github.com/webexpress-framework/WebExpress/blob/main/doc/installation_guide.md) - [Development Guide](https://github.com/webexpress-framework/WebExpress/blob/main/doc/development_guide.md) -- [WebExpress.WebCore API Documentation](https://reneschwarzer.github.io/WebExpress.WebCore/) -- [WebExpress.WebUI API Documentation](https://reneschwarzer.github.io/WebExpress.WebUI/) -- [WebExpress.WebApp API Documentation](https://reneschwarzer.github.io/WebExpress.WebApp/) -- [WebExpress.WebIndex API Documentation](https://reneschwarzer.github.io/WebExpress.WebIndex/) +- [WebExpress.WebCore API Documentation](https://webexpress-framework.github.io/WebExpress.WebCore/) +- [WebExpress.WebUI API Documentation](https://webexpress-framework.github.io/WebExpress.WebUI/) +- [WebExpress.WebApp API Documentation](https://webexpress-framework.github.io/WebExpress.WebApp/) +- [WebExpress.WebIndex API Documentation](https://webexpress-framework.github.io/WebExpress.WebIndex/) # Learning The following tutorials illustrate the essential techniques of `WebExpress`. These tutorials are designed to assist you, as a developer, in understanding the various aspects of `WebExpress`. Each tutorial provides a detailed, step-by-step guide that you can work through using an example. If you re interested in beginning the development of `WebExpress` components, we would recommend you to complete some of these tutorials. diff --git a/docs/user-guide.md b/docs/user-guide.md index 38304af..7f6360b 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -9,9 +9,9 @@ To get started with `WebExpress.WebCore`, use the following guides: - [Installation Guide](https://github.com/webexpress-framework/WebExpress/blob/main/doc/installation_guide.md) - [Development Guide](https://github.com/webexpress-framework/WebExpress/blob/main/doc/development_guide.md) -- [WebExpress.WebCore API Documentation](https://reneschwarzer.github.io/WebExpress.WebCore/) -- [WebExpress.WebUI API Documentation](https://reneschwarzer.github.io/WebExpress.WebUI/) -- [WebExpress.WebApp API Documentation](https://reneschwarzer.github.io/WebExpress.WebApp/) -- [WebExpress.WebIndex API Documentation](https://reneschwarzer.github.io/WebExpress.WebIndex/) +- [WebExpress.WebCore API Documentation](https://webexpress-framework.github.io/WebExpress.WebCore/) +- [WebExpress.WebUI API Documentation](https://webexpress-framework.github.io/WebExpress.WebUI/) +- [WebExpress.WebApp API Documentation](https://webexpress-framework.github.io/WebExpress.WebApp/) +- [WebExpress.WebIndex API Documentation](https://webexpress-framework.github.io/WebExpress.WebIndex/) We hope you enjoy using `WebExpress.WebCore` and find it valuable for your projects. Happy coding! From 56de7f4288956031c2b406f766b7817cd8386c18 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Wed, 24 Sep 2025 21:33:05 +0200 Subject: [PATCH 31/51] feat: add responsive control, general improvements and minor bugs --- .../Html/UnitTestHtmlElement.cs | 24 ++- .../Html/UnitTestHtmlElementExtension.cs | 204 ++++++++++++++++++ src/WebExpress.WebCore/WebHtml/Css.cs | 6 +- .../WebHtml/HTMLElementExtension.cs | 111 +++++----- 4 files changed, 278 insertions(+), 67 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementExtension.cs diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs index 425b741..e4df43e 100644 --- a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs @@ -3,7 +3,7 @@ namespace WebExpress.WebCore.Test.Html { /// - /// Unit tests for the HtmlElementFieldLabel class. + /// Unit tests for the HtmlElement class. /// [Collection("NonParallelTests")] public class UnitTestHtmlElement @@ -25,6 +25,7 @@ public void FindSingel() // test execution var res = html.Find(x => x is HtmlElementTextSemanticsSpan).FirstOrDefault(); + // validation Assert.Equal(@"", res.Trim()); } @@ -49,6 +50,7 @@ public void Find() // test execution var res = html.Find(x => x is HtmlElementTextSemanticsSpan).FirstOrDefault(); + // validation Assert.Equal(@"", res.Trim()); } @@ -64,7 +66,7 @@ public void AddClassTest() // test execution div.AddClass("test-class"); - // assertion + // validation Assert.Contains("class=\"test-class\"", div.ToString()); } @@ -81,7 +83,7 @@ public void RemoveClassTest() // test execution div.RemoveClass("test-class"); - // assertion + // validation Assert.DoesNotContain("class=\"test-class\"", div.ToString()); } @@ -97,7 +99,7 @@ public void AddStyleTest() // test execution div.AddStyle("color:red;"); - // assertion + // validation Assert.Contains("style=\"color:red;\"", div.ToString()); } @@ -114,7 +116,7 @@ public void RemoveStyleTest() // test execution div.RemoveStyle("color"); - // assertion + // validation Assert.DoesNotContain("style=\"color:red;\"", div.ToString()); } @@ -131,7 +133,7 @@ public void AddMultipleClassesTest() div.AddClass("class1"); div.AddClass("class2"); - // assertion + // validation Assert.Contains("class=\"class1 class2\"", div.ToString()); } @@ -149,7 +151,7 @@ public void RemoveOneOfMultipleClassesTest() // test execution div.RemoveClass("class1"); - // assertion + // validation Assert.DoesNotContain("class1", div.ToString()); Assert.Contains("class2", div.ToString()); } @@ -167,7 +169,7 @@ public void AddMultipleStylesTest() div.AddStyle("color:red;"); div.AddStyle("background:blue;"); - // assertion + // validation Assert.Contains("color:red;", div.ToString()); Assert.Contains("background:blue;", div.ToString()); } @@ -186,7 +188,7 @@ public void RemoveOneOfMultipleStylesTest() // test execution div.RemoveStyle("color:red;"); - // assertion + // validation Assert.DoesNotContain("color:red;", div.ToString()); Assert.Contains("background:blue;", div.ToString()); } @@ -200,7 +202,7 @@ public void ToStringEmptyDivTest() // preconditions var div = new HtmlElementTextContentDiv(); - // assertion + // validation Assert.Equal("
", div.ToString().Trim()); } @@ -216,7 +218,7 @@ public void ToStringWithChildrenTest() new HtmlElementTextSemanticsI() ); - // assertion + // validation Assert.Contains("", div.ToString()); Assert.Contains("", div.ToString()); } diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementExtension.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementExtension.cs new file mode 100644 index 0000000..7bfe0a0 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementExtension.cs @@ -0,0 +1,204 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the UnitTestHtmlElementExtension class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementExtension + { + /// + /// Tests the AddClass method. + /// + [Theory] + [InlineData("existing1 existing2", "new", "existing1 existing2 new")] + [InlineData("existing", "new", "existing new")] + [InlineData("existing", "new new", "existing new")] + [InlineData("existing", "new1 new2", "existing new1 new2")] + [InlineData("existing", "", "existing")] + [InlineData("existing", null, "existing")] + [InlineData("", "new", "new")] + [InlineData(null, "new", "new")] + public void AddClass(string initial, string toAdd, string expected) + { + // preconditions + var element = new HtmlElementTextContentDiv { Class = initial } as IHtmlNode; + + // test execution + element.AddClass(toAdd); + + Assert.Equal(expected, (element as IHtmlElement).Class); + } + + /// + /// Tests the RemoveClass method. + /// + [Theory] + [InlineData("one two three", null, "one two three")] + [InlineData("one two three", "", "one two three")] + [InlineData("one two three", "two", "one three")] + [InlineData("alpha beta", "gamma", "alpha beta")] + [InlineData(null, "gamma", "")] + public void RemoveClass(string initial, string toRemove, string expected) + { + // preconditions + var element = new HtmlElementTextContentDiv { Class = initial } as IHtmlNode; + + // test execution + element.RemoveClass(toRemove); + + // validation + Assert.Equal(expected, (element as IHtmlElement).Class); + } + + /// + /// Tests the AddStyle method. + /// + [Theory] + [InlineData("color:red;", "margin:10px;", "color:red; margin:10px;")] + [InlineData("", "padding:5px;", "padding:5px;")] + [InlineData("existing1 existing2", "new", "existing1 existing2 new")] + [InlineData("existing", "new", "existing new")] + [InlineData("existing", "new new", "existing new")] + [InlineData("existing", "new1 new2", "existing new1 new2")] + [InlineData("existing", "", "existing")] + [InlineData("existing", null, "existing")] + [InlineData("", "new", "new")] + [InlineData(null, "new", "new")] + public void AddStyle(string initial, string toAdd, string expected) + { + // preconditions + var element = new HtmlElementTextContentDiv { Style = initial } as IHtmlNode; + + // test execution + element.AddStyle(toAdd); + + // validation + Assert.Equal(expected, (element as IHtmlElement).Style); + } + + /// + /// Tests the RemoveStyle method. + /// + [Theory] + [InlineData("color:red; margin:10px;", "margin:10px;", "color:red;")] + [InlineData("padding:5px;", "padding:5px;", "")] + [InlineData("one two three", null, "one two three")] + [InlineData("one two three", "", "one two three")] + [InlineData("one two three", "two", "one three")] + [InlineData("alpha beta", "gamma", "alpha beta")] + [InlineData(null, "gamma", "")] + public void RemoveStyle(string initial, string toRemove, string expected) + { + // preconditions + var element = new HtmlElementTextContentDiv { Style = initial } as IHtmlNode; + + // test execution + element.RemoveStyle(toRemove); + + // validation + Assert.Equal(expected.Trim(), (element as IHtmlElement).Style.Trim()); + } + + /// + /// Tests the AddUserAttribute method. + /// + [Theory] + [InlineData("data-test", null, "data-test")] + [InlineData("data-id", "42", "data-id=42")] + public void AddUserAttribute(string name, string value, string expected) + { + // preconditions + var element = new HtmlElementTextContentDiv() as IHtmlNode; + + // test execution + if (!string.IsNullOrWhiteSpace(value)) + { + element.AddUserAttribute(name, value); + } + else + { + element.AddUserAttribute(name); + } + + // validation + var attribute = (element as IHtmlElement).Attributes + .Where(x => x.Name.Equals(name)) + .FirstOrDefault(); + + if (attribute is HtmlAttribute valueAttribute) + { + Assert.Equal(expected, $"{attribute?.Name}={valueAttribute?.Value}"); + } + else + { + Assert.Equal(expected, attribute?.Name); + } + } + + /// + /// Tests the RemoveUserAttribute method. + /// + [Theory] + [InlineData("data-test", null)] + [InlineData("data-id", "42")] + public void RemoveUserAttribute(string name, string value) + { + // preconditions + var element = new HtmlElementTextContentDiv() as IHtmlNode; + element.AddUserAttribute(name, value); + + // test execution + element.RemoveUserAttribute(name); + + // validation + Assert.False((element as IHtmlElement).Attributes.Where(x => x.Equals(name)).Any()); + } + + /// + /// Tests the Find method. + /// + [Fact] + public void Find() + { + // preconditions + var root = new HtmlElementTextContentDiv(); + var node = root as IHtmlNode; + var child1 = new HtmlElementTextSemanticsSpan(); + var child2 = new HtmlElementTextSemanticsSpan(); + root.Add(child1); + root.Add(child2); + + // test execution + var result = node.Find(e => e is HtmlElementTextSemanticsSpan).ToList(); + + // validation + Assert.Equal(2, result.Count); + Assert.Contains(child1, result); + Assert.Contains(child2, result); + } + + /// + /// Tests the Find method. + /// + [Fact] + public void FindOnCollection() + { + var div1 = new HtmlElementTextContentDiv(); + var span1 = new HtmlElementTextSemanticsSpan(); + div1.Add(span1); + + var div2 = new HtmlElementTextContentDiv(); + var span2 = new HtmlElementTextSemanticsSpan(); + div2.Add(span2); + + var nodes = new List { div1, div2 } as IEnumerable; + var result = nodes.Find(e => e is HtmlElementTextSemanticsSpan); + + Assert.Equal(2, result.Count()); + Assert.Contains(span1, result); + Assert.Contains(span2, result); + } + } +} diff --git a/src/WebExpress.WebCore/WebHtml/Css.cs b/src/WebExpress.WebCore/WebHtml/Css.cs index 07351e4..27e206d 100644 --- a/src/WebExpress.WebCore/WebHtml/Css.cs +++ b/src/WebExpress.WebCore/WebHtml/Css.cs @@ -19,7 +19,8 @@ public static string Concatenate(params string[] items) } /// - /// Joins the specified CSS classes into a single string, starting with a required first class, ensuring no duplicates and ignoring null or whitespace entries. + /// Joins the specified CSS classes into a single string, starting with a required first + /// class, ensuring no duplicates and ignoring null or whitespace entries. /// /// The first CSS class, which is required. /// Additional CSS classes to join. @@ -30,7 +31,8 @@ public static string Concatenate(string first, params string[] items) } /// - /// Joins the specified CSS classes into a single string, starting with a required first class, ensuring no duplicates and ignoring null or whitespace entries. + /// Joins the specified CSS classes into a single string, starting with a required first + /// class, ensuring no duplicates and ignoring null or whitespace entries. /// /// The first CSS class, which is required. /// Additional CSS classes to join. diff --git a/src/WebExpress.WebCore/WebHtml/HTMLElementExtension.cs b/src/WebExpress.WebCore/WebHtml/HTMLElementExtension.cs index 492016b..1a847f8 100644 --- a/src/WebExpress.WebCore/WebHtml/HTMLElementExtension.cs +++ b/src/WebExpress.WebCore/WebHtml/HTMLElementExtension.cs @@ -17,20 +17,9 @@ public static class HtmlElementExtension /// The HTML element extended by the checkout. public static IHtmlNode AddClass(this IHtmlNode html, string cssClass) { - if (html is HtmlElement) + if (!string.IsNullOrWhiteSpace(cssClass) && html is HtmlElement element) { - var element = html as HtmlElement; - - var list = new List(element.Class.Split(" ".ToCharArray(), StringSplitOptions.RemoveEmptyEntries)).Select(x => x.ToLower()).ToList(); - - if (!list.Contains(cssClass.ToLower())) - { - list.Add(cssClass.ToLower()); - } - - var css = string.Join(' ', list); - - element.Class = css; + element.Class = Css.Concatenate([.. element.Class?.Split(" "), .. cssClass.Split(" ")]); } return html; @@ -44,22 +33,9 @@ public static IHtmlNode AddClass(this IHtmlNode html, string cssClass) /// The HTML element reduced by the checkout. public static IHtmlNode RemoveClass(this IHtmlNode html, string cssClass) { - if (cssClass == null) return html; - - if (html is HtmlElement) + if (html is HtmlElement element) { - var element = html as HtmlElement; - - var list = new List(element.Class.Split(" ".ToCharArray(), StringSplitOptions.RemoveEmptyEntries)).Select(x => x.ToLower()).ToList(); - - if (list.Contains(cssClass.ToLower())) - { - list.Remove(cssClass.ToLower()); - } - - var css = string.Join(' ', list); - - element.Class = css; + element.Class = Css.Remove(element.Class, cssClass); } return html; @@ -73,20 +49,9 @@ public static IHtmlNode RemoveClass(this IHtmlNode html, string cssClass) /// The HTML element extended by the checkout. public static IHtmlNode AddStyle(this IHtmlNode html, string cssStyle) { - if (html is HtmlElement) + if (!string.IsNullOrWhiteSpace(cssStyle) && html is HtmlElement element) { - var element = html as HtmlElement; - - var list = new List(element.Style.Split(" ".ToCharArray(), StringSplitOptions.RemoveEmptyEntries)).Select(x => x.ToLower()).ToList(); - - if (!list.Contains(cssStyle.ToLower())) - { - list.Add(cssStyle.ToLower()); - } - - var css = string.Join(' ', list); - - element.Style = css; + element.Style = Css.Concatenate([.. element.Style?.Split(" "), .. cssStyle.Split(" ")]); } return html; @@ -100,20 +65,9 @@ public static IHtmlNode AddStyle(this IHtmlNode html, string cssStyle) /// The HTML element reduced by the checkout. public static IHtmlNode RemoveStyle(this IHtmlNode html, string cssStyle) { - if (html is HtmlElement) + if (html is HtmlElement element) { - var element = html as HtmlElement; - - var list = new List(element.Style.Split(" ".ToCharArray(), StringSplitOptions.RemoveEmptyEntries)).Select(x => x.ToLower()).ToList(); - - if (list.Contains(cssStyle?.ToLower())) - { - list.Remove(cssStyle.ToLower()); - } - - var css = string.Join(' ', list); - - element.Style = css; + element.Style = Css.Remove(element.Style, cssStyle); } return html; @@ -169,5 +123,54 @@ public static IEnumerable Find(this IEnumerable nodes, Fun } } + /// + /// Sets the valueless user-defined attribute. + /// + /// The HTML node. + /// The attribute name. + /// The current instance for method chaining. + public static IHtmlNode AddUserAttribute(this IHtmlNode html, string name) + { + if (html is HtmlElement element) + { + element.AddUserAttribute(name); + } + + return html; + } + + /// + /// Sets the value of an user-defined attribute. + /// + /// The HTML node. + /// The attribute name. + /// The value of the attribute. + /// The current instance for method chaining. + public static IHtmlNode AddUserAttribute(this IHtmlNode html, string name, string value) + { + if (html is HtmlElement element) + { + element.AddUserAttribute(name, value); + } + + return html; + } + + /// + /// Removes an user-defined attribute. + /// + /// The HTML node. + /// The attribute name. + /// The current instance for method chaining. + public static IHtmlNode RemoveUserAttribute(this IHtmlNode html, string name) + { + if (html is HtmlElement element) + { + element.RemoveUserAttribute(name); + } + + return html; + } + } } From 704a50e22bdcafeaf63efdffacf5ae20731fb7dc Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 28 Sep 2025 17:20:32 +0200 Subject: [PATCH 32/51] add: GitHub actions workflow for C# build and xUnit tests --- .github/workflows/test.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e69de29 From fa16f3fc270e7b13386c510b6c046e7730676d79 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 28 Sep 2025 17:23:50 +0200 Subject: [PATCH 33/51] fix: GitHub actions workflow for C# build and xUnit tests --- .github/workflows/test.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e69de29..e918498 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: CS Tests + +on: + pull_request: + branches: + - main + - develop + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '9.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build project + run: dotnet build --no-restore --configuration Release + + - name: Run xUnit tests + run: dotnet test --no-build --configuration Release --logger "trx" From f527af3a60304ec0c71aaa9f0b6a3229cb53499e Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 28 Sep 2025 17:29:30 +0200 Subject: [PATCH 34/51] add: enable manual trigger via workflow_dispatch --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e918498..2f55a5d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,7 @@ name: CS Tests on: + workflow_dispatch: pull_request: branches: - main From 073053431e1724ad77ccddfb2c1ce48d14771556 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 28 Sep 2025 17:45:09 +0200 Subject: [PATCH 35/51] fix: GitHub actions workflow for C# build and xUnit tests --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2f55a5d..ff6d68d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: CS Tests +name: UnitTests on: workflow_dispatch: @@ -17,7 +17,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: '9.0.x' + dotnet-version: 9.x - name: Restore dependencies run: dotnet restore From 2976d9d9885c0524ee6bceaf7603686d94096e20 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Mon, 29 Sep 2025 17:48:52 +0200 Subject: [PATCH 36/51] feat: general improvements and minor bugs --- src/WebExpress.WebCore/WebExpress.WebCore.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/WebExpress.WebCore/WebExpress.WebCore.csproj b/src/WebExpress.WebCore/WebExpress.WebCore.csproj index 7c2b3bb..c59e015 100644 --- a/src/WebExpress.WebCore/WebExpress.WebCore.csproj +++ b/src/WebExpress.WebCore/WebExpress.WebCore.csproj @@ -24,6 +24,7 @@ true true Debug;Release;DebugLocal + true From 72a539dfde6a84526517858847ca32aa6ce462b7 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Tue, 30 Sep 2025 18:23:42 +0200 Subject: [PATCH 37/51] fix: documentation build --- .github/workflows/generate-docs.yml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index e427ab2..a0be790 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -3,7 +3,7 @@ name: Generate and Deploy Documentation on: push: branches: - - main + - develop permissions: actions: read @@ -36,16 +36,34 @@ jobs: - name: Add DocFX to PATH run: echo "$HOME/.dotnet/tools" >> $GITHUB_PATH + - name: Create wwwroot directory for WebCore + run: mkdir -p ./src/WebExpress.WebCore/wwwroot + + - name: Build WebExpress.WebCore + run: | + dotnet build ./src/WebExpress.WebCore/WebExpress.WebCore.csproj -c Release + - name: Generate documentation run: | cd docs docfx metadata docfx build + - name: Generate API toc.yaml + run: | + echo "### YamlMime:TableOfContent" > docs/api/toc.yaml + echo "[" >> docs/api/toc.yaml + find docs/_site/api -maxdepth 1 -type f -name '*.html' | sort | while read file; do + name=$(basename "$file" .html) + echo " { \"name\": \"$name\", \"href\": \"$name.html\" }," >> docs/api/toc.yaml + done + sed -i '$ s/},/}/' docs/api/toc.yaml + echo "]" >> docs/api/toc.yaml + - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: '_site' + path: './docs/_site' - name: Deploy to GitHub Pages id: deployment From 9ca43d5d384e77bd2466fc9314bc9f8e73f2da57 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Tue, 30 Sep 2025 18:34:17 +0200 Subject: [PATCH 38/51] fix: general improvements and minor bugs --- docs/docfx.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docfx.json b/docs/docfx.json index f71e14c..cf1d708 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -51,7 +51,7 @@ "xref": [ "../.xrefmap.json" ], - "output": "../_site", + "output": "_site", "template": [ "default", "modern", From 43ace7b16c8b505eda4e6e3e18b96e72af3bb48a Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Tue, 30 Sep 2025 18:46:35 +0200 Subject: [PATCH 39/51] fix: general improvements and minor bugs --- docs/toc.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/toc.yml b/docs/toc.yml index 1b4ceba..42dd242 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -1,7 +1,7 @@ - name: Home href: index.md - name: API Documentation - href: api/WebExpress.WebCore.html + href: api/ - name: User Guide href: user-guide.md - name: Tutorials From 938ed2420d950d1a56f6ceab0d84e0426c34d5e3 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Tue, 30 Sep 2025 18:52:38 +0200 Subject: [PATCH 40/51] feat: general improvements and minor bugs --- .github/workflows/generate-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index a0be790..554e7c2 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -3,7 +3,7 @@ name: Generate and Deploy Documentation on: push: branches: - - develop + - main permissions: actions: read From 73dd2b85441d8f1dd3dd2e99c4b89e4230432835 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 5 Oct 2025 17:27:26 +0200 Subject: [PATCH 41/51] feat: improved reverse index and minor bugs --- src/WebExpress.WebCore/WebUri/UriAuthority.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WebExpress.WebCore/WebUri/UriAuthority.cs b/src/WebExpress.WebCore/WebUri/UriAuthority.cs index 8fdb5eb..4c2c8a4 100644 --- a/src/WebExpress.WebCore/WebUri/UriAuthority.cs +++ b/src/WebExpress.WebCore/WebUri/UriAuthority.cs @@ -78,13 +78,13 @@ public virtual string ToString(int defaultPort) var userinfo = string.Join(":", new string[] { User, Password }.Where(x => !string.IsNullOrWhiteSpace(x))); #pragma warning restore 618 - var adress = string.Join(":", new string[] + var address = string.Join(":", new string[] { Host, Port != defaultPort ? Port?.ToString() : "" }.Where(x => !string.IsNullOrWhiteSpace(x))); - return "//" + string.Join("@", new string[] { userinfo, adress }.Where(x => !string.IsNullOrWhiteSpace(x))); + return "//" + string.Join("@", new string[] { userinfo, address }.Where(x => !string.IsNullOrWhiteSpace(x))); } } } \ No newline at end of file From 558301761ce6f6dfba9d294cd3d4a3400da08d70 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Tue, 7 Oct 2025 19:15:25 +0200 Subject: [PATCH 42/51] feat: general improvements and minor bugs --- .../Fixture/UnitTestFixture.cs | 10 +- .../Manager/UnitTestPackageManager.cs | 205 +++++++++- .../WebPackage/IPackageManager.cs | 3 +- .../WebPackage/PackageBuilder.cs | 362 ++++++++++++----- .../WebPackage/PackageManager.cs | 369 ++++++++++++------ 5 files changed, 723 insertions(+), 226 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs index af13749..bf3684b 100644 --- a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs +++ b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs @@ -39,7 +39,7 @@ public static IHttpServerContext CreateHttpServerContextMock() ( new RouteEndpoint("server"), [], - "", + Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()), Environment.CurrentDirectory, Environment.CurrentDirectory, Environment.CurrentDirectory, @@ -52,8 +52,9 @@ public static IHttpServerContext CreateHttpServerContextMock() /// /// Create a component hub. /// + /// The server context. If null, a mock context will be created. /// The component hub. - public static ComponentHub CreateComponentHubMock() + public static ComponentHub CreateComponentHubMock(IHttpServerContext httpServerContext = null) { var ctorComponentHub = typeof(ComponentHub).GetConstructor ( @@ -63,7 +64,10 @@ public static ComponentHub CreateComponentHubMock() null ); - var componentHub = (ComponentHub)ctorComponentHub.Invoke([CreateHttpServerContextMock()]); + var componentHub = (ComponentHub)ctorComponentHub.Invoke + ([ + httpServerContext ?? CreateHttpServerContextMock() + ]); // set static field in the webex class var type = typeof(WebEx); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs index dd8c04c..5e3af6b 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs @@ -1,17 +1,20 @@ -using WebExpress.WebCore.Test.Fixture; +using System.IO.Compression; +using System.Reflection; +using WebExpress.WebCore.Test.Fixture; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebPackage; +using WebExpress.WebCore.WebPackage.Model; namespace WebExpress.WebCore.Test.Manager { /// - /// Test the package manager. + /// Unit tests for the package manager. /// [Collection("NonParallelTests")] public class UnitTestPackageManager { /// - /// Test the register function of the package manager. + /// Tests the register function of the package manager. /// [Fact] public void Register() @@ -25,7 +28,7 @@ public void Register() } /// - /// Test the remove function of the package manager. + /// Tests the remove function of the package manager. /// [Fact] public void Remove() @@ -51,5 +54,197 @@ public void IsIComponentManager() // test execution Assert.True(typeof(IComponentManager).IsAssignableFrom(packageManager.GetType())); } + + /// + /// Tests adding a package and firing the AddPackage event. + /// + [Fact] + public void AddPackageEvent() + { + // preconditions + var componentHub = UnitTestFixture.CreateComponentHubMock(); + var packageManager = componentHub.PackageManager as PackageManager; + bool eventFired = false; + packageManager.AddPackage += (sender, item) => { eventFired = true; }; + + // create dummy package + var package = new PackageCatalogItem() { Id = "test", File = "test.wxp", State = PackageCatalogeItemState.Active }; + + // test execution + var method = typeof(PackageManager).GetMethod("OnAddPackage", BindingFlags.NonPublic | BindingFlags.Instance); + method.Invoke(packageManager, [package]); + + // validation + Assert.True(eventFired); + } + + /// + /// Tests removing a package and firing the RemovePackage event. + /// + [Fact] + public void RemovePackageEvent() + { + // preconditions + var componentHub = UnitTestFixture.CreateComponentHubMock(); + var packageManager = componentHub.PackageManager as PackageManager; + bool eventFired = false; + packageManager.RemovePackage += (sender, item) => { eventFired = true; }; + + // create dummy package + var package = new PackageCatalogItem() { Id = "test", File = "test.wxp", State = PackageCatalogeItemState.Active }; + + // test execution + var method = typeof(PackageManager).GetMethod("OnRemovePackage", BindingFlags.NonPublic | BindingFlags.Instance); + method.Invoke(packageManager, [package]); + + Assert.True(eventFired); + } + + /// + /// Tests that a package can be added, scanned and detected as new. + /// + [Fact] + public void ScanDetectsNewPackage() + { + // preconditions + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var dummyFile = Path.Combine(packagePath, "dummy.wxp"); + + try + { + // create dummy package zip file with valid .spec inside + Directory.CreateDirectory(packagePath); + + using (var zip = ZipFile.Open(dummyFile, ZipArchiveMode.Create)) + { + var entry = zip.CreateEntry("dummy.spec"); + using var writer = new StreamWriter(entry.Open()); + writer.Write(@" + + dummy + 1.0.0 + DummyTitle + UnitTest + "); + } + + // test execution - scan should detect the new file + packageManager.Scan(); + + // validation + Assert.Contains(packageManager.Catalog.Packages, x => x.File == "dummy.wxp"); + + } + finally + { + // cleanup + File.Delete(dummyFile); + Directory.Delete(packagePath, true); + } + } + + /// + /// Tests that removing a package file triggers its removal from the catalog. + /// + [Fact] + public void ScanDetectsRemovedPackage() + { + // preconditions + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var dummyFile = Path.Combine(packagePath, "dummy.wxp"); + + try + { + // place and scan dummy package file + Directory.CreateDirectory(packagePath); + + using (var zip = ZipFile.Open(dummyFile, ZipArchiveMode.Create)) + { + var entry = zip.CreateEntry("dummy.spec"); + using var writer = new StreamWriter(entry.Open()); + writer.Write(@" + + dummy + 1.0.0 + DummyTitle + UnitTest + "); + } + + packageManager.Scan(); + Assert.Contains(packageManager.Catalog.Packages, x => x.File == "dummy.wxp"); + + // remove file and scan again + File.Delete(dummyFile); + + // test execution - scan should detect the removed file + packageManager.Scan(); + + // validation + Assert.DoesNotContain(packageManager.Catalog.Packages, x => x.File == "dummy.wxp"); + + } + finally + { + // cleanup + File.Delete(dummyFile); + Directory.Delete(packagePath, true); + } + } + + /// + /// Tests loading package metadata from a package file. + /// + [Fact] + public void LoadPackageReadsSpec() + { + // preconditions + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var dummyFile = Path.Combine(packagePath, "dummy.wxp"); + + try + { + // create minimal dummy .wxp with .spec inside + Directory.CreateDirectory(packagePath); + + using (var zip = ZipFile.Open(dummyFile, ZipArchiveMode.Create)) + { + var entry = zip.CreateEntry("dummy.spec"); + using var writer = new StreamWriter(entry.Open()); + writer.Write(@" + + dummy + 1.0.0 + DummyTitle + UnitTest + "); + } + // use private LoadPackage method via reflection + var method = typeof(PackageManager).GetMethod("LoadPackage", BindingFlags.NonPublic | BindingFlags.Instance); + + // test execution + var result = method.Invoke(packageManager, [dummyFile]) as PackageCatalogItem; + + // validation + Assert.NotNull(result); + Assert.Equal("dummy", result.Id); + Assert.Equal("DummyTitle", result.Metadata.Title); + } + finally + { + // cleanup + File.Delete(dummyFile); + Directory.Delete(packagePath, true); + } + } } -} +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebPackage/IPackageManager.cs b/src/WebExpress.WebCore/WebPackage/IPackageManager.cs index 315e6d6..8f81a6c 100644 --- a/src/WebExpress.WebCore/WebPackage/IPackageManager.cs +++ b/src/WebExpress.WebCore/WebPackage/IPackageManager.cs @@ -5,7 +5,8 @@ namespace WebExpress.WebCore.WebPackage { /// - /// The package manager manages packages with WebExpress extensions. The packages must be in WebExpressPackage format (*.wxp). + /// The package manager manages packages with WebExpress extensions. The packages must + /// be in WebExpressPackage format (*.wxp). /// public interface IPackageManager : IComponentManager { diff --git a/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs b/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs index e601d24..2ec4ecd 100644 --- a/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs +++ b/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs @@ -2,6 +2,8 @@ using System.IO; using System.IO.Compression; using System.Linq; +using System.Text; +using System.Xml; using System.Xml.Serialization; using WebExpress.WebCore.WebPackage.Model; @@ -26,49 +28,70 @@ public static void Create(string specFile, string config, string targets, string Console.WriteLine($"*** PackageBuilder: targets '{targets}'."); Console.WriteLine($"*** PackageBuilder: outputDirectory '{outputDirectory}'."); + // validate input paths + if (string.IsNullOrWhiteSpace(specFile)) + { + throw new ArgumentException("specFile must not be null or empty.", nameof(specFile)); + } + + if (!File.Exists(specFile)) + { + throw new FileNotFoundException("The specified spec file does not exist.", specFile); + } + var rootDirectory = Path.GetDirectoryName(specFile); + + // secure XML deserialization by prohibiting DTD processing using var fileStream = File.OpenRead(specFile); var serializer = new XmlSerializer(typeof(PackageItemSpec)); - var package = (PackageItemSpec)serializer.Deserialize(fileStream); - var zipFileType = package.Id.Equals("WebExpress") ? "zip" : "wxp"; + using var xmlReader = XmlReader.Create(fileStream, new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null + }); + + var package = (PackageItemSpec)serializer.Deserialize(xmlReader) ?? + throw new InvalidOperationException("Failed to deserialize the spec file."); + var isCorePackage = string.Equals(package.Id, "WebExpress", StringComparison.Ordinal); + var zipFileType = isCorePackage ? "zip" : "wxp"; Console.WriteLine($"*** PackageBuilder: Creates a webex package '{package.Id}' in directory '{outputDirectory}'."); + if (string.IsNullOrWhiteSpace(outputDirectory)) + { + throw new ArgumentException("outputDirectory must not be null or empty.", nameof(outputDirectory)); + } + if (!Directory.Exists(outputDirectory)) { Directory.CreateDirectory(outputDirectory); } - using var zipFileStream = new FileStream(Path.Combine(outputDirectory, $"{package.Id}.{package.Version}.{zipFileType}"), FileMode.Create); + // sanitize output archive file name components + var safeId = SanitizeFileNameComponent(package.Id); + var safeVersion = SanitizeFileNameComponent(package.Version); + var archiveFilePath = Path.Combine(outputDirectory, $"{safeId}.{safeVersion}.{zipFileType}"); + + using var zipFileStream = new FileStream(archiveFilePath, FileMode.Create, FileAccess.Write, FileShare.None); using var archive = new ZipArchive(zipFileStream, ZipArchiveMode.Create, true); // find readme if (!string.IsNullOrWhiteSpace(package.Readme)) { var file = Find(rootDirectory, Path.GetFileName(package.Readme)); - - if (File.Exists(file)) + if (!string.IsNullOrWhiteSpace(file) && File.Exists(file)) { - ReadmeToZip - ( - archive, - File.ReadAllBytes(file) - ); + ReadmeToZip(archive, file); } } - // privacypolicy.md + // privacy policy if (!string.IsNullOrWhiteSpace(package.PrivacyPolicy)) { var file = Find(rootDirectory, Path.GetFileName(package.PrivacyPolicy)); - - if (File.Exists(file)) + if (!string.IsNullOrWhiteSpace(file) && File.Exists(file)) { - PrivacyPolicyToZip - ( - archive, - File.ReadAllBytes(file) - ); + PrivacyPolicyToZip(archive, file); } } @@ -76,33 +99,32 @@ public static void Create(string specFile, string config, string targets, string if (!string.IsNullOrWhiteSpace(package.Icon)) { var file = Find(rootDirectory, Path.GetFileName(package.Icon)); - - if (File.Exists(file)) + if (!string.IsNullOrWhiteSpace(file) && File.Exists(file)) { - IconToZip - ( - archive, - Path.GetFileName(package.Icon), - File.ReadAllBytes(file) - ); + IconToZip(archive, file); } } - // find licenses - foreach (var file in Directory.GetFiles(Path.GetDirectoryName(specFile), "*.lic", SearchOption.AllDirectories)) + // find licenses + if (!string.IsNullOrWhiteSpace(rootDirectory) && Directory.Exists(rootDirectory)) { - if (File.Exists(file)) + try + { + foreach (var licFilePath in Directory.GetFiles(rootDirectory, "*.lic", SearchOption.AllDirectories)) + { + if (!string.IsNullOrWhiteSpace(licFilePath) && File.Exists(licFilePath)) + { + LicensesToZip(archive, licFilePath); + } + } + } + catch (Exception) { - LicensesToZip - ( - archive, - Path.GetFileName(file), - File.ReadAllBytes(file) - ); + // ignore errors while enumerating license files } } - if (!package.Id.Equals("WebExpress")) + if (!isCorePackage) { SpecToZip(archive, package); } @@ -115,13 +137,11 @@ public static void Create(string specFile, string config, string targets, string /// Create the readme file. ///
/// The zip archive. - /// The readme content. - private static void ReadmeToZip(ZipArchive archive, byte[] readme) + /// The readme file path. + private static void ReadmeToZip(ZipArchive archive, string filePath) { - var zipArchiveEntry = archive.CreateEntry("readme.md", CompressionLevel.Fastest); - using var zipStream = zipArchiveEntry.Open(); - zipStream.Write(readme, 0, readme.Length); - + // always use fixed entry name and stream file contents + AddFileToZip(archive, "readme.md", filePath); Console.WriteLine($"*** PackageBuilder: Create the readme file."); } @@ -129,13 +149,11 @@ private static void ReadmeToZip(ZipArchive archive, byte[] readme) /// Create the privacy policy file. ///
/// The zip archive. - /// The privacy policy content. - private static void PrivacyPolicyToZip(ZipArchive archive, byte[] readme) + /// The privacy policy file path. + private static void PrivacyPolicyToZip(ZipArchive archive, string filePath) { - var zipArchiveEntry = archive.CreateEntry("privacypolicy.md", CompressionLevel.Fastest); - using var zipStream = zipArchiveEntry.Open(); - zipStream.Write(readme, 0, readme.Length); - + // always use fixed entry name and stream file contents + AddFileToZip(archive, "privacypolicy.md", filePath); Console.WriteLine($"*** PackageBuilder: Create the privacy policy file."); } @@ -143,14 +161,13 @@ private static void PrivacyPolicyToZip(ZipArchive archive, byte[] readme) /// Create the icon file. ///
/// The zip archive. - /// The icon file name. - /// The icon content. - private static void IconToZip(ZipArchive archive, string fileName, byte[] icon) + /// The icon file path. + private static void IconToZip(ZipArchive archive, string filePath) { - var zipArchiveEntry = archive.CreateEntry($"icon{Path.GetExtension(fileName)}", CompressionLevel.Fastest); - using var zipStream = zipArchiveEntry.Open(); - zipStream.Write(icon, 0, icon.Length); - + // build entry name with original extension + var ext = Path.GetExtension(filePath); + var entryName = $"icon{ext}"; + AddFileToZip(archive, entryName, filePath); Console.WriteLine($"*** PackageBuilder: Create the icon file."); } @@ -158,14 +175,13 @@ private static void IconToZip(ZipArchive archive, string fileName, byte[] icon) /// Create the licenses file. /// /// The zip archive. - /// The licenses file name. - /// The licenses content. - private static void LicensesToZip(ZipArchive archive, string fileName, byte[] icon) + /// The licenses file path. + private static void LicensesToZip(ZipArchive archive, string filePath) { - var zipArchiveEntry = archive.CreateEntry($"licenses/{Path.GetFileNameWithoutExtension(fileName)}.txt", CompressionLevel.Fastest); - using var zipStream = zipArchiveEntry.Open(); - zipStream.Write(icon, 0, icon.Length); - + // convert license file to a normalized .txt entry name while keeping contents as-is + var name = Path.GetFileNameWithoutExtension(filePath); + var entryName = $"licenses/{name}.txt"; + AddFileToZip(archive, entryName, filePath); Console.WriteLine($"*** PackageBuilder: Create the licenses file."); } @@ -176,24 +192,30 @@ private static void LicensesToZip(ZipArchive archive, string fileName, byte[] ic /// The package. private static void SpecToZip(ZipArchive archive, PackageItemSpec package) { - var zipBinarys = package.Id.Equals("WebExpress") ? "bin" : "lib"; - var zipArchiveEntry = archive.CreateEntry($"{package.Id}.spec", CompressionLevel.Fastest); + var zipBinarys = string.Equals(package?.Id, "WebExpress", StringComparison.Ordinal) ? "bin" : "lib"; + var specEntryName = $"{SanitizeFileNameComponent(package?.Id)}.spec"; + var sanitizedEntryName = SanitizeEntryPath(specEntryName); + + var zipArchiveEntry = archive.CreateEntry(sanitizedEntryName, CompressionLevel.Fastest); var serializer = new XmlSerializer(typeof(PackageItemSpec)); using var zipStream = zipArchiveEntry.Open(); + // safely derive icon name if present + var iconName = !string.IsNullOrWhiteSpace(package?.Icon) ? $"icon{Path.GetExtension(package.Icon)}" : null; + var newPackage = new PackageItemSpec() { - Id = package.Id, - Version = package.Version, - Title = package.Title, - Authors = package.Authors, - License = package.License, - LicenseUrl = package.LicenseUrl, - Icon = $"icon{Path.GetExtension(package.Icon)}", - Readme = $"readme.md", - Description = package.Description, - Tags = package.Tags, - Plugins = package.Plugins?.Select(x => $"{zipBinarys}/{Path.GetFileName(x)}").ToArray(), + Id = package?.Id, + Version = package?.Version, + Title = package?.Title, + Authors = package?.Authors, + License = package?.License, + LicenseUrl = package?.LicenseUrl, + Icon = iconName, + Readme = "readme.md", + Description = package?.Description, + Tags = package?.Tags, + Plugins = package?.Plugins?.Select(x => $"{zipBinarys}/{SanitizeFileNameComponent(Path.GetFileName(x))}").ToArray(), }; serializer.Serialize(zipStream, newPackage); @@ -211,26 +233,57 @@ private static void SpecToZip(ZipArchive archive, PackageItemSpec package) /// The target frameworks. Semicolon separated list of target framework moniker (TFM). private static void ProjectToZip(ZipArchive archive, PackageItemSpec package, string path, string config, string targets) { - var zipBinarys = package.Id.Equals("WebExpress") ? "bin" : "lib"; + var zipBinarys = string.Equals(package?.Id, "WebExpress", StringComparison.Ordinal) ? "bin" : "lib"; foreach (var plugin in package?.Plugins ?? Enumerable.Empty()) { var pluginName = Path.GetFileName(plugin); + var safePluginName = SanitizeFileNameComponent(pluginName); + foreach (var target in targets?.Split(';', StringSplitOptions.RemoveEmptyEntries) ?? Enumerable.Empty()) { - var dir = Path.Combine(path, plugin, "bin", config, target); + var safeTarget = SanitizeFileNameComponent(target); + var dir = Path.Combine(path ?? string.Empty, plugin, "bin", config ?? string.Empty, target); + + if (!Directory.Exists(dir)) + { + // skip missing output directories + continue; + } - foreach (var fileName in Directory.GetFiles(dir, "*.*", SearchOption.AllDirectories)) + string[] files = []; + try + { + files = Directory.GetFiles(dir, "*.*", SearchOption.AllDirectories); + } + catch (Exception) + { + // ignore errors while enumerating plugin output files + continue; + } + + foreach (var fileName in files) { if (!string.IsNullOrWhiteSpace(fileName) && File.Exists(fileName)) { - var item = fileName.Replace(dir, ""); - var fileData = File.ReadAllBytes(fileName); - var zipArchiveEntry = archive.CreateEntry($"{zipBinarys}/{pluginName}/{target}{item}", CompressionLevel.Fastest); - using var zipStream = zipArchiveEntry.Open(); - zipStream.Write(fileData, 0, fileData.Length); - - Console.WriteLine($"*** PackageBuilder: Copy the output file '{item}' to {pluginName}."); + // compute relative path robustly + string relativePath; + try + { + relativePath = Path.GetRelativePath(dir, fileName); + } + catch + { + // fallback to file name if relative path fails + relativePath = Path.GetFileName(fileName); + } + + var entryPathRaw = $"{zipBinarys}/{safePluginName}/{safeTarget}/{relativePath}"; + var entryPath = SanitizeEntryPath(entryPathRaw); + + AddFileToZip(archive, entryPath, fileName); + + Console.WriteLine($"*** PackageBuilder: Copy the output file '{relativePath}' to {safePluginName}."); } } } @@ -245,7 +298,7 @@ private static void ProjectToZip(ZipArchive archive, PackageItemSpec package, st /// The root path. private static void ArtifactsToZip(ZipArchive archive, PackageItemSpec package, string path) { - var zipBinarys = package.Id.Equals("WebExpress") ? "bin" : "lib"; + var zipBinarys = string.Equals(package?.Id, "WebExpress", StringComparison.Ordinal) ? "bin" : "lib"; foreach (var item in package?.Artifacts ?? Enumerable.Empty()) { @@ -253,10 +306,9 @@ private static void ArtifactsToZip(ZipArchive archive, PackageItemSpec package, if (!string.IsNullOrWhiteSpace(fileName) && File.Exists(fileName)) { - var fileData = File.ReadAllBytes(fileName); - var zipArchiveEntry = archive.CreateEntry($"{zipBinarys}/{item}", CompressionLevel.Fastest); - using var zipStream = zipArchiveEntry.Open(); - zipStream.Write(fileData, 0, fileData.Length); + // preserve relative subpaths in artifacts while ensuring safe zip paths + var entryPath = SanitizeEntryPath($"{zipBinarys}/{item}"); + AddFileToZip(archive, entryPath, fileName); Console.WriteLine($"*** PackageBuilder: Create the artifact file '{fileName}'."); } @@ -264,28 +316,134 @@ private static void ArtifactsToZip(ZipArchive archive, PackageItemSpec package, } /// - /// Find a file + /// Find a file by walking up the directory tree and searching recursively at each level. /// - /// The path. - /// The file name. - /// The file name, if found or null. + /// The starting path. + /// The file name or trailing path to match. + /// The file name, if found; otherwise null. private static string Find(string path, string fileName) { - try + // validate input + if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(fileName)) { - foreach (var f in Directory.GetFiles(path, "*.*", SearchOption.AllDirectories) - .Where(x => x.Replace('\\', '/').EndsWith(fileName.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase))) + return null; + } + + var normalizedTail = fileName.Replace('\\', '/'); + + // walk upwards until root + while (!string.IsNullOrEmpty(path)) + { + try { - return f; + var matches = Directory + .GetFiles(path, "*.*", SearchOption.AllDirectories) + .Where(x => x.Replace('\\', '/').EndsWith(normalizedTail, StringComparison.OrdinalIgnoreCase)); + + foreach (var f in matches) + { + return f; + } + } + catch (Exception) + { + // ignore errors while enumerating and continue with parent + } + + try + { + path = Directory.GetParent(path)?.FullName; + } + catch + { + path = null; + } + } + + return null; + } + + /// + /// Adds a file to the zip archive using a sanitized entry path and streams the file contents. + /// + /// The zip archive. + /// The entry path inside the zip archive. + /// The source file path to read. + private static void AddFileToZip(ZipArchive archive, string entryPath, string sourceFilePath) + { + // sanitize entry path to prevent zip-slip + var safeEntry = SanitizeEntryPath(entryPath); + var zipArchiveEntry = archive.CreateEntry(safeEntry, CompressionLevel.Fastest); + + using var zipStream = zipArchiveEntry.Open(); + using var fs = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); + fs.CopyTo(zipStream); + } + + /// + /// Sanitizes a filename component by removing invalid characters and path separators. + /// + /// The filename component. + /// A sanitized filename component safe for file and zip entry names. + private static string SanitizeFileNameComponent(string name) + { + if (string.IsNullOrEmpty(name)) + { + return string.Empty; + } + + var invalid = Path.GetInvalidFileNameChars(); + var sb = new StringBuilder(name.Length); + + foreach (var ch in name) + { + var isInvalid = Array.IndexOf(invalid, ch) >= 0 || ch == '/' || ch == '\\' || ch == ':'; + if (isInvalid) + { + // replace invalid characters with underscore + sb.Append('_'); } + else + { + sb.Append(ch); + } + } - path = Directory.GetParent(path)?.FullName; + return sb.ToString(); + } + + /// + /// Sanitizes a ZIP entry path by normalizing separators and removing dangerous segments like "..". + /// + /// The raw entry path. + /// A sanitized entry path safe for inclusion in a zip archive. + private static string SanitizeEntryPath(string entryPath) + { + if (string.IsNullOrWhiteSpace(entryPath)) + { + return string.Empty; } - catch + + // normalize to forward slashes + var path = entryPath.Replace('\\', '/'); + + // remove drive letters and leading slashes + if (path.Length >= 2 && char.IsLetter(path[0]) && path[1] == ':') { + path = path[2..]; } - return null; + path = path.TrimStart('/'); + + // split and filter segments + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + var safeSegments = segments + .Where(s => s != "." && s != "..") + .Select(SanitizeFileNameComponent) + .Where(s => !string.IsNullOrWhiteSpace(s)); + + // join back with forward slashes + return string.Join("/", safeSegments); } } -} +} \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebPackage/PackageManager.cs b/src/WebExpress.WebCore/WebPackage/PackageManager.cs index 8571f43..dc138a8 100644 --- a/src/WebExpress.WebCore/WebPackage/PackageManager.cs +++ b/src/WebExpress.WebCore/WebPackage/PackageManager.cs @@ -20,7 +20,8 @@ namespace WebExpress.WebCore.WebPackage { /// - /// The package manager manages packages with WebExpress extensions. The packages must be in WebExpressPackage format (*.wxp). + /// The package manager manages packages with WebExpress extensions. The packages + /// must be in WebExpressPackage format (*.wxp). /// public sealed class PackageManager : IPackageManager, ISystemComponent { @@ -48,6 +49,11 @@ public sealed class PackageManager : IPackageManager, ISystemComponent /// public PackageCatalog Catalog { get; } = new PackageCatalog(); + /// + /// Synchronization object for scanning and mutating catalog. + /// + private readonly Lock _scanLock = new(); + /// /// Initializes a new instance of the class. /// @@ -132,101 +138,156 @@ public void ShutDown() /// public void Scan() { - _httpServerContext.Log.Debug - ( - I18N.Translate + lock (_scanLock) + { + _httpServerContext.Log.Debug ( - "webexpress.webcore:packagemanager.scan", - _httpServerContext.PackagePath - ) - ); + I18N.Translate + ( + "webexpress.webcore:packagemanager.scan", + _httpServerContext.PackagePath + ) + ); - // determine all WebExpress packages from the file system - var packageFiles = Directory.GetFiles(_httpServerContext.PackagePath, "*.wxp").Select(x => Path.GetFileName(x)).ToList(); + // determine all WebExpress packages from the file system + 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(); + // all packages that are not yet installed + var newPackages = packageFiles.Except(Catalog.Packages.Where(x => x != null).Select(x => x.File)).ToList(); - // all packages that are already installed - //var existingPackages = Catalog.Packages.Select(x => x.File); + // all packages that are no longer available + var removePackages = Catalog.Packages.Where(x => x != null).Select(x => x.File).Except(packageFiles).ToList(); - // all packages that are no longer available - var removePackages = Catalog.Packages.Where(x => x != 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)) + { + var fullPath = Path.Combine(_httpServerContext.PackagePath, existing.File); + if (!File.Exists(fullPath)) + { + continue; + } - foreach (var package in newPackages) - { - var packagesFromFile = LoadPackage(Path.Combine(_httpServerContext.PackagePath, package)); + var fromFile = LoadPackage(fullPath); + if (fromFile == null) + { + continue; + } - ExtractPackage(packagesFromFile); - RegisterPackage(packagesFromFile); - BootPackage(packagesFromFile); + if (HasPackageChanged(existing, fromFile)) + { + changedPackages.Add(existing.File); + } + } - Catalog.Packages.Add(packagesFromFile); + foreach (var package in newPackages) + { + var packagesFromFile = LoadPackage(Path.Combine(_httpServerContext.PackagePath, package)); + if (packagesFromFile == null) + { + continue; + } - _httpServerContext.Log.Debug - ( - I18N.Translate - ( - "webexpress.webcore:packagemanager.add", - package - ) - ); - } + packagesFromFile.State = PackageCatalogeItemState.Active; - foreach (var package in removePackages) - { - var packagesFromFile = LoadPackage(Path.Combine(_httpServerContext.PackagePath, package)); + ExtractPackage(packagesFromFile); + RegisterPackage(packagesFromFile); + BootPackage(packagesFromFile); - Catalog.Packages.Add(packagesFromFile); + Catalog.Packages.Add(packagesFromFile); - _httpServerContext.Log.Debug - ( - I18N.Translate + // raise event for added package + OnAddPackage(packagesFromFile); + + _httpServerContext.Log.Debug ( - "webexpress.webcore:packagemanager.remove", - package - ) - ); - } + I18N.Translate + ( + "webexpress.webcore:packagemanager.add", + package + ) + ); + } - // 2. alle WebExpress-Pakete aus dem Filesystem ermitteln + foreach (var package in changedPackages) + { + var existing = Catalog.Packages.FirstOrDefault(x => x != null && x.File == package); + if (existing == null) + { + continue; + } + var fromFile = LoadPackage(Path.Combine(_httpServerContext.PackagePath, package)); + if (fromFile == null) + { + continue; + } - // 2. Installiere Pakete ermitteln - var packagesFromCatalog = Catalog.Packages; + // respect disabled state; only update metadata without activating + if (existing.State == PackageCatalogeItemState.Disable) + { + existing.Metadata = fromFile.Metadata; + _httpServerContext.Log.Debug($"package '{package}' metadata updated while disabled"); + } + else + { + // deactivate and unload old plugin instances + DeactivateAndUnregisterPackage(existing); + // cleanup extracted content + RemoveExtractedDirectory(existing); + + // update metadata and identification + existing.Id = fromFile.Id; + existing.Metadata = fromFile.Metadata; + existing.State = PackageCatalogeItemState.Active; + + // extract, register and boot new content + ExtractPackage(existing); + RegisterPackage(existing); + BootPackage(existing); + + _httpServerContext.Log.Debug($"package '{package}' updated and reloaded"); + } + } + foreach (var package in removePackages) + { + var existing = Catalog.Packages.FirstOrDefault(x => x != null && x.File == package); + if (existing == null) + { + continue; + } + // deactivate and unload all plugins related to the package + DeactivateAndUnregisterPackage(existing); - // 2 . DLL extrahieren - //webExpressPackages.ForEach(x => x.Files); + // cleanup extracted directory + RemoveExtractedDirectory(existing); + // raise event before removing from catalog + OnRemovePackage(existing); - //foreach () - //{ - // if (!Packages.ContainsKey(packagefile)) - // { - // var package = Package.Open(packagefile); - // Console.WriteLine("Nuspec File: " + package.FileName); - // Console.WriteLine("Nuspec Id: " + package.Id); - // Console.WriteLine("Nuspec Version: " + package.Version); - // Console.WriteLine("Nuspec Autoren: " + package.Authors); - // Console.WriteLine("Nuspec License: " + package.License); - // Console.WriteLine("Nuspec LicenseUrl: " + package.LicenseUrl); - // Console.WriteLine("Nuspec Description: " + package.Description); - // Console.WriteLine("Nuspec Repository: " + package.Repository); - // Console.WriteLine("Nuspec Abhängigkeiten: " + string.Join(",", package.Dependencies.Select(x => x.Id))); + // remove package from catalog + Catalog.Packages.Remove(existing); - // Packages.Add(packagefile, package); - // } - //} + _httpServerContext.Log.Debug + ( + I18N.Translate + ( + "webexpress.webcore:packagemanager.remove", + package + ) + ); + } - if (newPackages.Count != 0 || removePackages.Count != 0) - { - // build sitemap - _componentHub.SitemapManager.Refresh(); + if (newPackages.Count != 0 || removePackages.Count != 0 || changedPackages.Count != 0) + { + // build sitemap + _componentHub.SitemapManager.Refresh(); - // save the catalog - SaveCatalog(); + // save the catalog + SaveCatalog(); + } } } @@ -244,29 +305,18 @@ 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(); - var serializer = new XmlSerializer(typeof(PackageItemSpec)); - var spec = (PackageItemSpec)serializer.Deserialize(specEntry.Open()); - // var files = new List>(); - - // foreach (ZipArchiveEntry entry in zip.Entries.Where(x => Path.GetDirectoryName(x.FullName).StartsWith("lib"))) - // { - // Console.WriteLine("Lib: " + entry?.FullName); - - // using var stream = entry?.Open(); - // using MemoryStream ms = new MemoryStream(); - // stream.CopyTo(ms); - // files.Add(new Tuple(entry?.FullName, ms.ToArray())); - // } - - // foreach (ZipArchiveEntry entry in zip.Entries.Where(x => Path.GetDirectoryName(x.FullName).StartsWith("runtimes"))) - // { - // Console.WriteLine("Runtimes: " + entry?.FullName); + if (specEntry == null) + { + _httpServerContext.Log.Warning($"package spec was not found in '{file}'"); + return null; + } - // using var stream = entry?.Open(); - // using MemoryStream ms = new MemoryStream(); - // stream.CopyTo(ms); - // files.Add(new Tuple(entry?.FullName, ms.ToArray())); - // } + var serializer = new XmlSerializer(typeof(PackageItemSpec)); + PackageItemSpec spec; + using (var stream = specEntry.Open()) + { + spec = (PackageItemSpec)serializer.Deserialize(stream); + } return new PackageCatalogItem() { @@ -365,38 +415,38 @@ private void ExtractPackage(PackageCatalogItem package) { using var zip = ZipFile.Open(packageFile, ZipArchiveMode.Read); - var specEntry = zip.Entries.Where(x => Path.GetExtension(x.FullName) == ".spec").FirstOrDefault(); var extractedPath = Path.Combine(_httpServerContext.PackagePath, Path.GetFileNameWithoutExtension(package?.File)); if (!Directory.Exists(extractedPath)) { Directory.CreateDirectory(extractedPath); - // deleting an existing directory - //Directory.Delete(extractedPath, true); } - foreach (var entry in zip.Entries.Where(x => Path.GetDirectoryName(x.FullName).StartsWith("lib"))) + foreach (var entry in zip.Entries.Where(x => Path.GetDirectoryName(x.FullName).StartsWith("lib", StringComparison.OrdinalIgnoreCase))) { - var entryFileName = Path.Combine(extractedPath, entry?.FullName); - - if (entryFileName.EndsWith('/')) + // directory entries in the zip have an empty Name + if (string.IsNullOrEmpty(entry.Name)) { - if (!Directory.Exists(entryFileName)) + var dirPath = Path.Combine(extractedPath, entry.FullName); + if (!Directory.Exists(dirPath)) { - Directory.CreateDirectory(entryFileName); + Directory.CreateDirectory(dirPath); } + + continue; } - else + + var targetFilePath = Path.Combine(extractedPath, entry.FullName); + var targetDir = Path.GetDirectoryName(targetFilePath); + + if (!Directory.Exists(targetDir)) { - if (!Directory.Exists(Path.GetDirectoryName(entryFileName))) - { - Directory.CreateDirectory(Path.GetDirectoryName(entryFileName)); - } + Directory.CreateDirectory(targetDir); + } - if (!File.Exists(entryFileName)) - { - entry.ExtractToFile(entryFileName); - } + if (!File.Exists(targetFilePath)) + { + entry.ExtractToFile(targetFilePath); } } } @@ -409,7 +459,7 @@ private void ExtractPackage(PackageCatalogItem package) private void RegisterPackage(PackageCatalogItem package) { // load plugins - foreach (var plugin in package?.Metadata.PluginSources ?? []) + foreach (var plugin in package?.Metadata?.PluginSources ?? []) { var pluginContexts = _pluginManager.Register(GetTargetPath(package, plugin)); @@ -427,7 +477,8 @@ private void BootPackage(PackageCatalogItem package) } /// - /// Determines the target directory where the plug-ins of the package are located for the current target platform + /// Determines the target directory where the plug-ins of the package are located + /// for the current target platform. /// /// The package. /// The plugin. @@ -500,6 +551,94 @@ private void Log() _httpServerContext.Log.Info(string.Join(Environment.NewLine, list)); } + /// + /// Checks if a package has changed by comparing spec-relevant metadata. + /// + /// The existing catalog item. + /// The catalog item loaded from file. + /// True if changed; otherwise false. + private static bool HasPackageChanged(PackageCatalogItem existing, PackageCatalogItem fromFile) + { + if (existing == null || fromFile == 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) + { + return false; + } + + if (!string.Equals(existing.Metadata.Version, fromFile.Metadata.Version, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // compare plugin sources sequence-insensitively + var a = (existing.Metadata.PluginSources ?? []).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(); + var b = (fromFile.Metadata.PluginSources ?? []).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(); + + if (a.Length != b.Length) + { + return true; + } + + for (int i = 0; i < a.Length; i++) + { + if (!string.Equals(a[i], b[i], StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + /// Gracefully deactivates a package, shutting down and removing its plugins and + /// clearing the plugin list. + /// + /// The package. + private void DeactivateAndUnregisterPackage(PackageCatalogItem package) + { + if (package == null) + { + return; + } + + // shut down components associated to each plugin and remove the plugin + foreach (var pluginContext in package.Plugins.ToList()) + { + _componentHub.ShutDownComponent(pluginContext); + _pluginManager.Remove(pluginContext); + } + + package.Plugins.Clear(); + package.State = PackageCatalogeItemState.Available; + } + + /// + /// Removes the extracted directory for a package if it exists. + /// + /// The package. + private void RemoveExtractedDirectory(PackageCatalogItem package) + { + var extractedPath = Path.Combine(_httpServerContext.PackagePath, Path.GetFileNameWithoutExtension(package?.File)); + try + { + if (Directory.Exists(extractedPath)) + { + Directory.Delete(extractedPath, true); + } + } + catch (Exception ex) + { + // keep running even if cleanup fails + _httpServerContext.Log.Exception(ex); + } + } + /// /// Release of unmanaged resources reserved during use. /// @@ -507,4 +646,4 @@ public void Dispose() { } } -} +} \ No newline at end of file From 43082600b1b98176914b748c6159c424f5cc3717 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 12 Oct 2025 18:48:01 +0200 Subject: [PATCH 43/51] feat: add include manager, general improvements and minor bugs --- .../Fixture/AssertExtensions.cs | 89 +++++ .../Manager/UnitTestIncludeManager.cs | 163 ++++++++ .../TestIncludeCssA.cs | 16 + .../TestIncludeCssB.cs | 16 + .../TestIncludeJavaScriptA.cs | 16 + .../TestIncludeJavaScriptB.cs | 16 + .../WebAttribute/AssetAttribute.cs | 21 ++ .../WebAttribute/CacheAttribute.cs | 2 +- .../WebAttribute/IIncludeAttribute.cs | 9 + .../WebAttribute/ScopeAttribute.cs | 2 +- .../WebAttribute/SystemPluginAttribute.cs | 3 +- .../WebComponent/ComponentHub.cs | 12 +- .../WebComponent/IComponentHub.cs | 7 + src/WebExpress.WebCore/WebInclude/IInclude.cs | 9 + .../WebInclude/IIncludeContext.cs | 48 +++ .../WebInclude/IIncludeManager.cs | 43 +++ .../WebInclude/IncludeContext.cs | 48 +++ .../WebInclude/IncludeFile.cs | 22 ++ .../WebInclude/IncludeManager.cs | 352 ++++++++++++++++++ .../WebInclude/Model/IncludeDictionary.cs | 50 +++ .../WebInclude/Model/IncludeItem.cs | 91 +++++ .../WebInclude/TypeInclude.cs | 19 + .../WebPlugin/PluginManager.cs | 49 ++- 23 files changed, 1086 insertions(+), 17 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/Fixture/AssertExtensions.cs create mode 100644 src/WebExpress.WebCore.Test/Manager/UnitTestIncludeManager.cs create mode 100644 src/WebExpress.WebCore.Test/TestIncludeCssA.cs create mode 100644 src/WebExpress.WebCore.Test/TestIncludeCssB.cs create mode 100644 src/WebExpress.WebCore.Test/TestIncludeJavaScriptA.cs create mode 100644 src/WebExpress.WebCore.Test/TestIncludeJavaScriptB.cs create mode 100644 src/WebExpress.WebCore/WebAttribute/AssetAttribute.cs create mode 100644 src/WebExpress.WebCore/WebAttribute/IIncludeAttribute.cs create mode 100644 src/WebExpress.WebCore/WebInclude/IInclude.cs create mode 100644 src/WebExpress.WebCore/WebInclude/IIncludeContext.cs create mode 100644 src/WebExpress.WebCore/WebInclude/IIncludeManager.cs create mode 100644 src/WebExpress.WebCore/WebInclude/IncludeContext.cs create mode 100644 src/WebExpress.WebCore/WebInclude/IncludeFile.cs create mode 100644 src/WebExpress.WebCore/WebInclude/IncludeManager.cs create mode 100644 src/WebExpress.WebCore/WebInclude/Model/IncludeDictionary.cs create mode 100644 src/WebExpress.WebCore/WebInclude/Model/IncludeItem.cs create mode 100644 src/WebExpress.WebCore/WebInclude/TypeInclude.cs diff --git a/src/WebExpress.WebCore.Test/Fixture/AssertExtensions.cs b/src/WebExpress.WebCore.Test/Fixture/AssertExtensions.cs new file mode 100644 index 0000000..f241278 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Fixture/AssertExtensions.cs @@ -0,0 +1,89 @@ +using System.Text.RegularExpressions; +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Fixture +{ + /// + /// Provides extension methods for assertions. + /// + public static partial class AssertExtensions + { + /// + /// Gets a regular expression that matches whitespace between '>' and '<' characters. + /// + /// A object that matches whitespace between '>' and '<' characters. + [GeneratedRegex(@">\s+<")] + private static partial Regex WhitespaceRegex(); + + /// + /// Asserts that the actual string is equal to the expected string, allowing for placeholders. + /// + /// The Assert instance (not used, but required for extension method). + /// The expected string with placeholders. + /// The actual string to compare. + public static void EqualWithPlaceholders(string expected, string actual) + { + var str = RemoveLineBreaks(actual?.ToString()); + Assert.True(AreEqualWithPlaceholders(expected, str), $"Expected: {expected}{Environment.NewLine}Actual: {str}"); + } + + /// + /// Asserts that the actual node is equal to the expected string, allowing for placeholders. + /// + /// The Assert instance (not used, but required for extension method). + /// The expected string with placeholders. + /// The actual string to compare. + public static void EqualWithPlaceholders(string expected, IHtmlNode actual) + { + var str = RemoveLineBreaks(actual?.ToString()); + Assert.True(AreEqualWithPlaceholders(expected, str), $"Expected: {expected}{Environment.NewLine}Actual: {str}"); + } + + /// + /// Compares two strings, allowing for placeholders in the expected string. + /// + /// The expected string, which may contain '*' as a wildcard character. + /// The actual string to compare against the expected string. + /// 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) + { + return true; + } + else if (expected != null && actual == null) + { + return false; + } + else if (expected == null && actual != null) + { + return false; + } + + var pattern = "^" + Regex.Escape(expected).Replace(@"\*", ".*") + "$"; + + return Regex.IsMatch(actual, pattern); + } + + /// + /// Removes all line breaks from the input string. + /// + /// The input string from which to remove line breaks. + /// A string with all line breaks removed. + public static string RemoveLineBreaks(string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + + // remove all line breaks + string result = input.Replace("\r\n", "").Replace("\r", "").Replace("\n", ""); + + // remove whitespace of any length between '>' and '<' + result = WhitespaceRegex().Replace(result, "><"); + + return result; + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestIncludeManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestIncludeManager.cs new file mode 100644 index 0000000..c194bdd --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestIncludeManager.cs @@ -0,0 +1,163 @@ +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebInclude; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Test the include manager. + /// + [Collection("NonParallelTests")] + public class UnitTestIncludeManager + { + /// + /// Test the register function of the include manager. + /// + [Fact] + public void Register() + { + // preconditions + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + + // test execution + Assert.Equal(12, componentHub.IncludeManager.Includes.Count()); + } + + /// + /// Test the remove function of the include manager. + /// + [Fact] + public void Remove() + { + // preconditions + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var plugin = componentHub.PluginManager.GetPlugin(typeof(TestPlugin)); + var includeManager = componentHub.IncludeManager as IncludeManager; + + // test execution + includeManager.Remove(plugin); + + // validation + Assert.Empty(componentHub.IncludeManager.Includes); + } + + /// + /// Test the id property of the include. + /// + [Theory] + [InlineData(typeof(TestApplicationA), typeof(TestIncludeJavaScriptA), "webexpress.webcore.test.testincludejavascripta")] + [InlineData(typeof(TestApplicationA), typeof(TestIncludeJavaScriptB), "webexpress.webcore.test.testincludejavascriptb")] + [InlineData(typeof(TestApplicationA), typeof(TestIncludeCssA), "webexpress.webcore.test.testincludecssa")] + [InlineData(typeof(TestApplicationA), typeof(TestIncludeCssB), "webexpress.webcore.test.testincludecssb")] + [InlineData(typeof(TestApplicationB), typeof(TestIncludeJavaScriptA), "webexpress.webcore.test.testincludejavascripta")] + [InlineData(typeof(TestApplicationB), typeof(TestIncludeJavaScriptB), "webexpress.webcore.test.testincludejavascriptb")] + [InlineData(typeof(TestApplicationB), typeof(TestIncludeCssA), "webexpress.webcore.test.testincludecssa")] + [InlineData(typeof(TestApplicationB), typeof(TestIncludeCssB), "webexpress.webcore.test.testincludecssb")] + [InlineData(typeof(TestApplicationC), typeof(TestIncludeJavaScriptA), "webexpress.webcore.test.testincludejavascripta")] + [InlineData(typeof(TestApplicationC), typeof(TestIncludeJavaScriptB), "webexpress.webcore.test.testincludejavascriptb")] + [InlineData(typeof(TestApplicationC), typeof(TestIncludeCssA), "webexpress.webcore.test.testincludecssa")] + [InlineData(typeof(TestApplicationC), typeof(TestIncludeCssB), "webexpress.webcore.test.testincludecssb")] + public void Id(Type applicationType, Type includeType, string expected) + { + // preconditions + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); + var include = componentHub.IncludeManager.GetIncludes(application, includeType)?.FirstOrDefault(); + + // test execution + var id = include?.IncludeId.ToString(); + + // validation + AssertExtensions.EqualWithPlaceholders(expected, id); + } + + /// + /// Test the files property of the include. + /// + [Theory] + [InlineData(typeof(TestApplicationA), typeof(TestIncludeJavaScriptA), "/myA.js;/myB.js;/myC.js")] + [InlineData(typeof(TestApplicationA), typeof(TestIncludeJavaScriptB), "/myX.js;/myY.js;/myZ.js")] + [InlineData(typeof(TestApplicationA), typeof(TestIncludeCssA), "/myA.css;/myB.css;/myC.css")] + [InlineData(typeof(TestApplicationA), typeof(TestIncludeCssB), "/myX.css;/myY.css;/myZ.css")] + [InlineData(typeof(TestApplicationB), typeof(TestIncludeJavaScriptA), "/myA.js;/myB.js;/myC.js")] + [InlineData(typeof(TestApplicationB), typeof(TestIncludeJavaScriptB), "/myX.js;/myY.js;/myZ.js")] + [InlineData(typeof(TestApplicationB), typeof(TestIncludeCssA), "/myA.css;/myB.css;/myC.css")] + [InlineData(typeof(TestApplicationB), typeof(TestIncludeCssB), "/myX.css;/myY.css;/myZ.css")] + [InlineData(typeof(TestApplicationC), typeof(TestIncludeJavaScriptA), "/myA.js;/myB.js;/myC.js")] + [InlineData(typeof(TestApplicationC), typeof(TestIncludeJavaScriptB), "/myX.js;/myY.js;/myZ.js")] + [InlineData(typeof(TestApplicationC), typeof(TestIncludeCssA), "/myA.css;/myB.css;/myC.css")] + [InlineData(typeof(TestApplicationC), typeof(TestIncludeCssB), "/myX.css;/myY.css;/myZ.css")] + public void Files(Type applicationType, Type resourceType, string expected) + { + // preconditions + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); + var include = componentHub.IncludeManager.GetIncludes(application, resourceType)?.FirstOrDefault(); + + // test execution + var files = include.Files.Select(x => x.FileName); + + // validation + AssertExtensions.EqualWithPlaceholders(expected, string.Join(";", files)); + } + + /// + /// Test the files property of the include. + /// + [Theory] + [InlineData(typeof(TestApplicationA), typeof(TestIncludeJavaScriptA), "JavaScript;JavaScript;JavaScript")] + [InlineData(typeof(TestApplicationA), typeof(TestIncludeJavaScriptB), "JavaScript;JavaScript;JavaScript")] + [InlineData(typeof(TestApplicationA), typeof(TestIncludeCssA), "StyleSheet;StyleSheet;StyleSheet")] + [InlineData(typeof(TestApplicationA), typeof(TestIncludeCssB), "StyleSheet;StyleSheet;StyleSheet")] + [InlineData(typeof(TestApplicationB), typeof(TestIncludeJavaScriptA), "JavaScript;JavaScript;JavaScript")] + [InlineData(typeof(TestApplicationB), typeof(TestIncludeJavaScriptB), "JavaScript;JavaScript;JavaScript")] + [InlineData(typeof(TestApplicationB), typeof(TestIncludeCssA), "StyleSheet;StyleSheet;StyleSheet")] + [InlineData(typeof(TestApplicationB), typeof(TestIncludeCssB), "StyleSheet;StyleSheet;StyleSheet")] + [InlineData(typeof(TestApplicationC), typeof(TestIncludeJavaScriptA), "JavaScript;JavaScript;JavaScript")] + [InlineData(typeof(TestApplicationC), typeof(TestIncludeJavaScriptB), "JavaScript;JavaScript;JavaScript")] + [InlineData(typeof(TestApplicationC), typeof(TestIncludeCssA), "StyleSheet;StyleSheet;StyleSheet")] + [InlineData(typeof(TestApplicationC), typeof(TestIncludeCssB), "StyleSheet;StyleSheet;StyleSheet")] + public void FileType(Type applicationType, Type resourceType, string expected) + { + // preconditions + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); + var include = componentHub.IncludeManager.GetIncludes(application, resourceType)?.FirstOrDefault(); + + // test execution + var files = include.Files.Select(x => x.Type); + + // validation + AssertExtensions.EqualWithPlaceholders(expected, string.Join(";", files)); + } + + /// + /// Tests whether the include manager implements interface IComponentManager. + /// + [Fact] + public void IsIComponentManager() + { + // preconditions + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + + // test execution + Assert.True(typeof(IComponentManager).IsAssignableFrom(componentHub.IncludeManager.GetType())); + } + + /// + /// Tests whether the include context implements interface IContext. + /// + [Fact] + public void IsIContext() + { + // preconditions + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + + // test execution + 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/TestIncludeCssA.cs b/src/WebExpress.WebCore.Test/TestIncludeCssA.cs new file mode 100644 index 0000000..5ac35ec --- /dev/null +++ b/src/WebExpress.WebCore.Test/TestIncludeCssA.cs @@ -0,0 +1,16 @@ +using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebInclude; + +namespace WebExpress.WebCore.Test +{ + /// + /// A dummy include for testing purposes. + /// + [Asset("/myA.css")] + [Asset("/myB.css")] + [Asset("/myC.css")] + public sealed class TestIncludeCssA : IInclude + { + + } +} diff --git a/src/WebExpress.WebCore.Test/TestIncludeCssB.cs b/src/WebExpress.WebCore.Test/TestIncludeCssB.cs new file mode 100644 index 0000000..fd67770 --- /dev/null +++ b/src/WebExpress.WebCore.Test/TestIncludeCssB.cs @@ -0,0 +1,16 @@ +using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebInclude; + +namespace WebExpress.WebCore.Test +{ + /// + /// A dummy include for testing purposes. + /// + [Asset("/myX.css")] + [Asset("/myY.css")] + [Asset("/myZ.css")] + public sealed class TestIncludeCssB : IInclude + { + + } +} diff --git a/src/WebExpress.WebCore.Test/TestIncludeJavaScriptA.cs b/src/WebExpress.WebCore.Test/TestIncludeJavaScriptA.cs new file mode 100644 index 0000000..d23fd5d --- /dev/null +++ b/src/WebExpress.WebCore.Test/TestIncludeJavaScriptA.cs @@ -0,0 +1,16 @@ +using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebInclude; + +namespace WebExpress.WebCore.Test +{ + /// + /// A dummy include for testing purposes. + /// + [Asset("/myA.js")] + [Asset("/myB.js")] + [Asset("/myC.js")] + public sealed class TestIncludeJavaScriptA : IInclude + { + + } +} diff --git a/src/WebExpress.WebCore.Test/TestIncludeJavaScriptB.cs b/src/WebExpress.WebCore.Test/TestIncludeJavaScriptB.cs new file mode 100644 index 0000000..2943741 --- /dev/null +++ b/src/WebExpress.WebCore.Test/TestIncludeJavaScriptB.cs @@ -0,0 +1,16 @@ +using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebInclude; + +namespace WebExpress.WebCore.Test +{ + /// + /// A dummy include for testing purposes. + /// + [Asset("/myX.js")] + [Asset("/myY.js")] + [Asset("/myZ.js")] + public sealed class TestIncludeJavaScriptB : IInclude + { + + } +} diff --git a/src/WebExpress.WebCore/WebAttribute/AssetAttribute.cs b/src/WebExpress.WebCore/WebAttribute/AssetAttribute.cs new file mode 100644 index 0000000..f9ab64f --- /dev/null +++ b/src/WebExpress.WebCore/WebAttribute/AssetAttribute.cs @@ -0,0 +1,21 @@ +using System; + +namespace WebExpress.WebCore.WebAttribute +{ + /// + /// Specifies a asset (JavaScript or StyleSheet) file to be included. This attribute can be applied multiple times to include + /// multiple asset files. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public class AssetAttribute : Attribute, IIncludeAttribute + { + /// + /// Initializes a new instance of the class with the specified asset file. + /// + /// The path to the asset file associated with this attribute. Cannot be null or empty. + public AssetAttribute(string file) + { + + } + } +} diff --git a/src/WebExpress.WebCore/WebAttribute/CacheAttribute.cs b/src/WebExpress.WebCore/WebAttribute/CacheAttribute.cs index e90b6ab..a3f71e8 100644 --- a/src/WebExpress.WebCore/WebAttribute/CacheAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/CacheAttribute.cs @@ -6,7 +6,7 @@ namespace WebExpress.WebCore.WebAttribute /// Indicates that a page or component can be reused /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] - public class CacheAttribute : Attribute, IEndpointAttribute + public class CacheAttribute : Attribute, IEndpointAttribute, IIncludeAttribute { /// /// Initializes a new instance of the class. diff --git a/src/WebExpress.WebCore/WebAttribute/IIncludeAttribute.cs b/src/WebExpress.WebCore/WebAttribute/IIncludeAttribute.cs new file mode 100644 index 0000000..1869a6b --- /dev/null +++ b/src/WebExpress.WebCore/WebAttribute/IIncludeAttribute.cs @@ -0,0 +1,9 @@ +namespace WebExpress.WebCore.WebAttribute +{ + /// + /// Identifies a class as a include component. + /// + public interface IIncludeAttribute + { + } +} diff --git a/src/WebExpress.WebCore/WebAttribute/ScopeAttribute.cs b/src/WebExpress.WebCore/WebAttribute/ScopeAttribute.cs index ebcae9a..2537895 100644 --- a/src/WebExpress.WebCore/WebAttribute/ScopeAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/ScopeAttribute.cs @@ -12,7 +12,7 @@ namespace WebExpress.WebCore.WebAttribute /// The type of the scope. Must be a class that implements the interface. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] - public class ScopeAttribute : Attribute, IPageAttribute, ISettingPageAttribute + public class ScopeAttribute : Attribute, IPageAttribute, ISettingPageAttribute, IIncludeAttribute where TScope : class, IScope { /// diff --git a/src/WebExpress.WebCore/WebAttribute/SystemPluginAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SystemPluginAttribute.cs index 1962657..3ae8516 100644 --- a/src/WebExpress.WebCore/WebAttribute/SystemPluginAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SystemPluginAttribute.cs @@ -11,7 +11,8 @@ public class SystemPluginAttribute : Attribute /// /// Initializes a new instance of the class. /// - public SystemPluginAttribute() + /// The Id of the plugin to which there is a dependency. + public SystemPluginAttribute(string dependency = null) { } diff --git a/src/WebExpress.WebCore/WebComponent/ComponentHub.cs b/src/WebExpress.WebCore/WebComponent/ComponentHub.cs index cbbea77..ed96034 100644 --- a/src/WebExpress.WebCore/WebComponent/ComponentHub.cs +++ b/src/WebExpress.WebCore/WebComponent/ComponentHub.cs @@ -9,6 +9,7 @@ using WebExpress.WebCore.WebEvent; using WebExpress.WebCore.WebFragment; using WebExpress.WebCore.WebIdentity; +using WebExpress.WebCore.WebInclude; using WebExpress.WebCore.WebJob; using WebExpress.WebCore.WebLog; using WebExpress.WebCore.WebPackage; @@ -40,6 +41,7 @@ public class ComponentHub : IComponentHub private readonly EndpointManager _endpointManager; private readonly AssetManager _assetManager; private readonly ResourceManager _resourceManager; + private readonly IncludeManager _includeManager; private readonly PageManager _pageManager; private readonly SettingPageManager _settingPageManager; private readonly RestApiManager _restApiManager; @@ -78,6 +80,7 @@ public class ComponentHub : IComponentHub _fragmentManager, _assetManager, _resourceManager, + _includeManager, _pageManager, _settingPageManager, _restApiManager, @@ -151,6 +154,12 @@ public class ComponentHub : IComponentHub /// The instance of the resource manager. public IResourceManager ResourceManager => _resourceManager; + /// + /// Returns the include manager. + /// + /// The instance of the include manager. + public IIncludeManager IncludeManager => _includeManager; + /// /// Returns the page manager. /// @@ -231,6 +240,7 @@ protected ComponentHub(IHttpServerContext httpServerContext) _endpointManager = CreateInstance(typeof(EndpointManager)) as EndpointManager; _assetManager = CreateInstance(typeof(AssetManager)) as AssetManager; _resourceManager = CreateInstance(typeof(ResourceManager)) as ResourceManager; + _includeManager = CreateInstance(typeof(IncludeManager)) as IncludeManager; _pageManager = CreateInstance(typeof(PageManager)) as PageManager; _settingPageManager = CreateInstance(typeof(SettingPageManager)) as SettingPageManager; _restApiManager = CreateInstance(typeof(RestApiManager)) as RestApiManager; @@ -521,7 +531,7 @@ private void Log() output.Add ( string.Empty.PadRight(2) + - _internationalizationManager.Translate("webexpress.webcore:componentmanager.name", manager.GetType()?.Name.ToLower()) + _internationalizationManager.Translate("webexpress.webcore:componentmanager.name", manager?.GetType()?.Name.ToLower()) ); } diff --git a/src/WebExpress.WebCore/WebComponent/IComponentHub.cs b/src/WebExpress.WebCore/WebComponent/IComponentHub.cs index 13ac642..c5bbaef 100644 --- a/src/WebExpress.WebCore/WebComponent/IComponentHub.cs +++ b/src/WebExpress.WebCore/WebComponent/IComponentHub.cs @@ -7,6 +7,7 @@ using WebExpress.WebCore.WebEvent; using WebExpress.WebCore.WebFragment; using WebExpress.WebCore.WebIdentity; +using WebExpress.WebCore.WebInclude; using WebExpress.WebCore.WebJob; using WebExpress.WebCore.WebLog; using WebExpress.WebCore.WebPackage; @@ -103,6 +104,12 @@ public interface IComponentHub : IComponentManager /// The instance of the resource manager. IResourceManager ResourceManager { get; } + /// + /// Returns the include manager. + /// + /// The instance of the include manager. + IIncludeManager IncludeManager { get; } + /// /// Returns the page manager. /// diff --git a/src/WebExpress.WebCore/WebInclude/IInclude.cs b/src/WebExpress.WebCore/WebInclude/IInclude.cs new file mode 100644 index 0000000..0a7a26e --- /dev/null +++ b/src/WebExpress.WebCore/WebInclude/IInclude.cs @@ -0,0 +1,9 @@ +namespace WebExpress.WebCore.WebInclude +{ + /// + /// Marker interface for include components. + /// + public interface IInclude + { + } +} diff --git a/src/WebExpress.WebCore/WebInclude/IIncludeContext.cs b/src/WebExpress.WebCore/WebInclude/IIncludeContext.cs new file mode 100644 index 0000000..e93e5b4 --- /dev/null +++ b/src/WebExpress.WebCore/WebInclude/IIncludeContext.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.WebInclude +{ + /// + /// The include context describes an include registration per plugin and application including files, kind, and scopes. + /// + public interface IIncludeContext : IContext + { + /// + /// Retruns the identifier of the included component. + /// + ComponentId IncludeId { get; } + + /// + /// Retruns the context for the plugin, providing access to shared resources and services. + /// + IPluginContext PluginContext { get; } + + /// + /// Returns the application context that provides configuration and services for the application. + /// + IApplicationContext ApplicationContext { get; } + + /// + /// Returns a value indicating whether caching is enabled. + /// + bool Cache { get; } + + /// + /// Returns the collection of files to be included. + /// + IEnumerable Files { get; } + + /// + /// Retruns the collection of scopes associated with the current context. + /// + /// + /// The collection can be empty if no scopes are defined. Callers can set this property + /// to customize the applicable scopes or retrieve it to inspect the current configuration. + /// + IEnumerable Scopes { get; } + } +} diff --git a/src/WebExpress.WebCore/WebInclude/IIncludeManager.cs b/src/WebExpress.WebCore/WebInclude/IIncludeManager.cs new file mode 100644 index 0000000..90ac4e0 --- /dev/null +++ b/src/WebExpress.WebCore/WebInclude/IIncludeManager.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; + +namespace WebExpress.WebCore.WebInclude +{ + /// + /// Defines the include manager contract. It exposes an inventory of include contexts and raises add/remove events. + /// + public interface IIncludeManager : IComponentManager + { + /// + /// An event that fires when an include is added. + /// + event EventHandler AddInclude; + + /// + /// An event that fires when an include is removed. + /// + event EventHandler RemoveInclude; + + /// + /// Returns all include contexts. + /// + IEnumerable Includes { get; } + + /// + /// Returns include contexts for a given application. + /// + /// The application context. + /// Enumerable of include contexts. + IEnumerable GetIncludes(IApplicationContext applicationContext); + + /// + /// Returns include contexts for a given application and include type. + /// + /// The application context. + /// The include class type. + /// Enumerable of include contexts. + IEnumerable GetIncludes(IApplicationContext applicationContext, Type includeType); + } +} diff --git a/src/WebExpress.WebCore/WebInclude/IncludeContext.cs b/src/WebExpress.WebCore/WebInclude/IncludeContext.cs new file mode 100644 index 0000000..e9a9027 --- /dev/null +++ b/src/WebExpress.WebCore/WebInclude/IncludeContext.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.WebInclude +{ + /// + /// Default include context implementation. + /// + internal sealed class IncludeContext : IIncludeContext + { + /// + /// Retruns or sets the identifier of the included component. + /// + public ComponentId IncludeId { get; set; } + + /// + /// Retruns or sets the context for the plugin, providing access to shared resources and services. + /// + public IPluginContext PluginContext { get; set; } + + /// + /// Returns or sets the application context that provides configuration and services for the application. + /// + public IApplicationContext ApplicationContext { get; set; } + + /// + /// Returns or sets a value indicating whether caching is enabled. + /// + public bool Cache { get; set; } + + /// + /// Returns or sets the collection of files to be included. + /// + public IEnumerable Files { get; set; } = []; + + /// + /// Retruns or sets the collection of scopes associated with the current context. + /// + /// + /// The collection can be empty if no scopes are defined. Callers can set this property + /// to customize the applicable scopes or retrieve it to inspect the current configuration. + /// + public IEnumerable Scopes { get; set; } = []; + } +} diff --git a/src/WebExpress.WebCore/WebInclude/IncludeFile.cs b/src/WebExpress.WebCore/WebInclude/IncludeFile.cs new file mode 100644 index 0000000..6419968 --- /dev/null +++ b/src/WebExpress.WebCore/WebInclude/IncludeFile.cs @@ -0,0 +1,22 @@ +namespace WebExpress.WebCore.WebInclude +{ + /// + /// Represents a file to be included, along with its associated type information. + /// + /// + /// This class is used to specify a file and its corresponding type for inclusion in a process or + /// operation. + /// + public class IncludeFile + { + /// + /// Returns or sets the type to be included in the operation. + /// + public TypeInclude Type { get; set; } + + /// + /// Returns or sets the name of the file, including its extension. + /// + public string FileName { get; set; } + } +} diff --git a/src/WebExpress.WebCore/WebInclude/IncludeManager.cs b/src/WebExpress.WebCore/WebInclude/IncludeManager.cs new file mode 100644 index 0000000..be6ec01 --- /dev/null +++ b/src/WebExpress.WebCore/WebInclude/IncludeManager.cs @@ -0,0 +1,352 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using WebExpress.WebCore.Internationalization; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebInclude.Model; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.WebInclude +{ + /// + /// The include manager discovers and manages JavaScript and CSS include resources on a per-plugin and per-application basis. + /// It raises events upon add/remove and maintains a context view for consumers (e.g., endpoints that serve bundled assets). + /// + public sealed class IncludeManager : IIncludeManager, ISystemComponent, IDisposable + { + private readonly IComponentHub _componentHub; + private readonly IHttpServerContext _httpServerContext; + private readonly IncludeDictionary _dictionary = []; + + /// + /// An event that fires when an include is added. + /// + public event EventHandler AddInclude; + + /// + /// An event that fires when an include is removed. + /// + public event EventHandler RemoveInclude; + + /// + /// Returns all include contexts. + /// + public IEnumerable Includes => _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Select(x => x.IncludeContext); + + /// + /// Initializes a new instance of the include manager. + /// + /// The component hub. + /// The reference to the context of the host. + [SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "Used via Reflection.")] + private IncludeManager(IComponentHub componentHub, IHttpServerContext httpServerContext) + { + _componentHub = componentHub; + _httpServerContext = httpServerContext; + + _componentHub.PluginManager.AddPlugin += OnAddPlugin; + _componentHub.PluginManager.RemovePlugin += OnRemovePlugin; + _componentHub.ApplicationManager.AddApplication += OnAddApplication; + _componentHub.ApplicationManager.RemoveApplication += OnRemoveApplication; + + _httpServerContext.Log.Debug + ( + I18N.Translate("webexpress.webcore:includemanager.initialization") + ); + } + + /// + /// Discovers and binds includes for all applications associated with the plugin. + /// + /// The plugin context. + private void Register(IPluginContext pluginContext) + { + if (pluginContext == null) + { + return; + } + + if (_dictionary.ContainsKey(pluginContext)) + { + return; + } + + Register(pluginContext, _componentHub.ApplicationManager.GetApplications(pluginContext)); + } + + /// + /// Discovers and binds includes for a single application across all its plugins. + /// + /// The application context. + private void Register(IApplicationContext applicationContext) + { + if (applicationContext == null) + { + return; + } + + foreach (var pluginContext in _componentHub.PluginManager.GetPlugins(applicationContext)) + { + if (_dictionary.TryGetValue(pluginContext, out var appDict) && appDict.ContainsKey(applicationContext)) + { + continue; + } + + Register(pluginContext, [applicationContext]); + } + } + + /// + /// Registers includes for a given plugin and application contexts. + /// + /// The plugin context. + /// The application contexts. + private void Register(IPluginContext pluginContext, IEnumerable applicationContexts) + { + var assembly = pluginContext?.Assembly; + + if (assembly == null) + { + return; + } + + foreach (var includeType in assembly.GetTypes() + .Where(x => x.IsClass && x.IsSealed && x.IsPublic) + .Where(x => x.GetInterface(typeof(IInclude).Name) != null)) + { + var id = includeType.FullName?.ToLower(); + var cache = false; + var scopes = new List(); + var files = new List<(TypeInclude, string)>(); + + foreach (var customAttribute in includeType.CustomAttributes + .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IIncludeAttribute)))) + { + if (customAttribute.AttributeType == typeof(CacheAttribute)) + { + cache = true; + } + else if (customAttribute.AttributeType.Name == typeof(ScopeAttribute<>).Name && + customAttribute.AttributeType.Namespace == typeof(ScopeAttribute<>).Namespace) + { + scopes.Add(customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault()); + } + else if (customAttribute.AttributeType == typeof(AssetAttribute)) + { + var file = customAttribute.ConstructorArguments.FirstOrDefault().Value?.ToString(); + var extension = Path.GetExtension(file); + + switch (extension) + { + case ".js": + files.Add((TypeInclude.JavaScript, file)); + break; + case ".css": + files.Add((TypeInclude.StyleSheet, file)); + break; + default: + break; + } + } + } + + // assign the resource to existing applications + foreach (var applicationContext in applicationContexts) + { + var includeContext = new IncludeContext() + { + IncludeId = new ComponentId(id), + PluginContext = pluginContext, + ApplicationContext = applicationContext, + Cache = cache, + Scopes = scopes, + Files = files.Select(x => new IncludeFile() { Type = x.Item1, FileName = x.Item2 }) + }; + + var includeItem = new IncludeItem(_componentHub) + { + IncludeId = includeContext.IncludeId, + PluginContext = pluginContext, + ApplicationContext = applicationContext, + IncludeContext = includeContext, + IncludeClass = includeType, + Cache = cache, + Scopes = scopes, + Files = files.Select(x => new IncludeFile() { Type = x.Item1, FileName = x.Item2 }) + }; + + if (_dictionary.AddIncludeItem(pluginContext, applicationContext, includeItem)) + { + OnAddInclude(includeItem.IncludeContext); + + _httpServerContext?.Log.Debug( + I18N.Translate( + "webexpress.webcore:includemanager.addinclude", + id, + applicationContext.ApplicationId + ) + ); + } + } + + } + } + + /// + /// Removes all includes associated with the plugin context. + /// + /// The plugin context. + internal void Remove(IPluginContext pluginContext) + { + if (pluginContext == null) + { + return; + } + + if (_dictionary.TryGetValue(pluginContext, out var value)) + { + foreach (var includeItem in value.Values.SelectMany(x => x.Values)) + { + OnRemoveInclude(includeItem.IncludeContext); + includeItem.Dispose(); + } + + _dictionary.Remove(pluginContext); + } + } + + /// + /// Removes all includes associated with the application context. + /// + /// The application context. + internal void Remove(IApplicationContext applicationContext) + { + if (applicationContext == null) + { + return; + } + + foreach (var pluginDict in _dictionary.Values) + { + foreach (var appDict in pluginDict.Where(x => x.Key == applicationContext).Select(x => x.Value)) + { + foreach (var includeItem in appDict.Values) + { + OnRemoveInclude(includeItem.IncludeContext); + includeItem.Dispose(); + } + } + + pluginDict.Remove(applicationContext); + } + } + + /// + /// Returns include contexts for a given application. + /// + /// The application context. + /// Enumerable of include contexts. + public IEnumerable GetIncludes(IApplicationContext applicationContext) + { + if (applicationContext == null) + { + return []; + } + + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.IncludeContext.ApplicationContext.Equals(applicationContext)) + .Select(x => x.IncludeContext); + } + + /// + /// Returns include contexts for a given application and include type. + /// + /// The application context. + /// The include class type. + /// Enumerable of include contexts. + public IEnumerable GetIncludes(IApplicationContext applicationContext, Type includeType) + { + if (applicationContext == null || includeType == null) + { + return Enumerable.Empty(); + } + + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .Where(x => x.IncludeClass.Equals(includeType)) + .Where(x => x.IncludeContext.ApplicationContext.Equals(applicationContext)) + .Select(x => x.IncludeContext); + } + + /// + /// Raises the AddInclude event. + /// + /// The include context. + private void OnAddInclude(IIncludeContext includeContext) + { + AddInclude?.Invoke(this, includeContext); + } + + /// + /// Raises the RemoveInclude event. + /// + /// The include context. + private void OnRemoveInclude(IIncludeContext includeContext) + { + RemoveInclude?.Invoke(this, includeContext); + } + + /// + /// Handles plugin added event. + /// + private void OnAddPlugin(object sender, IPluginContext e) + { + Register(e); + } + + /// + /// Handles plugin removed event. + /// + private void OnRemovePlugin(object sender, IPluginContext e) + { + Remove(e); + } + + /// + /// Handles application removed event. + /// + private void OnRemoveApplication(object sender, IApplicationContext e) + { + Remove(e); + } + + /// + /// Handles application added event. + /// + private void OnAddApplication(object sender, IApplicationContext e) + { + Register(e); + } + + /// + /// Disposes registered handlers. + /// + public void Dispose() + { + _componentHub.PluginManager.AddPlugin -= OnAddPlugin; + _componentHub.PluginManager.RemovePlugin -= OnRemovePlugin; + _componentHub.ApplicationManager.AddApplication -= OnAddApplication; + _componentHub.ApplicationManager.RemoveApplication -= OnRemoveApplication; + } + } +} diff --git a/src/WebExpress.WebCore/WebInclude/Model/IncludeDictionary.cs b/src/WebExpress.WebCore/WebInclude/Model/IncludeDictionary.cs new file mode 100644 index 0000000..5da0c95 --- /dev/null +++ b/src/WebExpress.WebCore/WebInclude/Model/IncludeDictionary.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.WebInclude.Model +{ + /// + /// Nested dictionary for includes: Plugin -> Application -> Id -> IncludeItem + /// + internal sealed class IncludeDictionary : Dictionary>> + { + /// + /// Adds an item to the internal collection for the specified plugin and application contexts. + /// + /// The plugin context associated with the item. Cannot be null. + /// The application context associated with the item. Cannot be null. + /// The item to add. Cannot be null. + /// True 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) + { + return false; + } + + if (!TryGetValue(pluginContext, out var appDict)) + { + appDict = new Dictionary>(); + Add(pluginContext, appDict); + } + + if (!appDict.TryGetValue(applicationContext, out var includeMap)) + { + includeMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + appDict.Add(applicationContext, includeMap); + } + + var key = includeItem.IncludeId?.ToString() ?? includeItem.IncludeClass?.FullName?.ToLower() ?? Guid.NewGuid().ToString("N"); + + if (!includeMap.ContainsKey(key)) + { + includeMap[key] = includeItem; + return true; + } + + return false; + } + } +} diff --git a/src/WebExpress.WebCore/WebInclude/Model/IncludeItem.cs b/src/WebExpress.WebCore/WebInclude/Model/IncludeItem.cs new file mode 100644 index 0000000..cee78bd --- /dev/null +++ b/src/WebExpress.WebCore/WebInclude/Model/IncludeItem.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.WebInclude.Model +{ + /// + /// An include item represents a registered include with runtime instance storage (when cached). + /// + internal sealed class IncludeItem : IDisposable + { + private readonly IComponentHub _componentHub; + + /// + /// Retruns or sets the identifier of the included component. + /// + public ComponentId IncludeId { get; set; } + + /// + /// Retruns or sets the context for the plugin, providing access to shared resources and services. + /// + public IPluginContext PluginContext { get; set; } + + /// + /// Returns or sets the application context that provides configuration and services for the application. + /// + public IApplicationContext ApplicationContext { get; set; } + + /// + /// Returns or sets the context used to manage include operations for related entities. + /// + /// + /// This property is typically used to specify the context for including related entities + /// in queries. + /// + public IIncludeContext IncludeContext { get; set; } + + /// + /// Type or sets the type of the class to include in the operation. + /// + /// + /// This property is typically used to specify the class type that should be included in + /// a particular operation or process. Ensure that the specified type is compatible with the operation's + /// requirements. + /// + public Type IncludeClass { get; set; } + + /// + /// Returns or sets a value indicating whether caching is enabled. + /// + public bool Cache { get; set; } + + /// + /// Retruns or sets the collection of scopes associated with the current context. + /// + /// + /// The collection can be empty if no scopes are defined. Callers can set this property + /// to customize the applicable scopes or retrieve it to inspect the current configuration. + /// + public IEnumerable Scopes { get; set; } = []; + + /// + /// Returns or sets the collection of files to be included. + /// + public IEnumerable Files { get; set; } = []; + + /// + /// Initializes a new instance of the class with the specified component hub. + /// + /// The component hub used to manage and interact with components. This parameter cannot + /// be null. + public IncludeItem(IComponentHub componentHub) + { + _componentHub = componentHub; + } + + /// + /// Releases all resources used by the current instance of the class. + /// + /// This method is provided to support the pattern. Currently, + /// it does not release any unmanaged resources, but it is included to allow for future + /// extensibility if unmanaged resources are added. + public void Dispose() + { + // no unmanaged resources to release yet + } + } +} diff --git a/src/WebExpress.WebCore/WebInclude/TypeInclude.cs b/src/WebExpress.WebCore/WebInclude/TypeInclude.cs new file mode 100644 index 0000000..be8bdd1 --- /dev/null +++ b/src/WebExpress.WebCore/WebInclude/TypeInclude.cs @@ -0,0 +1,19 @@ +namespace WebExpress.WebCore.WebInclude +{ + /// + /// The include kind specifies whether the include represents JavaScript or CSS. + /// + public enum TypeInclude + { + /// + /// Represents the JavaScript programming language. + /// + JavaScript = 0, + + /// + /// Represents a stylesheet resource, typically used to define the visual presentation of a document + /// or user interface. + /// + StyleSheet = 1 + } +} diff --git a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs index fb9aaf3..e9587a9 100644 --- a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs +++ b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs @@ -169,6 +169,10 @@ private IEnumerable Register(Assembly assembly, PluginLoadContex // system plugins without plugin class (e.g. webexpress.webui) if (assembly.GetCustomAttribute() != 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 id = new ComponentId(assembly.GetName().Name.ToLower()); var pluginContext = new PluginContext() { @@ -180,24 +184,43 @@ private IEnumerable Register(Assembly assembly, PluginLoadContex Version = assembly.GetCustomAttribute()?.InformationalVersion }; + var hasUnfulfilledDependencies = HasUnfulfilledDependencies(id, dependencies.Select(x => new ComponentId(x))); + if (!_dictionary.ContainsKey(id)) { - _dictionary.Add(id, new PluginItem() + if (hasUnfulfilledDependencies) { - PluginLoadContext = loadContext, - PluginClass = assembly.ExportedTypes.FirstOrDefault() ?? typeof(IPlugin), - PluginContext = pluginContext, - Plugin = null, - Dependencies = null, - ApplicationTypes = [typeof(IApplication)] - }); + _unfulfilledDependencies.Add(id, new PluginItem() + { + PluginLoadContext = loadContext, + PluginClass = assembly.ExportedTypes.FirstOrDefault() ?? typeof(IPlugin), + PluginContext = pluginContext, + Plugin = null, + Dependencies = dependencies, + ApplicationTypes = [typeof(IApplication)] + }); + } + else if (!_dictionary.ContainsKey(id)) + { + _dictionary.Add(id, new PluginItem() + { + PluginLoadContext = loadContext, + PluginClass = assembly.ExportedTypes.FirstOrDefault() ?? typeof(IPlugin), + PluginContext = pluginContext, + Plugin = null, + Dependencies = dependencies, + ApplicationTypes = [typeof(IApplication)] + }); + + _httpServerContext.Log.Debug + ( + I18N.Translate("webexpress.webcore:pluginmanager.created", id) + ); - _httpServerContext.Log.Debug - ( - I18N.Translate("webexpress.webcore:pluginmanager.created", id) - ); + OnAddPlugin(pluginContext); - OnAddPlugin(pluginContext); + CheckUnfulfilledDependencies(); + } } else { From e8bb24ec7b4b78ca7a8645269a1467f8a5446ca2 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Tue, 14 Oct 2025 17:20:54 +0200 Subject: [PATCH 44/51] refactor: replace role terminology with policy --- .../Data/MockIdentityFactory.cs | 4 +- .../Data/MockIdentityGroup.cs | 2 +- .../Manager/UnitTestIdentityManager.cs | 20 +-- .../TestIdentityPermissionA.cs | 2 +- .../TestIdentityPermissionB.cs | 2 +- .../TestIdentityPermissionC.cs | 2 +- ...dentityRoleA.cs => TestIdentityPolicyA.cs} | 2 +- ...dentityRoleB.cs => TestIdentityPolicyB.cs} | 2 +- .../Internationalization/de | 4 +- .../Internationalization/en | 4 +- ...{IRoleAttribute.cs => IPolicyAttribute.cs} | 4 +- .../{RoleAttribute.cs => PolicyAttribute.cs} | 6 +- .../WebIdentity/IIdentityGroup.cs | 4 +- .../WebIdentity/IIdentityManager.cs | 22 +-- .../{IIdentityRole.cs => IIdentityPolicy.cs} | 4 +- ...leContext.cs => IIdentityPolicyContext.cs} | 8 +- .../WebIdentity/IdentityManager.cs | 106 ++++++------ ...oleContext.cs => IdentityPolicyContext.cs} | 10 +- .../Model/IdentityPermissionItem.cs | 10 +- .../Model/IdentityPolicyDictionary.cs | 151 +++++++++++++++++ ...ntityRoleItem.cs => IdentityPolicyItem.cs} | 34 ++-- .../Model/IdentityRoleDictionary.cs | 155 ------------------ 22 files changed, 277 insertions(+), 281 deletions(-) rename src/WebExpress.WebCore.Test/{TestIdentityRoleA.cs => TestIdentityPolicyA.cs} (87%) rename src/WebExpress.WebCore.Test/{TestIdentityRoleB.cs => TestIdentityPolicyB.cs} (89%) rename src/WebExpress.WebCore/WebAttribute/{IRoleAttribute.cs => IPolicyAttribute.cs} (52%) rename src/WebExpress.WebCore/WebAttribute/{RoleAttribute.cs => PolicyAttribute.cs} (65%) rename src/WebExpress.WebCore/WebIdentity/{IIdentityRole.cs => IIdentityPolicy.cs} (57%) rename src/WebExpress.WebCore/WebIdentity/{IIdentityRoleContext.cs => IIdentityPolicyContext.cs} (70%) rename src/WebExpress.WebCore/WebIdentity/{IdentityRoleContext.cs => IdentityPolicyContext.cs} (73%) create mode 100644 src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyDictionary.cs rename src/WebExpress.WebCore/WebIdentity/Model/{IdentityRoleItem.cs => IdentityPolicyItem.cs} (66%) delete mode 100644 src/WebExpress.WebCore/WebIdentity/Model/IdentityRoleDictionary.cs diff --git a/src/WebExpress.WebCore.Test/Data/MockIdentityFactory.cs b/src/WebExpress.WebCore.Test/Data/MockIdentityFactory.cs index cd2b9ee..afb77fc 100644 --- a/src/WebExpress.WebCore.Test/Data/MockIdentityFactory.cs +++ b/src/WebExpress.WebCore.Test/Data/MockIdentityFactory.cs @@ -38,12 +38,12 @@ public static IIdentityGroup GetIdentityGroup(string name) private static IEnumerable CreateTestGroups() { var group1 = new MockIdentityGroup(id: Guid.NewGuid(), name: "Admins"); - group1.Assign(["webexpress.webcore.test.testidentityrolea", "webexpress.webcore.test.testidentityroleb"]); + group1.Assign(["webexpress.webcore.test.testidentitypolicya", "webexpress.webcore.test.testidentitypolicyb"]); yield return group1; var group2 = new MockIdentityGroup(id: Guid.NewGuid(), name: "Users"); - group2.Assign(["webexpress.webcore.test.testidentityroleb"]); + group2.Assign(["webexpress.webcore.test.testidentitypolicyb"]); yield return group2; diff --git a/src/WebExpress.WebCore.Test/Data/MockIdentityGroup.cs b/src/WebExpress.WebCore.Test/Data/MockIdentityGroup.cs index e566770..b7ea635 100644 --- a/src/WebExpress.WebCore.Test/Data/MockIdentityGroup.cs +++ b/src/WebExpress.WebCore.Test/Data/MockIdentityGroup.cs @@ -22,7 +22,7 @@ internal class MockIdentityGroup : IIdentityGroup /// /// Returns the roles associated with the group. /// - public IEnumerable Roles => _roles; + public IEnumerable Policies => _roles; /// /// Initializes a new instance of the class with the specified id and name. diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs index 70664ca..9f381a0 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs @@ -23,7 +23,7 @@ public void Register() // test execution Assert.Equal(9, componentHub.IdentityManager.Permissions.Count()); - Assert.Equal(6, componentHub.IdentityManager.Roles.Count()); + Assert.Equal(6, componentHub.IdentityManager.Policies.Count()); } /// @@ -41,7 +41,7 @@ public void Remove() identityManager.Remove(plugin); Assert.Empty(componentHub.IdentityManager.Permissions); - Assert.Empty(componentHub.IdentityManager.Roles); + Assert.Empty(componentHub.IdentityManager.Policies); } /// @@ -115,13 +115,13 @@ public void CheckAccessGroup(Type application, string groupName, Type permission /// Test the CheckAccess function of the identity manager. /// [Theory] - [InlineData(typeof(TestApplicationA), typeof(TestIdentityRoleA), typeof(TestIdentityPermissionA), true)] - [InlineData(typeof(TestApplicationA), typeof(TestIdentityRoleA), typeof(TestIdentityPermissionB), true)] - [InlineData(typeof(TestApplicationA), typeof(TestIdentityRoleA), typeof(TestIdentityPermissionC), true)] - [InlineData(typeof(TestApplicationA), typeof(TestIdentityRoleB), typeof(TestIdentityPermissionA), true)] - [InlineData(typeof(TestApplicationA), typeof(TestIdentityRoleB), typeof(TestIdentityPermissionB), true)] - [InlineData(typeof(TestApplicationA), typeof(TestIdentityRoleB), typeof(TestIdentityPermissionC), false)] - public void CheckAccessRole(Type application, Type role, Type permission, bool expected) + [InlineData(typeof(TestApplicationA), typeof(TestIdentityPolicyA), typeof(TestIdentityPermissionA), true)] + [InlineData(typeof(TestApplicationA), typeof(TestIdentityPolicyA), typeof(TestIdentityPermissionB), true)] + [InlineData(typeof(TestApplicationA), typeof(TestIdentityPolicyA), typeof(TestIdentityPermissionC), true)] + [InlineData(typeof(TestApplicationA), typeof(TestIdentityPolicyB), typeof(TestIdentityPermissionA), true)] + [InlineData(typeof(TestApplicationA), typeof(TestIdentityPolicyB), typeof(TestIdentityPermissionB), true)] + [InlineData(typeof(TestApplicationA), typeof(TestIdentityPolicyB), typeof(TestIdentityPermissionC), false)] + public void CheckAccess(Type application, Type policy, Type permission, bool expected) { // preconditions var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); @@ -129,7 +129,7 @@ public void CheckAccessRole(Type application, Type role, Type permission, bool e var applicationContext = componentHub.ApplicationManager.GetApplications(application).FirstOrDefault(); // test execution - var access = identityManager.CheckAccess(applicationContext, role, permission); + var access = identityManager.CheckAccess(applicationContext, policy, permission); Assert.Equal(expected, access); } diff --git a/src/WebExpress.WebCore.Test/TestIdentityPermissionA.cs b/src/WebExpress.WebCore.Test/TestIdentityPermissionA.cs index a35920b..c032e9c 100644 --- a/src/WebExpress.WebCore.Test/TestIdentityPermissionA.cs +++ b/src/WebExpress.WebCore.Test/TestIdentityPermissionA.cs @@ -8,7 +8,7 @@ namespace WebExpress.WebCore.Test /// [Name("Read")] [Description("Permissions to read.")] - [Role()] + [Policy()] public sealed class TestIdentityPermissionA : IIdentityPermission { /// diff --git a/src/WebExpress.WebCore.Test/TestIdentityPermissionB.cs b/src/WebExpress.WebCore.Test/TestIdentityPermissionB.cs index d45da1c..fa96f28 100644 --- a/src/WebExpress.WebCore.Test/TestIdentityPermissionB.cs +++ b/src/WebExpress.WebCore.Test/TestIdentityPermissionB.cs @@ -8,7 +8,7 @@ namespace WebExpress.WebCore.Test /// [Name("Write")] [Description("Permissions to write.")] - [Role()] + [Policy()] public sealed class TestIdentityPermissionB : IIdentityPermission { /// diff --git a/src/WebExpress.WebCore.Test/TestIdentityPermissionC.cs b/src/WebExpress.WebCore.Test/TestIdentityPermissionC.cs index d20e621..3264306 100644 --- a/src/WebExpress.WebCore.Test/TestIdentityPermissionC.cs +++ b/src/WebExpress.WebCore.Test/TestIdentityPermissionC.cs @@ -8,7 +8,7 @@ namespace WebExpress.WebCore.Test /// [Name("Delte")] [Description("Permissions to delete.")] - [Role()] + [Policy()] public sealed class TestIdentityPermissionC : IIdentityPermission { /// diff --git a/src/WebExpress.WebCore.Test/TestIdentityRoleA.cs b/src/WebExpress.WebCore.Test/TestIdentityPolicyA.cs similarity index 87% rename from src/WebExpress.WebCore.Test/TestIdentityRoleA.cs rename to src/WebExpress.WebCore.Test/TestIdentityPolicyA.cs index c10ddf6..6bb59a5 100644 --- a/src/WebExpress.WebCore.Test/TestIdentityRoleA.cs +++ b/src/WebExpress.WebCore.Test/TestIdentityPolicyA.cs @@ -8,7 +8,7 @@ namespace WebExpress.WebCore.Test /// [Name("Admin")] [Description("Has permissions to create, edit, and delete data.")] - public sealed class TestIdentityRoleA : IIdentityRole + public sealed class TestIdentityPolicyA : IIdentityPolicy { /// /// Releases all resources used by the current instance of the class. diff --git a/src/WebExpress.WebCore.Test/TestIdentityRoleB.cs b/src/WebExpress.WebCore.Test/TestIdentityPolicyB.cs similarity index 89% rename from src/WebExpress.WebCore.Test/TestIdentityRoleB.cs rename to src/WebExpress.WebCore.Test/TestIdentityPolicyB.cs index 0f6abf4..f6acde6 100644 --- a/src/WebExpress.WebCore.Test/TestIdentityRoleB.cs +++ b/src/WebExpress.WebCore.Test/TestIdentityPolicyB.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebCore.Test [Description("Has permissions to create and edit, but not delete.")] [Permission()] [Permission()] - public sealed class TestIdentityRoleB : IIdentityRole + public sealed class TestIdentityPolicyB : IIdentityPolicy { /// /// Releases all resources used by the current instance of the class. diff --git a/src/WebExpress.WebCore/Internationalization/de b/src/WebExpress.WebCore/Internationalization/de index 9300415..0567d68 100644 --- a/src/WebExpress.WebCore/Internationalization/de +++ b/src/WebExpress.WebCore/Internationalization/de @@ -162,8 +162,8 @@ fragmentmanager.fragment=Fragment: '{0}' für die Anwendung '{1}'. identitymanager.initialization=Der Identitymanager wurde initialisiert. identitymanager.registerpermission=Die Berechtigung '{0}' wurde der Anwendung '{1}' zugewiesen und im Identitymanager registriert. identitymanager.duplicatepermission=Die Berechtigung '{0}' wurde bereits in der Anwendung '{1}' registriert. -identitymanager.registerrole=Die Rolle '{0}' wurde der Anwendung '{1}' zugewiesen und im Identitymanager registriert. -identitymanager.duplicaterole=Die Rolle '{0}' wurde bereits in der Anwendung '{1}' registriert. +identitymanager.registerpolicy=Die Policy '{0}' wurde der Anwendung '{1}' zugewiesen und im Identitymanager registriert. +identitymanager.duplicatepolicy=Die Policy '{0}' wurde bereits in der Anwendung '{1}' registriert. thememanager.initialization=Der Thememanager wurde initialisiert. thememanager.addtheme=Das Theme '{0}' wurde in der Anwendung '{1}' registiert. diff --git a/src/WebExpress.WebCore/Internationalization/en b/src/WebExpress.WebCore/Internationalization/en index 9b74b55..9497bee 100644 --- a/src/WebExpress.WebCore/Internationalization/en +++ b/src/WebExpress.WebCore/Internationalization/en @@ -162,8 +162,8 @@ fragmentmanager.fragment=Fragment: '{0}' for application '{1}'. identitymanager.initialization=The identity manager has been initialized. identitymanager.registerpermission=The permission '{0}' has been assigned to the application '{1}' and registered in the identity manager. identitymanager.duplicatepermission=The permission '{0}' has already been registered in the application '{1}'. -identitymanager.registerrole=The role '{0}' has been assigned to the application '{1}' and registered in the identity manager. -identitymanager.duplicaterole=The role '{0}' has already been registered in the application '{1}'. +identitymanager.registerpolicy=The policy '{0}' has been assigned to the application '{1}' and registered in the identity manager. +identitymanager.duplicatepolicy=The policy '{0}' has already been registered in the application '{1}'. thememanager.initialization=The theme manager has been initialized. thememanager.addtheme=The theme '{0}' has been registered in the application '{1}'. diff --git a/src/WebExpress.WebCore/WebAttribute/IRoleAttribute.cs b/src/WebExpress.WebCore/WebAttribute/IPolicyAttribute.cs similarity index 52% rename from src/WebExpress.WebCore/WebAttribute/IRoleAttribute.cs rename to src/WebExpress.WebCore/WebAttribute/IPolicyAttribute.cs index f868701..2d7c410 100644 --- a/src/WebExpress.WebCore/WebAttribute/IRoleAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/IPolicyAttribute.cs @@ -1,9 +1,9 @@ namespace WebExpress.WebCore.WebAttribute { /// - /// Interface of a role assignment attribute. + /// Interface of a policy assignment attribute. /// - public interface IRoleAttribute + public interface IPolicyAttribute { } } diff --git a/src/WebExpress.WebCore/WebAttribute/RoleAttribute.cs b/src/WebExpress.WebCore/WebAttribute/PolicyAttribute.cs similarity index 65% rename from src/WebExpress.WebCore/WebAttribute/RoleAttribute.cs rename to src/WebExpress.WebCore/WebAttribute/PolicyAttribute.cs index 8970557..ead98e0 100644 --- a/src/WebExpress.WebCore/WebAttribute/RoleAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/PolicyAttribute.cs @@ -4,15 +4,15 @@ namespace WebExpress.WebCore.WebAttribute { /// - /// Connects roles with permissions. + /// Connects policies with permissions. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] - public class RoleAttribute : Attribute, IRoleAttribute where T : class, IIdentityRole + public class PolicyAttribute : Attribute, IPolicyAttribute where T : class, IIdentityPolicy { /// /// Initializes a new instance of the class. /// - public RoleAttribute() + public PolicyAttribute() { } diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityGroup.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityGroup.cs index d8a98a8..72fea02 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityGroup.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityGroup.cs @@ -8,8 +8,8 @@ namespace WebExpress.WebCore.WebIdentity public interface IIdentityGroup { /// - /// Returns the roles associated with the group. + /// Returns the policies associated with the group. /// - IEnumerable Roles { get; } + IEnumerable Policies { get; } } } diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs index 34c29be..6d217a1 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs @@ -18,9 +18,9 @@ public interface IIdentityManager : IComponentManager public IEnumerable Permissions { get; } /// - /// Returns all roles. + /// Returns all policies. /// - public IEnumerable Roles { get; } + public IEnumerable Policies { get; } /// /// Returns all identities. @@ -93,23 +93,23 @@ bool CheckAccess(IApplicationContext applicationContext, II bool CheckAccess(IApplicationContext applicationContext, IIdentityGroup group, Type permission); /// - /// Checks if the specified identity role has the given permission. + /// Checks if the specified identity policy has the given permission. /// - /// The type of the identity role. + /// The type of the identity policy. /// The type of the identity permission. /// The context of the application. - /// True if the identity role has the permission, false otherwise. - bool CheckAccess(IApplicationContext applicationContext) - where TIdentityRole : IIdentityRole + /// True if the identity policy has the permission, false otherwise. + bool CheckAccess(IApplicationContext applicationContext) + where TIdentityPolicy : IIdentityPolicy where TIdentityPermission : IIdentityPermission; /// - /// Checks if the specified identity role has the given permission. + /// Checks if the specified identity policy has the given permission. /// /// The context of the application. - /// The identity role to check. + /// The identity policy to check. /// The permission to check for. - /// True if the identity role has the permission, false otherwise. - bool CheckAccess(IApplicationContext applicationContext, Type role, Type permission); + /// True if the identity policy has the permission, false otherwise. + bool CheckAccess(IApplicationContext applicationContext, Type policy, Type permission); } } diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityRole.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityPolicy.cs similarity index 57% rename from src/WebExpress.WebCore/WebIdentity/IIdentityRole.cs rename to src/WebExpress.WebCore/WebIdentity/IIdentityPolicy.cs index b48dc12..740de32 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityRole.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityPolicy.cs @@ -3,9 +3,9 @@ namespace WebExpress.WebCore.WebIdentity { /// - /// Interface that defines an identity role. + /// Interface that defines an identity policy. /// - public interface IIdentityRole : IComponent + public interface IIdentityPolicy : IComponent { } } diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityRoleContext.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityPolicyContext.cs similarity index 70% rename from src/WebExpress.WebCore/WebIdentity/IIdentityRoleContext.cs rename to src/WebExpress.WebCore/WebIdentity/IIdentityPolicyContext.cs index 08fea06..d6e9fdb 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityRoleContext.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityPolicyContext.cs @@ -5,14 +5,14 @@ namespace WebExpress.WebCore.WebIdentity { /// - /// Defines the context for a role, providing access to various related contexts and properties. + /// Defines the context for a policy, providing access to various related contexts and properties. /// - public interface IIdentityRoleContext : IContext + public interface IIdentityPolicyContext : IContext { /// - /// Returns the role id. + /// Returns the policy id. /// - IComponentId RoleId { get; } + IComponentId PolicyId { get; } /// /// Returns the associated plugin context. diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs index 9b2dea0..531a541 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs @@ -25,7 +25,7 @@ public class IdentityManager : IIdentityManager private readonly IComponentHub _componentHub; private readonly IHttpServerContext _httpServerContext; private readonly IdentityPermissionDictionary _permissionDictionary = []; - private readonly IdentityRoleDictionary _roleDictionary = []; + private readonly IdentityPolicyDictionary _policyDictionary = []; /// /// Returns all permissions. @@ -36,12 +36,12 @@ public class IdentityManager : IIdentityManager .Select(x => x.PermissionContext); /// - /// Returns all roles. + /// Returns all policies. /// - public IEnumerable Roles => _roleDictionary.Values + public IEnumerable Policies => _policyDictionary.Values .SelectMany(x => x.Values) .SelectMany(x => x) - .Select(x => x.RoleContext); + .Select(x => x.PolicyContext); /// /// Returns all identities. @@ -111,7 +111,7 @@ private void Register(IApplicationContext applicationContext) } /// - /// Registers roles and ientities for a given plugin and application context. + /// Registers policies and ientities for a given plugin and application context. /// /// The plugin context. /// The application context (optional). @@ -131,17 +131,17 @@ private void Register(IPluginContext pluginContext, IEnumerable(); + var policyTypes = new List(); foreach (var customAttribute in permissionType.CustomAttributes - .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IRoleAttribute)))) + .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IPolicyAttribute)))) { - if (customAttribute.AttributeType.Name == typeof(RoleAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(RoleAttribute<>).Namespace) + if (customAttribute.AttributeType.Name == typeof(PolicyAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(PolicyAttribute<>).Namespace) { var type = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); - if (type != null && !roleTypes.Contains(type)) + if (type != null && !policyTypes.Contains(type)) { - roleTypes.Add(type); + policyTypes.Add(type); } } } @@ -161,7 +161,7 @@ private void Register(IPluginContext pluginContext, IEnumerable x.IsClass == true && x.IsSealed && x.IsPublic && ( - x.GetInterface(typeof(IIdentityRole).Name) != null + x.GetInterface(typeof(IIdentityPolicy).Name) != null ) )) { - var id = new ComponentId(roleType.FullName); + var id = new ComponentId(policyType.FullName); var permissionTypes = new List(); - foreach (var customAttribute in roleType.CustomAttributes + foreach (var customAttribute in policyType.CustomAttributes .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IPermissionAttribute)))) { if (customAttribute.AttributeType.Name == typeof(PermissionAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(PermissionAttribute<>).Namespace) @@ -219,25 +219,25 @@ private void Register(IPluginContext pluginContext, IEnumerable - /// Removes all roles and permissions of an plugin. + /// Removes all policies and permissions of an plugin. /// /// The context of the plugin that contains the identities to remove. internal void Remove(IPluginContext pluginContext) @@ -277,21 +277,21 @@ internal void Remove(IPluginContext pluginContext) _permissionDictionary.Remove(pluginContext); } - // roles - if (_roleDictionary.TryGetValue(pluginContext, out var roleValue)) + // policies + if (_policyDictionary.TryGetValue(pluginContext, out var policyValue)) { - foreach (var permissionItem in roleValue + foreach (var permissionItem in policyValue .SelectMany(x => x.Value)) { permissionItem.Dispose(); } - _roleDictionary.Remove(pluginContext); + _policyDictionary.Remove(pluginContext); } } /// - /// Removes all roles and permissions of an application. + /// Removes all policies and permissions of an application. /// /// The context of the application that contains the identities to remove. internal void Remove(IApplicationContext applicationContext) @@ -315,14 +315,14 @@ internal void Remove(IApplicationContext applicationContext) pluginDict.Remove(applicationContext); } - // roles - foreach (var pluginDict in _roleDictionary.Values) + // policies + foreach (var pluginDict in _policyDictionary.Values) { foreach (var appDict in pluginDict.Where(x => x.Key == applicationContext).Select(x => x.Value)) { - foreach (var roleItem in appDict) + foreach (var policyItem in appDict) { - roleItem.Dispose(); + policyItem.Dispose(); } } @@ -471,9 +471,9 @@ public bool CheckAccess(IApplicationContext applicationContext, IIdentityGrou /// True if the identity group has the permission, false otherwise. public bool CheckAccess(IApplicationContext applicationContext, IIdentityGroup group, Type permission) { - foreach (var role in group?.Roles ?? []) + foreach (var policy in group?.Policies ?? []) { - if (CheckAccess(applicationContext, role, permission)) + if (CheckAccess(applicationContext, policy, permission)) { return true; } @@ -483,58 +483,58 @@ public bool CheckAccess(IApplicationContext applicationContext, IIdentityGroup g } /// - /// Checks if the specified identity role has the given permission. + /// Checks if the specified identity policy has the given permission. /// - /// The type of the identity role. + /// The type of the identity policy. /// The type of the identity permission. /// The context of the application. - /// True if the identity role has the permission, false otherwise. - public bool CheckAccess(IApplicationContext applicationContext) where R : IIdentityRole where P : IIdentityPermission + /// True if the identity policy has the permission, false otherwise. + public bool CheckAccess(IApplicationContext applicationContext) where R : IIdentityPolicy where P : IIdentityPermission { return CheckAccess(applicationContext, typeof(R), typeof(P)); } /// - /// Checks if the specified identity role has the given permission. + /// Checks if the specified identity policy has the given permission. /// /// The context of the application. - /// The identity role to check. + /// The identity policy to check. /// The permission to check for. - /// True if the identity role has the permission, false otherwise. - public bool CheckAccess(IApplicationContext applicationContext, Type roleType, Type permissionType) + /// True if the identity policy has the permission, false otherwise. + public bool CheckAccess(IApplicationContext applicationContext, Type policyType, Type permissionType) { - return CheckAccess(applicationContext, roleType.FullName.ToLower(), permissionType); + return CheckAccess(applicationContext, policyType.FullName.ToLower(), permissionType); } /// - /// Checks if the specified identity role has the given permission. + /// Checks if the specified identity policy has the given permission. /// /// The context of the application. - /// The identity role to check. + /// The identity policy to check. /// The permission to check for. - /// True if the identity role has the permission, false otherwise. - private bool CheckAccess(IApplicationContext applicationContext, string roleName, Type permissionType) + /// True if the identity policy has the permission, false otherwise. + private bool CheckAccess(IApplicationContext applicationContext, string policyName, Type permissionType) { - // roles to permissions - var roles = _roleDictionary.Values.SelectMany(x => x) + // policies to permissions + var policies = _policyDictionary.Values.SelectMany(x => x) .Where(x => x.Key == applicationContext) .SelectMany(entry => entry.Value); - foreach (var role in roles.Where(x => x.RoleClass.FullName.Equals(roleName, StringComparison.CurrentCultureIgnoreCase))) + foreach (var policy in policies.Where(x => x.PolicyClass.FullName.Equals(policyName, StringComparison.CurrentCultureIgnoreCase))) { - if (role.Permissions.Contains(permissionType)) + if (policy.Permissions.Contains(permissionType)) { return true; } } - // permissions to roles + // permissions to policies var permissions = _permissionDictionary.Values.SelectMany(x => x) .Where(x => x.Key == applicationContext) .SelectMany(entry => entry.Value); foreach (var permission in permissions.Where(x => x.PermissionClass == permissionType)) { - if (permission.Roles.Any(x => x.FullName.Equals(roleName, StringComparison.CurrentCultureIgnoreCase))) + if (permission.Policies.Any(x => x.FullName.Equals(policyName, StringComparison.CurrentCultureIgnoreCase))) { return true; } diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityRoleContext.cs b/src/WebExpress.WebCore/WebIdentity/IdentityPolicyContext.cs similarity index 73% rename from src/WebExpress.WebCore/WebIdentity/IdentityRoleContext.cs rename to src/WebExpress.WebCore/WebIdentity/IdentityPolicyContext.cs index 47683e4..fb54336 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityRoleContext.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityPolicyContext.cs @@ -5,14 +5,14 @@ namespace WebExpress.WebCore.WebIdentity { /// - /// Defines the context for a role, providing access to various related contexts and properties. + /// Defines the context for a policy, providing access to various related contexts and properties. /// - public class IdentityRoleContext : IIdentityRoleContext + public class IdentityPolicyContext : IIdentityPolicyContext { /// - /// Returns the role id. + /// Returns the policy id. /// - public IComponentId RoleId { get; internal set; } + public IComponentId PolicyId { get; internal set; } /// /// Returns the associated plugin context. @@ -30,7 +30,7 @@ public class IdentityRoleContext : IIdentityRoleContext /// A string that represents the current object. public override string ToString() { - return $"Role: {RoleId}"; + return $"Policy: {PolicyId}"; } } } diff --git a/src/WebExpress.WebCore/WebIdentity/Model/IdentityPermissionItem.cs b/src/WebExpress.WebCore/WebIdentity/Model/IdentityPermissionItem.cs index 85f7e18..cb833ac 100644 --- a/src/WebExpress.WebCore/WebIdentity/Model/IdentityPermissionItem.cs +++ b/src/WebExpress.WebCore/WebIdentity/Model/IdentityPermissionItem.cs @@ -24,9 +24,9 @@ public class IdentityPermissionItem public IApplicationContext ApplicationContext { get; private set; } /// - /// Returns the roles associated with the permission. + /// Returns the policies associated with the permission. /// - public IEnumerable Roles { get; private set; } + public IEnumerable Policies { get; private set; } /// /// Returns or sets the permission context. @@ -52,13 +52,13 @@ public class IdentityPermissionItem /// The corresponding application context. /// The permission class. /// The permission context. - /// The roles associated with the permission. - public IdentityPermissionItem(IComponentHub componentHub, IHttpServerContext httpServerContext, IPluginContext pluginContext, IApplicationContext applicationContext, Type permissionClass, IIdentityPermissionContext permissionContext, IEnumerable roles) + /// The policies associated with the permission. + public IdentityPermissionItem(IComponentHub componentHub, IHttpServerContext httpServerContext, IPluginContext pluginContext, IApplicationContext applicationContext, Type permissionClass, IIdentityPermissionContext permissionContext, IEnumerable policies) { _componentHub = componentHub; PluginContext = pluginContext; ApplicationContext = applicationContext; - Roles = roles; + Policies = policies; PermissionClass = permissionClass; PermissionContext = permissionContext; Instance = ComponentActivator.CreateInstance(httpServerContext, _componentHub, PermissionClass, PermissionContext); diff --git a/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyDictionary.cs b/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyDictionary.cs new file mode 100644 index 0000000..d47725e --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyDictionary.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.WebIdentity.Model +{ + /// + /// The identity policy directory. + /// + internal class IdentityPolicyDictionary : Dictionary>> + { + /// + /// Adds a policy item to the dictionary. + /// + /// The plugin context. + /// The application context. + /// The policy item. + /// True if the policy item was successfully added, false if an item with the same policy class already exists. + public bool AddPolicyItem(IPluginContext pluginContext, IApplicationContext applicationContext, IdentityPolicyItem policyItem) + { + var type = policyItem.PolicyClass; + + if (!typeof(IIdentityPolicy).IsAssignableFrom(type)) + { + return false; + } + + if (!TryGetValue(pluginContext, out var appContextDict)) + { + appContextDict = []; + this[pluginContext] = appContextDict; + } + + if (!appContextDict.TryGetValue(applicationContext, out var policyList)) + { + policyList = []; + appContextDict[applicationContext] = policyList; + } + + if (policyList.Any(x => x.PolicyClass == type)) + { + return false; // an item with the same policy class already exists + } + + policyList.Add(policyItem); + + return true; + } + + /// + /// Removes a policy item from the dictionary. + /// + /// The plugin context. + /// The application context. + public void RemovePolicyItem(IPluginContext pluginContext, IApplicationContext applicationContext) + where TIdentityPolicy : IIdentityPolicy + { + var type = typeof(TIdentityPolicy); + + if (ContainsKey(pluginContext)) + { + var appContextDict = this[pluginContext]; + + if (appContextDict.TryGetValue(applicationContext, out var policyList)) + { + var itemToRemove = policyList.FirstOrDefault(x => x.PolicyClass == type); + if (itemToRemove != null) + { + policyList.Remove(itemToRemove); + + if (policyList.Count == 0) + { + appContextDict.Remove(applicationContext); + + if (appContextDict.Count == 0) + { + Remove(pluginContext); + } + } + } + } + } + } + + /// + /// Returns the policy items from the dictionary. + /// + /// The type of the policy. + /// The application context. + /// An IEnumerable of policy items + public IEnumerable GetPolicyItems(IApplicationContext applicationContext) + where TIdentityPolicy : IIdentityPolicy + { + return GetPolicyItems(applicationContext, typeof(TIdentityPolicy)); + } + + /// + /// Returns the policy items from the dictionary. + /// + /// The application context. + /// The type of the policy. + /// An IEnumerable of policy items + public IEnumerable GetPolicyItems(IApplicationContext applicationContext, Type policyType) + { + if (!typeof(IIdentityPolicy).IsAssignableFrom(policyType)) + { + return []; + } + + if (ContainsKey(applicationContext?.PluginContext)) + { + var appContextDict = this[applicationContext?.PluginContext]; + + if (appContextDict.TryGetValue(applicationContext, out var policyList)) + { + return policyList.Where(x => x.PolicyClass == policyType); + } + } + + return []; + } + + /// + /// Returns all policy contexts for a given plugin context. + /// + /// The plugin context. + /// An IEnumerable of policy contexts. + public IEnumerable GetPolicyContexts(IPluginContext pluginContext) + { + return this.Where(x => x.Key == pluginContext) + .SelectMany(x => x.Value) + .SelectMany(x => x.Value) + .Select(x => x.PolicyContext); + } + + /// + /// Returns all policy contexts for a given application context. + /// + /// The application context. + /// An IEnumerable of policy contexts. + public IEnumerable GetPolicyContexts(IApplicationContext applicationContext) + { + return Values.SelectMany(x => x) + .Where(x => x.Key == applicationContext) + .SelectMany(entry => entry.Value) + .Select(x => x.PolicyContext); + } + } +} diff --git a/src/WebExpress.WebCore/WebIdentity/Model/IdentityRoleItem.cs b/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyItem.cs similarity index 66% rename from src/WebExpress.WebCore/WebIdentity/Model/IdentityRoleItem.cs rename to src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyItem.cs index 488530b..65f7233 100644 --- a/src/WebExpress.WebCore/WebIdentity/Model/IdentityRoleItem.cs +++ b/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyItem.cs @@ -7,9 +7,9 @@ namespace WebExpress.WebCore.WebIdentity.Model { /// - /// Represents an item in the identity role. + /// Represents an item in the identity policy. /// - public class IdentityRoleItem + public class IdentityPolicyItem { private readonly IComponentHub _componentHub; @@ -24,24 +24,24 @@ public class IdentityRoleItem public IApplicationContext ApplicationContext { get; private set; } /// - /// Returns or sets the role context. + /// Returns or sets the policy context. /// - public IIdentityRoleContext RoleContext { get; private set; } + public IIdentityPolicyContext PolicyContext { get; private set; } /// - /// Returns the permissions associated with the role. + /// Returns the permissions associated with the policy. /// public IEnumerable Permissions { get; private set; } /// - /// Returns or sets the role class. + /// Returns or sets the policy class. /// - public Type RoleClass { get; private set; } + public Type PolicyClass { get; private set; } /// - /// Returns the role instance. + /// Returns the policy instance. /// - public IIdentityRole Instance { get; } + public IIdentityPolicy Instance { get; } /// /// Initializes a new instance of the class. @@ -50,18 +50,18 @@ public class IdentityRoleItem /// The reference to the context of the host. /// The associated plugin context. /// The corresponding application context. - /// The role class. - /// The role context. - /// The permissions associated with the role. - public IdentityRoleItem(IComponentHub componentHub, IHttpServerContext httpServerContext, IPluginContext pluginContext, IApplicationContext applicationContext, Type permissionClass, IIdentityRoleContext roleContext, IEnumerable permissions) + /// The policy class. + /// The policy context. + /// The permissions associated with the policy. + public IdentityPolicyItem(IComponentHub componentHub, IHttpServerContext httpServerContext, IPluginContext pluginContext, IApplicationContext applicationContext, Type permissionClass, IIdentityPolicyContext policyContext, IEnumerable permissions) { _componentHub = componentHub; PluginContext = pluginContext; ApplicationContext = applicationContext; Permissions = permissions; - RoleClass = permissionClass; - RoleContext = roleContext; - Instance = ComponentActivator.CreateInstance(httpServerContext, _componentHub, RoleClass, RoleContext); + PolicyClass = permissionClass; + PolicyContext = policyContext; + Instance = ComponentActivator.CreateInstance(httpServerContext, _componentHub, PolicyClass, PolicyContext); } /// @@ -81,7 +81,7 @@ public void Dispose() /// The event element in its string representation. public override string ToString() { - return $"Role: '{RoleClass.FullName.ToLower()}'"; + return $"Policy: '{PolicyClass.FullName.ToLower()}'"; } } } diff --git a/src/WebExpress.WebCore/WebIdentity/Model/IdentityRoleDictionary.cs b/src/WebExpress.WebCore/WebIdentity/Model/IdentityRoleDictionary.cs deleted file mode 100644 index 8097df8..0000000 --- a/src/WebExpress.WebCore/WebIdentity/Model/IdentityRoleDictionary.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using WebExpress.WebCore.WebApplication; -using WebExpress.WebCore.WebPlugin; - -namespace WebExpress.WebCore.WebIdentity.Model -{ - /// - /// The identity role directory. - /// - internal class IdentityRoleDictionary : Dictionary>> - { - /// - /// Adds a role item to the dictionary. - /// - /// The plugin context. - /// The application context. - /// The role item. - /// True if the role item was successfully added, false if an item with the same role class already exists. - public bool AddRoleItem(IPluginContext pluginContext, IApplicationContext applicationContext, IdentityRoleItem roleItem) - { - var type = roleItem.RoleClass; - - if (!typeof(IIdentityRole).IsAssignableFrom(type)) - { - return false; - } - - if (!TryGetValue(pluginContext, out var appContextDict)) - { - appContextDict = []; - this[pluginContext] = appContextDict; - } - - if (!appContextDict.TryGetValue(applicationContext, out var roleList)) - { - roleList = new List(); - appContextDict[applicationContext] = roleList; - } - - if (roleList.Any(x => x.RoleClass == type)) - { - return false; // an item with the same role class already exists - } - - roleList.Add(roleItem); - - return true; - } - - /// - /// Removes a role item from the dictionary. - /// - /// The plugin context. - /// The application context. - public void RemoveRoleItem(IPluginContext pluginContext, IApplicationContext applicationContext) - where TIdentityRole : IIdentityRole - { - var type = typeof(TIdentityRole); - - if (ContainsKey(pluginContext)) - { - var appContextDict = this[pluginContext]; - - if (appContextDict.ContainsKey(applicationContext)) - { - var roleList = appContextDict[applicationContext]; - - var itemToRemove = roleList.FirstOrDefault(x => x.RoleClass == type); - if (itemToRemove != null) - { - roleList.Remove(itemToRemove); - - if (roleList.Count == 0) - { - appContextDict.Remove(applicationContext); - - if (appContextDict.Count == 0) - { - Remove(pluginContext); - } - } - } - } - } - } - - /// - /// Returns the role items from the dictionary. - /// - /// The type of the role. - /// The application context. - /// An IEnumerable of role items - public IEnumerable GetRoleItems(IApplicationContext applicationContext) - where TIdentityRole : IIdentityRole - { - return GetRoleItems(applicationContext, typeof(TIdentityRole)); - } - - /// - /// Returns the role items from the dictionary. - /// - /// The application context. - /// The type of the role. - /// An IEnumerable of role items - public IEnumerable GetRoleItems(IApplicationContext applicationContext, Type roleType) - { - if (!typeof(IIdentityRole).IsAssignableFrom(roleType)) - { - return Enumerable.Empty(); - } - - if (ContainsKey(applicationContext?.PluginContext)) - { - var appContextDict = this[applicationContext?.PluginContext]; - - if (appContextDict.ContainsKey(applicationContext)) - { - var roleList = appContextDict[applicationContext]; - - return roleList.Where(x => x.RoleClass == roleType); - } - } - - return Enumerable.Empty(); - } - - /// - /// Returns all role contexts for a given plugin context. - /// - /// The plugin context. - /// An IEnumerable of role contexts. - public IEnumerable GetRoleContexts(IPluginContext pluginContext) - { - return this.Where(x => x.Key == pluginContext) - .SelectMany(x => x.Value) - .SelectMany(x => x.Value) - .Select(x => x.RoleContext); - } - - /// - /// Returns all role contexts for a given application context. - /// - /// The application context. - /// An IEnumerable of role contexts. - public IEnumerable GetRoleContexts(IApplicationContext applicationContext) - { - return Values.SelectMany(x => x) - .Where(x => x.Key == applicationContext) - .SelectMany(entry => entry.Value) - .Select(x => x.RoleContext); - } - } -} From ee1b6a6c3f91ad34fc4f899991e9528ab1dbb2fb Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Tue, 14 Oct 2025 17:46:48 +0200 Subject: [PATCH 45/51] fix: include manager improvements --- src/WebExpress.WebCore/Internationalization/de | 4 ++++ src/WebExpress.WebCore/Internationalization/en | 4 ++++ src/WebExpress.WebCore/WebInclude/IncludeManager.cs | 9 +++++++++ 3 files changed, 17 insertions(+) diff --git a/src/WebExpress.WebCore/Internationalization/de b/src/WebExpress.WebCore/Internationalization/de index 0567d68..c54c6b5 100644 --- a/src/WebExpress.WebCore/Internationalization/de +++ b/src/WebExpress.WebCore/Internationalization/de @@ -165,6 +165,10 @@ identitymanager.duplicatepermission=Die Berechtigung '{0}' wurde bereits in der identitymanager.registerpolicy=Die Policy '{0}' wurde der Anwendung '{1}' zugewiesen und im Identitymanager registriert. identitymanager.duplicatepolicy=Die Policy '{0}' wurde bereits in der Anwendung '{1}' registriert. +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. + 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 9497bee..813cdb6 100644 --- a/src/WebExpress.WebCore/Internationalization/en +++ b/src/WebExpress.WebCore/Internationalization/en @@ -165,6 +165,10 @@ identitymanager.duplicatepermission=The permission '{0}' has already been regist identitymanager.registerpolicy=The policy '{0}' has been assigned to the application '{1}' and registered in the identity manager. identitymanager.duplicatepolicy=The policy '{0}' has already been registered in the application '{1}'. +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}'. + 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/WebInclude/IncludeManager.cs b/src/WebExpress.WebCore/WebInclude/IncludeManager.cs index be6ec01..864f76c 100644 --- a/src/WebExpress.WebCore/WebInclude/IncludeManager.cs +++ b/src/WebExpress.WebCore/WebInclude/IncludeManager.cs @@ -215,6 +215,15 @@ internal void Remove(IPluginContext pluginContext) foreach (var includeItem in value.Values.SelectMany(x => x.Values)) { OnRemoveInclude(includeItem.IncludeContext); + + _httpServerContext?.Log.Debug( + I18N.Translate( + "webexpress.webcore:includemanager.removeinclude", + includeItem.IncludeId, + includeItem.ApplicationContext.ApplicationId + ) + ); + includeItem.Dispose(); } From b3e87785b4ec47933b9fb6ab15b4f9532df98404 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Wed, 22 Oct 2025 19:09:59 +0200 Subject: [PATCH 46/51] feat: add rest dropdown control, general improvements and minor bugs --- src/WebExpress.WebCore/WebEx.cs | 50 +++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/src/WebExpress.WebCore/WebEx.cs b/src/WebExpress.WebCore/WebEx.cs index b862598..31f6b7b 100644 --- a/src/WebExpress.WebCore/WebEx.cs +++ b/src/WebExpress.WebCore/WebEx.cs @@ -24,6 +24,32 @@ public sealed class WebEx private static IComponentHub _componentHub; private HttpServer _httpServer; + /// + /// Occurs when the initialization process is completed. + /// + /// + /// Subscribe to this event to perform actions after the initialization is + /// finished. + /// + public event EventHandler Initialization; + + /// + /// Occurs when the start action is triggered. + /// + /// + /// Subscribe to this event to perform actions when the start process begins. + /// + public event EventHandler Start; + + /// + /// Occurs when the application is about to exit. + /// + /// + /// This event is raised just before the application shuts down. It provides an + /// opportunity to perform any necessary cleanup operations. + /// + public event EventHandler Exit; + /// /// Returns or sets the name of the web server. /// @@ -116,16 +142,16 @@ public int Execution(string[] args) } // initialization of the web server - Initialization(ArgumentParser.Current.GetValidArguments(args), Path.Combine(Path.Combine(Environment.CurrentDirectory, "config"), argumentDict["config"])); + OnInitialization(ArgumentParser.Current.GetValidArguments(args), Path.Combine(Path.Combine(Environment.CurrentDirectory, "config"), argumentDict["config"])); // start the manager (_componentHub as ComponentHub).Execute(); // starting the web server - Start(); + OnStart(); // finish - Exit(); + OnExit(); return 0; } @@ -137,7 +163,7 @@ public int Execution(string[] args) /// The event argument. private void OnCancel(object sender, ConsoleCancelEventArgs e) { - Exit(); + OnExit(); } /// @@ -145,7 +171,7 @@ private void OnCancel(object sender, ConsoleCancelEventArgs e) /// /// The valid arguments. /// The configuration file. - private void Initialization(string args, string configFile) + private void OnInitialization(string args, string configFile) { // Config laden using var reader = new FileStream(configFile, FileMode.Open); @@ -241,25 +267,31 @@ private void Initialization(string args, string configFile) } Console.CancelKeyPress += OnCancel; + + Initialization?.Invoke(this, EventArgs.Empty); } /// - /// Start the web server. + /// Initiates the HTTP server and raises the start event. /// - private void Start() + private void OnStart() { _httpServer.Start(); + Start?.Invoke(this, EventArgs.Empty); + Thread.CurrentThread.Join(); } /// - /// Quits the application. + /// Performs cleanup operations when the application is exiting. /// - private void Exit() + private void OnExit() { _httpServer.Stop(); + Exit?.Invoke(this, EventArgs.Empty); + // end of program log _httpServer.HttpServerContext.Log.Seperator('='); _httpServer.HttpServerContext.Log.Info(message: I18N.Translate("webexpress.webcore:app.errors"), args: _httpServer.HttpServerContext.Log.ErrorCount); From 17d293ac0025d1c110ddc2c7157eb6db67aa54f8 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Thu, 23 Oct 2025 17:19:41 +0200 Subject: [PATCH 47/51] feat: general improvements and minor bugs --- .../TestStatusPage404.cs | 7 --- src/WebExpress.WebCore/HttpServer.cs | 38 +++++++++++-- .../Internationalization/de | 1 + .../Internationalization/en | 1 + .../WebAttribute/DescriptionAttribute.cs | 2 +- .../WebMessage/BadRequestException.cs | 19 +++++++ .../RedirectException.cs | 2 +- src/WebExpress.WebCore/WebPage/Page.cs | 3 +- .../WebScope/IScopeStatusPage.cs | 9 +++ .../WebSession/ISessionManager.cs | 20 ++++++- .../WebSession/SessionManager.cs | 57 +++++++++++++++++++ .../WebStatusPage/IStatusPageContext.cs | 5 ++ .../WebStatusPage/StatusPageContext.cs | 7 ++- .../WebStatusPage/StatusPageManager.cs | 18 +++++- 14 files changed, 170 insertions(+), 19 deletions(-) create mode 100644 src/WebExpress.WebCore/WebMessage/BadRequestException.cs rename src/WebExpress.WebCore/{WebPage => WebMessage}/RedirectException.cs (95%) create mode 100644 src/WebExpress.WebCore/WebScope/IScopeStatusPage.cs diff --git a/src/WebExpress.WebCore.Test/TestStatusPage404.cs b/src/WebExpress.WebCore.Test/TestStatusPage404.cs index 9d0504d..ab0bea6 100644 --- a/src/WebExpress.WebCore.Test/TestStatusPage404.cs +++ b/src/WebExpress.WebCore.Test/TestStatusPage404.cs @@ -39,12 +39,5 @@ public void Process(IRenderContext renderContext, VisualTree visualTree) throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); } } - - /// - /// Release of unmanaged resources reserved during use. - /// - public void Dispose() - { - } } } diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index 4e77b39..b91e0f5 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -17,10 +17,10 @@ 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.WebPage; using WebExpress.WebCore.WebSitemap; using WebExpress.WebCore.WebUri; @@ -300,7 +300,7 @@ private Response HandleClient(HttpContext context) // Resource not found response = CreateStatusPage ( - string.Empty, + "Resource not found", request, searchResult ); @@ -317,6 +317,19 @@ private Response HandleClient(HttpContext context) response = new ResponseMovedTemporarily(ex.Uri); } } + catch (BadRequestException ex) + { + 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); @@ -337,7 +350,7 @@ private Response HandleClient(HttpContext context) else { // Resource not found - response = CreateStatusPage(string.Empty, request); + response = CreateStatusPage("Resource not found", request); } stopwatch.Stop(); @@ -437,10 +450,16 @@ private async Task SendResponseAsync(HttpContext context, Response response) private static Response CreateStatusPage(string message, Request request, SearchResult searchResult = null) where T : Response, new() { 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 applicationContext = applicationManager.Applications + .Where(x => route.StartsWith(x.Route.ToString())) + .FirstOrDefault(); if (searchResult != null) { - return WebEx.ComponentHub.StatusPageManager.CreateStatusResponse + return statusPageManager.CreateStatusResponse ( message, response.Status, @@ -449,6 +468,17 @@ private async Task SendResponseAsync(HttpContext context, Response response) ); } + if (applicationContext != null) + { + return statusPageManager.CreateStatusResponse + ( + message, + response.Status, + applicationContext, + request + ); + } + message = $"{response.Status}" + $"

{message}

" + $""; diff --git a/src/WebExpress.WebCore/Internationalization/de b/src/WebExpress.WebCore/Internationalization/de index c54c6b5..53f8faf 100644 --- a/src/WebExpress.WebCore/Internationalization/de +++ b/src/WebExpress.WebCore/Internationalization/de @@ -123,6 +123,7 @@ sitemapmanager.preorder={0} => {1} sitemapmanager.merge.error=Die beiden Sitemaps '{0}' und '{1}' konnten nicht gemerdged werden. sessionmanager.initialization=Der Sessionmanager wurde initialisiert. +sessionmanager.cleanup.removed=Die Sitzung mit der Id '{0}' wurde entfernt. settingpagemanager.initialization=Der Settingpagemanager wurde initialisiert. settingpagemanager.register.category=Die Einstellungskategorie '{0}' wurde der Anwendung '{1}' zugewiesen und im Settingpagemanager registriert. diff --git a/src/WebExpress.WebCore/Internationalization/en b/src/WebExpress.WebCore/Internationalization/en index 813cdb6..7c494be 100644 --- a/src/WebExpress.WebCore/Internationalization/en +++ b/src/WebExpress.WebCore/Internationalization/en @@ -123,6 +123,7 @@ sitemapmanager.preorder={0} => {1} sitemapmanager.merge.error=The two sitemaps '{0}' and '{1}' could not be merged. sessionmanager.initialization=The session manager has been initialized. +sessionmanager.cleanup.removed=The session with the id '{0}' has been removed. settingpagemanager.initialization=The setting page manager has been initialized. settingpagemanager.register.category=The setting category '{0}' has been registered in the application '{1}'. diff --git a/src/WebExpress.WebCore/WebAttribute/DescriptionAttribute.cs b/src/WebExpress.WebCore/WebAttribute/DescriptionAttribute.cs index 750f2a4..874fcf6 100644 --- a/src/WebExpress.WebCore/WebAttribute/DescriptionAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/DescriptionAttribute.cs @@ -7,7 +7,7 @@ namespace WebExpress.WebCore.WebAttribute /// Implements , , and . ///

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] - public class DescriptionAttribute : Attribute, IPluginAttribute, IApplicationAttribute, ISettingCategoryAttribute, ISettingGroupAttribute, IThemeAttribute + public class DescriptionAttribute : Attribute, IPluginAttribute, IApplicationAttribute, ISettingCategoryAttribute, ISettingGroupAttribute, IThemeAttribute, IStatusPageAttribute { /// /// Initializes a new instance of the class. diff --git a/src/WebExpress.WebCore/WebMessage/BadRequestException.cs b/src/WebExpress.WebCore/WebMessage/BadRequestException.cs new file mode 100644 index 0000000..92c3e21 --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/BadRequestException.cs @@ -0,0 +1,19 @@ +using System; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Represents an exception that is thrown when a bad request is encountered. + /// + public class BadRequestException : Exception + { + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public BadRequestException(string message = null) + : base(message ?? "Bad Request") + { + } + } +} diff --git a/src/WebExpress.WebCore/WebPage/RedirectException.cs b/src/WebExpress.WebCore/WebMessage/RedirectException.cs similarity index 95% rename from src/WebExpress.WebCore/WebPage/RedirectException.cs rename to src/WebExpress.WebCore/WebMessage/RedirectException.cs index 823892d..d9e27f0 100644 --- a/src/WebExpress.WebCore/WebPage/RedirectException.cs +++ b/src/WebExpress.WebCore/WebMessage/RedirectException.cs @@ -1,7 +1,7 @@ using System; using WebExpress.WebCore.WebUri; -namespace WebExpress.WebCore.WebPage +namespace WebExpress.WebCore.WebMessage { /// /// Represents an exception that is thrown to redirect a web page. diff --git a/src/WebExpress.WebCore/WebPage/Page.cs b/src/WebExpress.WebCore/WebPage/Page.cs index 860bfe2..3d2251f 100644 --- a/src/WebExpress.WebCore/WebPage/Page.cs +++ b/src/WebExpress.WebCore/WebPage/Page.cs @@ -1,4 +1,5 @@ -using WebExpress.WebCore.WebUri; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebPage { diff --git a/src/WebExpress.WebCore/WebScope/IScopeStatusPage.cs b/src/WebExpress.WebCore/WebScope/IScopeStatusPage.cs new file mode 100644 index 0000000..d1e172c --- /dev/null +++ b/src/WebExpress.WebCore/WebScope/IScopeStatusPage.cs @@ -0,0 +1,9 @@ +namespace WebExpress.WebCore.WebScope +{ + /// + /// An area for status pages. + /// + public interface IScopeStatusPage : IScope + { + } +} diff --git a/src/WebExpress.WebCore/WebSession/ISessionManager.cs b/src/WebExpress.WebCore/WebSession/ISessionManager.cs index 7f81cbf..29befb8 100644 --- a/src/WebExpress.WebCore/WebSession/ISessionManager.cs +++ b/src/WebExpress.WebCore/WebSession/ISessionManager.cs @@ -1,4 +1,5 @@ -using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebSession.Model; @@ -15,5 +16,22 @@ public interface ISessionManager : IComponentManager /// The request. /// The session. Session GetSession(Request request); + + /// + /// 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 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. + ISessionManager CleanUp(IApplicationContext applicationContext, int timeoutMinutes = 60 * 24 * 365); } } diff --git a/src/WebExpress.WebCore/WebSession/SessionManager.cs b/src/WebExpress.WebCore/WebSession/SessionManager.cs index b2108f2..465790d 100644 --- a/src/WebExpress.WebCore/WebSession/SessionManager.cs +++ b/src/WebExpress.WebCore/WebSession/SessionManager.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using WebExpress.WebCore.Internationalization; +using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebSession.Model; @@ -75,6 +77,61 @@ public Session GetSession(Request request) return session; } + /// + /// 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 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. + public ISessionManager CleanUp(IApplicationContext applicationContext, int timeoutMinutes = 60 * 24 * 365) + { + // validate input + ArgumentNullException.ThrowIfNull(applicationContext); + + // read timeout; non-positive values disable cleanup + if (timeoutMinutes <= 0) + { + return this; + } + + var now = DateTime.Now; + + // collect expired ids under lock to avoid concurrent modifications during enumeration + IEnumerable expiredIds; + lock (_dictionary) + { + expiredIds = _dictionary.Values + .Where(s => (now - s.Updated).TotalMinutes > timeoutMinutes) + .Select(s => s.Id); + + // remove expired sessions under the same lock + foreach (var id in expiredIds) + { + _dictionary.Remove(id); + } + } + + // log removals outside the lock + foreach (var id in expiredIds) + { + _httpServerContext.Log.Info + ( + I18N.Translate("webexpress.webcore:sessionmanager.cleanup.removed", id) + ); + } + + return this; + } + /// /// Release of unmanaged resources reserved during use. /// diff --git a/src/WebExpress.WebCore/WebStatusPage/IStatusPageContext.cs b/src/WebExpress.WebCore/WebStatusPage/IStatusPageContext.cs index 55f24df..22f9905 100644 --- a/src/WebExpress.WebCore/WebStatusPage/IStatusPageContext.cs +++ b/src/WebExpress.WebCore/WebStatusPage/IStatusPageContext.cs @@ -39,5 +39,10 @@ public interface IStatusPageContext : IContext /// Returns the status icon. /// IRoute StatusIcon { get; } + + /// + /// Returns the description of the current status. + /// + string StatusDescription { get; } } } diff --git a/src/WebExpress.WebCore/WebStatusPage/StatusPageContext.cs b/src/WebExpress.WebCore/WebStatusPage/StatusPageContext.cs index 8a4c416..b20f1ea 100644 --- a/src/WebExpress.WebCore/WebStatusPage/StatusPageContext.cs +++ b/src/WebExpress.WebCore/WebStatusPage/StatusPageContext.cs @@ -40,13 +40,18 @@ public class StatusPageContext : IStatusPageContext /// public IRoute StatusIcon { get; internal set; } + /// + /// Returns the description of the current status. + /// + public string StatusDescription { get; internal set; } + /// /// Returns a string that represents the current object. /// /// A string that represents the current object. public override string ToString() { - return $"StatusPage: {StatusPageId.ToString()}"; + return $"StatusPage: {StatusPageId}"; } } } diff --git a/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs b/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs index f06ed5b..d97608c 100644 --- a/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs +++ b/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs @@ -13,6 +13,7 @@ using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPage; using WebExpress.WebCore.WebPlugin; +using WebExpress.WebCore.WebScope; using WebExpress.WebCore.WebStatusPage.Model; namespace WebExpress.WebCore.WebStatusPage @@ -117,6 +118,7 @@ private void Register(IPluginContext pluginContext, IEnumerable Date: Thu, 23 Oct 2025 20:26:15 +0200 Subject: [PATCH 48/51] feat: general improvements and minor bugs --- src/WebExpress.WebCore/Internationalization/de | 2 +- src/WebExpress.WebCore/Internationalization/en | 2 +- src/WebExpress.WebCore/WebApplication/ApplicationManager.cs | 5 +++++ src/WebExpress.WebCore/WebExpress.WebCore.csproj | 4 ++-- src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs | 2 +- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/WebExpress.WebCore/Internationalization/de b/src/WebExpress.WebCore/Internationalization/de index 53f8faf..febb19d 100644 --- a/src/WebExpress.WebCore/Internationalization/de +++ b/src/WebExpress.WebCore/Internationalization/de @@ -111,7 +111,7 @@ statuspagemanager.initialization=Der Statuspagemanager wurde initialisiert. statuspagemanager.register=Der Status '{0}' wurde registriert und der Statusseite '{1}' zugewiesen. statuspagemanager.duplicat=Der Status '{0}' wurde bereits registriert. Die Statusseite '{1}' wird daher nicht verwendet. statuspagemanager.statuscodeless=Ein Statuscode wurde der Ressource '{1}' für die Anwendung '{0}' nicht zugewiesen. -statuspagemanager.statuspage=Statuscode: '{0}' +statuspagemanager.addstatuspage=Statuscode: '{0}' für Anwendung '{1}' sitemapmanager.titel=Sitemap: sitemapmanager.initialization=Der Sitemap-Manager wurde initialisiert. diff --git a/src/WebExpress.WebCore/Internationalization/en b/src/WebExpress.WebCore/Internationalization/en index 7c494be..2308ed3 100644 --- a/src/WebExpress.WebCore/Internationalization/en +++ b/src/WebExpress.WebCore/Internationalization/en @@ -111,7 +111,7 @@ statuspagemanager.initialization=The status page manager has been initialized. statuspagemanager.register=The status '{0}' has been registered and assigned to the status page '{1}'. statuspagemanager.duplicat=The status '{0}' has already been registered. Therefore, the status page '{1}' is not used. statuspagemanager.statuscodeless=A status code has not been assigned to the resource '{1}' for the application '{0}'. -statuspagemanager.resource=Status code: '{0}' +statuspagemanager.addstatuspage=Status code: '{0}' for application '{1}'. sitemapmanager.titel=Sitemap: sitemapmanager.initialization=The sitemap manager has been initialized. diff --git a/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs b/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs index a2f644d..05b6928 100644 --- a/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs +++ b/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using System.Reflection; using System.Text.RegularExpressions; using System.Threading.Tasks; using WebExpress.WebCore.Internationalization; @@ -270,6 +271,10 @@ public void Boot(IPluginContext pluginContext) { return; } + else if (pluginContext.Assembly.GetCustomAttribute() != null) + { + return; + } else if (!_dictionary.Contains(pluginContext)) { _httpServerContext.Log.Warning diff --git a/src/WebExpress.WebCore/WebExpress.WebCore.csproj b/src/WebExpress.WebCore/WebExpress.WebCore.csproj index c59e015..53c7830 100644 --- a/src/WebExpress.WebCore/WebExpress.WebCore.csproj +++ b/src/WebExpress.WebCore/WebExpress.WebCore.csproj @@ -8,9 +8,9 @@ net9.0 any https://github.com/webexpress-framework/WebExpress.git - Rene_Schwarzer@hotmail.de + webexpress-framework@outlook.com MIT - Rene_Schwarzer@hotmail.de + webexpress-framework@outlook.com true True Core library of the WebExpress web server. diff --git a/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs b/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs index d97608c..4e83e14 100644 --- a/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs +++ b/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs @@ -506,7 +506,7 @@ private void Log() { list.Add ( - I18N.Translate("webexpress.webcore:statuspagemanager.statuspage", statusPage.StatusCode) + I18N.Translate("webexpress.webcore:statuspagemanager.addstatuspage", statusPage.StatusCode, statusPage.ApplicationContext?.ApplicationId) ); } From 094cdd9a5aa3682641e9384acc9fc6a39158d382 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Thu, 30 Oct 2025 19:04:42 +0100 Subject: [PATCH 49/51] fix: applied improvements from code review --- .../WebIdentity/IdentityManager.cs | 1178 +++++++++-------- .../WebInclude/IIncludeContext.cs | 6 +- .../WebInclude/IncludeContext.cs | 6 +- .../WebInclude/Model/IncludeItem.cs | 13 +- .../WebMessage/ContentType.cs | 2 +- .../WebMessage/ResponseUnprocessableEntity.cs | 2 +- .../WebPackage/PackageBuilder.cs | 45 +- .../WebRestApi/RestApiValidationResult.cs | 4 +- .../WebRestApi/RestApiValidator.cs | 84 +- src/WebExpress.WebCore/WebTask/TaskManager.cs | 2 +- src/WebExpress.WebCore/WebUri/UriEndpoint.cs | 6 +- 11 files changed, 700 insertions(+), 648 deletions(-) diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs index 531a541..d5c3f83 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs @@ -1,587 +1,591 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Runtime.InteropServices; -using System.Security; -using System.Security.Cryptography; -using System.Text; -using WebExpress.WebCore.Internationalization; -using WebExpress.WebCore.WebApplication; -using WebExpress.WebCore.WebAttribute; -using WebExpress.WebCore.WebComponent; -using WebExpress.WebCore.WebIdentity.Model; -using WebExpress.WebCore.WebMessage; -using WebExpress.WebCore.WebPlugin; -using WebExpress.WebCore.WebSession.Model; - -namespace WebExpress.WebCore.WebIdentity -{ - /// - /// Management of identities (users). - /// - public class IdentityManager : IIdentityManager - { - private readonly IComponentHub _componentHub; - private readonly IHttpServerContext _httpServerContext; - private readonly IdentityPermissionDictionary _permissionDictionary = []; - private readonly IdentityPolicyDictionary _policyDictionary = []; - - /// - /// Returns all permissions. - /// - public IEnumerable Permissions => _permissionDictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x) - .Select(x => x.PermissionContext); - - /// - /// Returns all policies. - /// - public IEnumerable Policies => _policyDictionary.Values - .SelectMany(x => x.Values) - .SelectMany(x => x) - .Select(x => x.PolicyContext); - - /// - /// Returns all identities. - /// - public IEnumerable Identities => []; - - /// - /// Returns the current signed-in identity. - /// - public IIdentity CurrentIdentity { get; private set; } - - /// - /// 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 IdentityManager(IComponentHub componentHub, IHttpServerContext httpServerContext) - { - _componentHub = componentHub; - - _componentHub.PluginManager.AddPlugin += OnAddPlugin; - _componentHub.PluginManager.RemovePlugin += OnRemovePlugin; - _componentHub.ApplicationManager.AddApplication += OnAddApplication; - _componentHub.ApplicationManager.RemoveApplication += OnRemoveApplication; - - _httpServerContext = httpServerContext; - - _httpServerContext.Log.Debug - ( - I18N.Translate - ( - "webexpress.webcore:identitymanager.initialization" - ) - ); - } - - /// - /// Discovers and binds jobs to an application. - /// - /// The context of the plugin whose jobs are to be associated. - private void Register(IPluginContext pluginContext) - { - if (_permissionDictionary.ContainsKey(pluginContext)) - { - return; - } - - Register(pluginContext, _componentHub.ApplicationManager.GetApplications(pluginContext)); - } - - /// - /// Discovers and binds jobs to an application. - /// - /// The context of the application whose jobs are to be associated. - private void Register(IApplicationContext applicationContext) - { - foreach (var pluginContext in _componentHub.PluginManager.GetPlugins(applicationContext)) - { - if (_permissionDictionary.TryGetValue(pluginContext, out var appDict) && appDict.ContainsKey(applicationContext)) - { - continue; - } - - Register(pluginContext, [applicationContext]); - } - } - - /// - /// Registers policies and ientities for a given plugin and application context. - /// - /// The plugin context. - /// The application context (optional). - private void Register(IPluginContext pluginContext, IEnumerable applicationContexts) - { - var assembly = pluginContext?.Assembly; - - // permissions - foreach (var permissionType in assembly.GetTypes().Where - ( - x => x.IsClass == true && - x.IsSealed && - x.IsPublic && - ( - x.GetInterface(typeof(IIdentityPermission).Name) != null - ) - )) - { - var id = new ComponentId(permissionType.FullName); - var policyTypes = new List(); - - foreach (var customAttribute in permissionType.CustomAttributes - .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IPolicyAttribute)))) - { - if (customAttribute.AttributeType.Name == typeof(PolicyAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(PolicyAttribute<>).Namespace) - { - var type = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); - if (type != null && !policyTypes.Contains(type)) - { - policyTypes.Add(type); - } - } - } - - // assign the event to existing applications - foreach (var applicationContext in applicationContexts) - { - var permissionContext = new IdentityPermissionContext() - { - PluginContext = pluginContext, - ApplicationContext = applicationContext, - PermissionId = id, - Permission = permissionType - }; - - if (_permissionDictionary.AddPermissionItem - ( - pluginContext, - applicationContext, - new IdentityPermissionItem(_componentHub, _httpServerContext, pluginContext, applicationContext, permissionType, permissionContext, policyTypes) - )) - { - _httpServerContext.Log.Debug - ( - I18N.Translate - ( - "webexpress.webcore:identitymanager.registerpermission", - id, - applicationContext.ApplicationId - ) - ); - } - else - { - _httpServerContext.Log.Debug - ( - I18N.Translate - ( - "webexpress.webcore:identitymanager.duplicatepermission", - id, - applicationContext.ApplicationId - ) - ); - } - } - } - - // policies - foreach (var policyType in assembly.GetTypes().Where - ( - x => x.IsClass == true && - x.IsSealed && - x.IsPublic && - ( - x.GetInterface(typeof(IIdentityPolicy).Name) != null - ) - )) - { - var id = new ComponentId(policyType.FullName); - var permissionTypes = new List(); - - foreach (var customAttribute in policyType.CustomAttributes - .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IPermissionAttribute)))) - { - if (customAttribute.AttributeType.Name == typeof(PermissionAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(PermissionAttribute<>).Namespace) - { - var type = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); - if (type != null && !permissionTypes.Contains(type)) - { - permissionTypes.Add(type); - } - } - } - - // assign the event to existing applications - foreach (var applicationContext in applicationContexts) - { - var policyContext = new IdentityPolicyContext() - { - PluginContext = pluginContext, - ApplicationContext = applicationContext, - PolicyId = id - }; - - if (_policyDictionary.AddPolicyItem - ( - pluginContext, - applicationContext, - new IdentityPolicyItem(_componentHub, _httpServerContext, pluginContext, applicationContext, policyType, policyContext, permissionTypes) - )) - { - _httpServerContext.Log.Debug - ( - I18N.Translate - ( - "webexpress.webcore:identitymanager.registerpolicy", - id, - applicationContext.ApplicationId - ) - ); - } - else - { - _httpServerContext.Log.Debug - ( - I18N.Translate - ( - "webexpress.webcore:identitymanager.duplicatepolicy", - id, - applicationContext.ApplicationId - ) - ); - } - } - } - } - - /// - /// Removes all policies and permissions of an plugin. - /// - /// The context of the plugin that contains the identities to remove. - internal void Remove(IPluginContext pluginContext) - { - // permissions - if (_permissionDictionary.TryGetValue(pluginContext, out var permissionValue)) - { - foreach (var permissionItem in permissionValue - .SelectMany(x => x.Value)) - { - permissionItem.Dispose(); - } - - _permissionDictionary.Remove(pluginContext); - } - - // policies - if (_policyDictionary.TryGetValue(pluginContext, out var policyValue)) - { - foreach (var permissionItem in policyValue - .SelectMany(x => x.Value)) - { - permissionItem.Dispose(); - } - - _policyDictionary.Remove(pluginContext); - } - } - - /// - /// Removes all policies and permissions of an application. - /// - /// The context of the application that contains the identities to remove. - internal void Remove(IApplicationContext applicationContext) - { - if (applicationContext == null) - { - return; - } - - // permissions - foreach (var pluginDict in _permissionDictionary.Values) - { - foreach (var appDict in pluginDict.Where(x => x.Key == applicationContext).Select(x => x.Value)) - { - foreach (var permissionItem in appDict) - { - permissionItem.Dispose(); - } - } - - pluginDict.Remove(applicationContext); - } - - // policies - foreach (var pluginDict in _policyDictionary.Values) - { - foreach (var appDict in pluginDict.Where(x => x.Key == applicationContext).Select(x => x.Value)) - { - foreach (var policyItem in appDict) - { - policyItem.Dispose(); - } - } - - pluginDict.Remove(applicationContext); - } - } - - /// - /// Raises the event when an plugin is added. - /// - /// The source of the event. - /// The context of the plugin being added. - private void OnAddPlugin(object sender, IPluginContext e) - { - Register(e); - } - - /// - /// 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 e) - { - Remove(e); - } - - /// - /// Raises the event when an application is removed. - /// - /// The source of the event. - /// The context of the application being removed. - private void OnRemoveApplication(object sender, IApplicationContext e) - { - Remove(e); - } - - /// - /// 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 e) - { - Register(e); - } - - /// - /// Login an identity. - /// - /// The request. - /// The identity. - /// The password. - /// True if successful, false otherwise. - public bool Login(Request request, IIdentity identity, SecureString password) - { - if (identity?.PasswordHash == ComputeHash(password)) - { - var session = _componentHub.SessionManager.GetSession(request); - var authentification = session.GetOrCreateProperty(identity); - - if (authentification.Identity != identity) - { - return false; - } - - return true; - } - - return false; - } - - /// - /// Logout an identity. - /// - /// The request. - public void Logout(Request request) - { - var session = _componentHub.SessionManager.GetSession(request); - session.RemoveProperty(); - } - - /// - /// Returns the current signed-in identity based on the provided request. - /// - /// The request to get the current identity for. - /// The current signed-in identity. - public IIdentity GetCurrentIdentity(Request request) - { - var session = _componentHub.SessionManager.GetSession(request); - var authentification = session.GetProperty(); - - return authentification?.Identity; - } - - /// - /// Checks if the specified identity has the given permission. - /// - /// The type of the identity permission. - /// The context of the application. - /// The identity to check. - /// True if the identity has the permission, false otherwise. - public bool CheckAccess(IApplicationContext applicationContext, IIdentity identity) where T : IIdentityPermission - { - return CheckAccess(applicationContext, identity, typeof(T)); - } - - /// - /// Checks if the specified identity has the given permission. - /// - /// The context of the application. - /// The identity to check. - /// The permission to check for. - /// True if the identity has the permission, false otherwise. - public bool CheckAccess(IApplicationContext applicationContext, IIdentity identity, Type permission) - { - foreach (var group in identity?.Groups ?? []) - { - if (CheckAccess(applicationContext, group, permission)) - { - return true; - } - } - - return false; - } - - /// - /// Checks if the specified identity group has the given permission. - /// - /// The type of the identity permission. - /// The context of the application. - /// The identity group to check. - /// True if the identity group has the permission, false otherwise. - public bool CheckAccess(IApplicationContext applicationContext, IIdentityGroup group) where T : IIdentityPermission - { - return CheckAccess(applicationContext, group, typeof(T)); - } - - /// - /// Checks if the specified identity group has the given permission. - /// - /// The identity group to check. - /// The context of the application. - /// The permission to check for. - /// True if the identity group has the permission, false otherwise. - public bool CheckAccess(IApplicationContext applicationContext, IIdentityGroup group, Type permission) - { - foreach (var policy in group?.Policies ?? []) - { - if (CheckAccess(applicationContext, policy, permission)) - { - return true; - } - } - - return false; - } - - /// - /// Checks if the specified identity policy has the given permission. - /// - /// The type of the identity policy. - /// The type of the identity permission. - /// The context of the application. - /// True if the identity policy has the permission, false otherwise. - public bool CheckAccess(IApplicationContext applicationContext) where R : IIdentityPolicy where P : IIdentityPermission - { - return CheckAccess(applicationContext, typeof(R), typeof(P)); - } - /// - /// Checks if the specified identity policy has the given permission. - /// - /// The context of the application. - /// The identity policy to check. - /// The permission to check for. - /// True if the identity policy has the permission, false otherwise. - public bool CheckAccess(IApplicationContext applicationContext, Type policyType, Type permissionType) - { - return CheckAccess(applicationContext, policyType.FullName.ToLower(), permissionType); - } - - /// - /// Checks if the specified identity policy has the given permission. - /// - /// The context of the application. - /// The identity policy to check. - /// The permission to check for. - /// True if the identity policy has the permission, false otherwise. - private bool CheckAccess(IApplicationContext applicationContext, string policyName, Type permissionType) - { - // policies to permissions - var policies = _policyDictionary.Values.SelectMany(x => x) - .Where(x => x.Key == applicationContext) - .SelectMany(entry => entry.Value); - - foreach (var policy in policies.Where(x => x.PolicyClass.FullName.Equals(policyName, StringComparison.CurrentCultureIgnoreCase))) - { - if (policy.Permissions.Contains(permissionType)) - { - return true; - } - } - - // permissions to policies - var permissions = _permissionDictionary.Values.SelectMany(x => x) - .Where(x => x.Key == applicationContext) - .SelectMany(entry => entry.Value); - - foreach (var permission in permissions.Where(x => x.PermissionClass == permissionType)) - { - if (permission.Policies.Any(x => x.FullName.Equals(policyName, StringComparison.CurrentCultureIgnoreCase))) - { - return true; - } - } - - return false; - } - - /// - /// Computes the SHA-256 hash of the input string. - /// - /// The input string to hash. - /// The computed hash as a hexadecimal string. - public static string ComputeHash(SecureString input) - { - var bstr = IntPtr.Zero; - try - { - bstr = Marshal.SecureStringToBSTR(input); - var length = Marshal.ReadInt32(bstr, -4); - var bytes = new byte[length]; - Marshal.Copy(bstr, bytes, 0, length); - var hashBytes = SHA256.HashData(bytes); - var builder = new StringBuilder(); - - foreach (var b in hashBytes) - { - builder.Append(b.ToString("x2")); - } - - return builder.ToString(); - } - finally - { - if (bstr != IntPtr.Zero) - { - Marshal.ZeroFreeBSTR(bstr); - } - } - } - - /// - /// Release of unmanaged resources reserved during use. - /// - public void Dispose() - { - GC.SuppressFinalize(this); - } - } -} +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security; +using System.Security.Cryptography; +using System.Text; +using WebExpress.WebCore.Internationalization; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebIdentity.Model; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebPlugin; +using WebExpress.WebCore.WebSession.Model; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Management of identities (users). + /// + public class IdentityManager : IIdentityManager + { + private readonly IComponentHub _componentHub; + private readonly IHttpServerContext _httpServerContext; + private readonly IdentityPermissionDictionary _permissionDictionary = []; + private readonly IdentityPolicyDictionary _policyDictionary = []; + + /// + /// Returns all permissions. + /// + public IEnumerable Permissions => _permissionDictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x) + .Select(x => x.PermissionContext); + + /// + /// Returns all policies. + /// + public IEnumerable Policies => _policyDictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x) + .Select(x => x.PolicyContext); + + /// + /// Returns all identities. + /// + public IEnumerable Identities => []; + + /// + /// Returns the current signed-in identity. + /// + public IIdentity CurrentIdentity { get; private set; } + + /// + /// 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 IdentityManager(IComponentHub componentHub, IHttpServerContext httpServerContext) + { + _componentHub = componentHub; + + _componentHub.PluginManager.AddPlugin += OnAddPlugin; + _componentHub.PluginManager.RemovePlugin += OnRemovePlugin; + _componentHub.ApplicationManager.AddApplication += OnAddApplication; + _componentHub.ApplicationManager.RemoveApplication += OnRemoveApplication; + + _httpServerContext = httpServerContext; + + _httpServerContext.Log.Debug + ( + I18N.Translate + ( + "webexpress.webcore:identitymanager.initialization" + ) + ); + } + + /// + /// Discovers and binds jobs to an application. + /// + /// The context of the plugin whose jobs are to be associated. + private void Register(IPluginContext pluginContext) + { + if (_permissionDictionary.ContainsKey(pluginContext)) + { + return; + } + + Register(pluginContext, _componentHub.ApplicationManager.GetApplications(pluginContext)); + } + + /// + /// Discovers and binds jobs to an application. + /// + /// The context of the application whose jobs are to be associated. + private void Register(IApplicationContext applicationContext) + { + foreach (var pluginContext in _componentHub.PluginManager.GetPlugins(applicationContext)) + { + if (_permissionDictionary.TryGetValue(pluginContext, out var appDict) && appDict.ContainsKey(applicationContext)) + { + continue; + } + + Register(pluginContext, [applicationContext]); + } + } + + /// + /// Registers policies and ientities for a given plugin and application context. + /// + /// The plugin context. + /// The application context (optional). + private void Register(IPluginContext pluginContext, IEnumerable applicationContexts) + { + var assembly = pluginContext?.Assembly; + + // permissions + foreach (var permissionType in assembly.GetTypes().Where + ( + x => x.IsClass == true && + x.IsSealed && + x.IsPublic && + ( + x.GetInterface(typeof(IIdentityPermission).Name) != null + ) + )) + { + var id = new ComponentId(permissionType.FullName); + var policyTypes = new List(); + + foreach (var customAttribute in permissionType.CustomAttributes + .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IPolicyAttribute)))) + { + if + ( + customAttribute.AttributeType.Name == typeof(PolicyAttribute<>).Name && + customAttribute.AttributeType.Namespace == typeof(PolicyAttribute<>).Namespace) + { + var type = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); + if (type != null && !policyTypes.Contains(type)) + { + policyTypes.Add(type); + } + } + } + + // assign the event to existing applications + foreach (var applicationContext in applicationContexts) + { + var permissionContext = new IdentityPermissionContext() + { + PluginContext = pluginContext, + ApplicationContext = applicationContext, + PermissionId = id, + Permission = permissionType + }; + + if (_permissionDictionary.AddPermissionItem + ( + pluginContext, + applicationContext, + new IdentityPermissionItem(_componentHub, _httpServerContext, pluginContext, applicationContext, permissionType, permissionContext, policyTypes) + )) + { + _httpServerContext.Log.Debug + ( + I18N.Translate + ( + "webexpress.webcore:identitymanager.registerpermission", + id, + applicationContext.ApplicationId + ) + ); + } + else + { + _httpServerContext.Log.Debug + ( + I18N.Translate + ( + "webexpress.webcore:identitymanager.duplicatepermission", + id, + applicationContext.ApplicationId + ) + ); + } + } + } + + // policies + foreach (var policyType in assembly.GetTypes().Where + ( + x => x.IsClass == true && + x.IsSealed && + x.IsPublic && + ( + x.GetInterface(typeof(IIdentityPolicy).Name) != null + ) + )) + { + var id = new ComponentId(policyType.FullName); + var permissionTypes = new List(); + + foreach (var customAttribute in policyType.CustomAttributes + .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IPermissionAttribute)))) + { + if (customAttribute.AttributeType.Name == typeof(PermissionAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(PermissionAttribute<>).Namespace) + { + var type = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); + if (type != null && !permissionTypes.Contains(type)) + { + permissionTypes.Add(type); + } + } + } + + // assign the event to existing applications + foreach (var applicationContext in applicationContexts) + { + var policyContext = new IdentityPolicyContext() + { + PluginContext = pluginContext, + ApplicationContext = applicationContext, + PolicyId = id + }; + + if (_policyDictionary.AddPolicyItem + ( + pluginContext, + applicationContext, + new IdentityPolicyItem(_componentHub, _httpServerContext, pluginContext, applicationContext, policyType, policyContext, permissionTypes) + )) + { + _httpServerContext.Log.Debug + ( + I18N.Translate + ( + "webexpress.webcore:identitymanager.registerpolicy", + id, + applicationContext.ApplicationId + ) + ); + } + else + { + _httpServerContext.Log.Debug + ( + I18N.Translate + ( + "webexpress.webcore:identitymanager.duplicatepolicy", + id, + applicationContext.ApplicationId + ) + ); + } + } + } + } + + /// + /// Removes all policies and permissions of an plugin. + /// + /// The context of the plugin that contains the identities to remove. + internal void Remove(IPluginContext pluginContext) + { + // permissions + if (_permissionDictionary.TryGetValue(pluginContext, out var permissionValue)) + { + foreach (var permissionItem in permissionValue + .SelectMany(x => x.Value)) + { + permissionItem.Dispose(); + } + + _permissionDictionary.Remove(pluginContext); + } + + // policies + if (_policyDictionary.TryGetValue(pluginContext, out var policyValue)) + { + foreach (var permissionItem in policyValue + .SelectMany(x => x.Value)) + { + permissionItem.Dispose(); + } + + _policyDictionary.Remove(pluginContext); + } + } + + /// + /// Removes all policies and permissions of an application. + /// + /// The context of the application that contains the identities to remove. + internal void Remove(IApplicationContext applicationContext) + { + if (applicationContext == null) + { + return; + } + + // permissions + foreach (var pluginDict in _permissionDictionary.Values) + { + foreach (var appDict in pluginDict.Where(x => x.Key == applicationContext).Select(x => x.Value)) + { + foreach (var permissionItem in appDict) + { + permissionItem.Dispose(); + } + } + + pluginDict.Remove(applicationContext); + } + + // policies + foreach (var pluginDict in _policyDictionary.Values) + { + foreach (var appDict in pluginDict.Where(x => x.Key == applicationContext).Select(x => x.Value)) + { + foreach (var policyItem in appDict) + { + policyItem.Dispose(); + } + } + + pluginDict.Remove(applicationContext); + } + } + + /// + /// Raises the event when an plugin is added. + /// + /// The source of the event. + /// The context of the plugin being added. + private void OnAddPlugin(object sender, IPluginContext e) + { + Register(e); + } + + /// + /// 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 e) + { + Remove(e); + } + + /// + /// Raises the event when an application is removed. + /// + /// The source of the event. + /// The context of the application being removed. + private void OnRemoveApplication(object sender, IApplicationContext e) + { + Remove(e); + } + + /// + /// 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 e) + { + Register(e); + } + + /// + /// Login an identity. + /// + /// The request. + /// The identity. + /// The password. + /// True if successful, false otherwise. + public bool Login(Request request, IIdentity identity, SecureString password) + { + if (identity?.PasswordHash == ComputeHash(password)) + { + var session = _componentHub.SessionManager.GetSession(request); + var authentification = session.GetOrCreateProperty(identity); + + if (authentification.Identity != identity) + { + return false; + } + + return true; + } + + return false; + } + + /// + /// Logout an identity. + /// + /// The request. + public void Logout(Request request) + { + var session = _componentHub.SessionManager.GetSession(request); + session.RemoveProperty(); + } + + /// + /// Returns the current signed-in identity based on the provided request. + /// + /// The request to get the current identity for. + /// The current signed-in identity. + public IIdentity GetCurrentIdentity(Request request) + { + var session = _componentHub.SessionManager.GetSession(request); + var authentification = session.GetProperty(); + + return authentification?.Identity; + } + + /// + /// Checks if the specified identity has the given permission. + /// + /// The type of the identity permission. + /// The context of the application. + /// The identity to check. + /// True if the identity has the permission, false otherwise. + public bool CheckAccess(IApplicationContext applicationContext, IIdentity identity) where T : IIdentityPermission + { + return CheckAccess(applicationContext, identity, typeof(T)); + } + + /// + /// Checks if the specified identity has the given permission. + /// + /// The context of the application. + /// The identity to check. + /// The permission to check for. + /// True if the identity has the permission, false otherwise. + public bool CheckAccess(IApplicationContext applicationContext, IIdentity identity, Type permission) + { + foreach (var group in identity?.Groups ?? []) + { + if (CheckAccess(applicationContext, group, permission)) + { + return true; + } + } + + return false; + } + + /// + /// Checks if the specified identity group has the given permission. + /// + /// The type of the identity permission. + /// The context of the application. + /// The identity group to check. + /// True if the identity group has the permission, false otherwise. + public bool CheckAccess(IApplicationContext applicationContext, IIdentityGroup group) where T : IIdentityPermission + { + return CheckAccess(applicationContext, group, typeof(T)); + } + + /// + /// Checks if the specified identity group has the given permission. + /// + /// The identity group to check. + /// The context of the application. + /// The permission to check for. + /// True if the identity group has the permission, false otherwise. + public bool CheckAccess(IApplicationContext applicationContext, IIdentityGroup group, Type permission) + { + foreach (var policy in group?.Policies ?? []) + { + if (CheckAccess(applicationContext, policy, permission)) + { + return true; + } + } + + return false; + } + + /// + /// Checks if the specified identity policy has the given permission. + /// + /// The type of the identity policy. + /// The type of the identity permission. + /// The context of the application. + /// True if the identity policy has the permission, false otherwise. + public bool CheckAccess(IApplicationContext applicationContext) where R : IIdentityPolicy where P : IIdentityPermission + { + return CheckAccess(applicationContext, typeof(R), typeof(P)); + } + + /// + /// Checks if the specified identity policy has the given permission. + /// + /// The context of the application. + /// The identity policy to check. + /// The permission to check for. + /// True if the identity policy has the permission, false otherwise. + public bool CheckAccess(IApplicationContext applicationContext, Type policyType, Type permissionType) + { + return CheckAccess(applicationContext, policyType.FullName.ToLower(), permissionType); + } + + /// + /// Checks if the specified identity policy has the given permission. + /// + /// The context of the application. + /// The identity policy to check. + /// The permission to check for. + /// True if the identity policy has the permission, false otherwise. + private bool CheckAccess(IApplicationContext applicationContext, string policyName, Type permissionType) + { + // policies to permissions + var policies = _policyDictionary.Values.SelectMany(x => x) + .Where(x => x.Key == applicationContext) + .SelectMany(entry => entry.Value); + + foreach (var policy in policies.Where(x => x.PolicyClass.FullName.Equals(policyName, StringComparison.CurrentCultureIgnoreCase))) + { + if (policy.Permissions.Contains(permissionType)) + { + return true; + } + } + + // permissions to policies + var permissions = _permissionDictionary.Values.SelectMany(x => x) + .Where(x => x.Key == applicationContext) + .SelectMany(entry => entry.Value); + + foreach (var permission in permissions.Where(x => x.PermissionClass == permissionType)) + { + if (permission.Policies.Any(x => x.FullName.Equals(policyName, StringComparison.CurrentCultureIgnoreCase))) + { + return true; + } + } + + return false; + } + + /// + /// Computes the SHA-256 hash of the input string. + /// + /// The input string to hash. + /// The computed hash as a hexadecimal string. + public static string ComputeHash(SecureString input) + { + var bstr = IntPtr.Zero; + try + { + bstr = Marshal.SecureStringToBSTR(input); + var length = Marshal.ReadInt32(bstr, -4); + var bytes = new byte[length]; + Marshal.Copy(bstr, bytes, 0, length); + var hashBytes = SHA256.HashData(bytes); + var builder = new StringBuilder(); + + foreach (var b in hashBytes) + { + builder.Append(b.ToString("x2")); + } + + return builder.ToString(); + } + finally + { + if (bstr != IntPtr.Zero) + { + Marshal.ZeroFreeBSTR(bstr); + } + } + } + + /// + /// Release of unmanaged resources reserved during use. + /// + public void Dispose() + { + GC.SuppressFinalize(this); + } + } +} diff --git a/src/WebExpress.WebCore/WebInclude/IIncludeContext.cs b/src/WebExpress.WebCore/WebInclude/IIncludeContext.cs index e93e5b4..2bd80b6 100644 --- a/src/WebExpress.WebCore/WebInclude/IIncludeContext.cs +++ b/src/WebExpress.WebCore/WebInclude/IIncludeContext.cs @@ -12,12 +12,12 @@ namespace WebExpress.WebCore.WebInclude public interface IIncludeContext : IContext { /// - /// Retruns the identifier of the included component. + /// Returns the identifier of the included component. /// ComponentId IncludeId { get; } /// - /// Retruns the context for the plugin, providing access to shared resources and services. + /// Returns the context for the plugin, providing access to shared resources and services. /// IPluginContext PluginContext { get; } @@ -37,7 +37,7 @@ public interface IIncludeContext : IContext IEnumerable Files { get; } /// - /// Retruns the collection of scopes associated with the current context. + /// Returns the collection of scopes associated with the current context. /// /// /// The collection can be empty if no scopes are defined. Callers can set this property diff --git a/src/WebExpress.WebCore/WebInclude/IncludeContext.cs b/src/WebExpress.WebCore/WebInclude/IncludeContext.cs index e9a9027..da2c2a8 100644 --- a/src/WebExpress.WebCore/WebInclude/IncludeContext.cs +++ b/src/WebExpress.WebCore/WebInclude/IncludeContext.cs @@ -12,12 +12,12 @@ namespace WebExpress.WebCore.WebInclude internal sealed class IncludeContext : IIncludeContext { /// - /// Retruns or sets the identifier of the included component. + /// Returns or sets the identifier of the included component. /// public ComponentId IncludeId { get; set; } /// - /// Retruns or sets the context for the plugin, providing access to shared resources and services. + /// Returns or sets the context for the plugin, providing access to shared resources and services. /// public IPluginContext PluginContext { get; set; } @@ -37,7 +37,7 @@ internal sealed class IncludeContext : IIncludeContext public IEnumerable Files { get; set; } = []; /// - /// Retruns or sets the collection of scopes associated with the current context. + /// Returns or sets the collection of scopes associated with the current context. /// /// /// The collection can be empty if no scopes are defined. Callers can set this property diff --git a/src/WebExpress.WebCore/WebInclude/Model/IncludeItem.cs b/src/WebExpress.WebCore/WebInclude/Model/IncludeItem.cs index cee78bd..c9d9993 100644 --- a/src/WebExpress.WebCore/WebInclude/Model/IncludeItem.cs +++ b/src/WebExpress.WebCore/WebInclude/Model/IncludeItem.cs @@ -14,12 +14,12 @@ internal sealed class IncludeItem : IDisposable private readonly IComponentHub _componentHub; /// - /// Retruns or sets the identifier of the included component. + /// Returns or sets the identifier of the included component. /// public ComponentId IncludeId { get; set; } /// - /// Retruns or sets the context for the plugin, providing access to shared resources and services. + /// Returns or sets the context for the plugin, providing access to shared resources and services. /// public IPluginContext PluginContext { get; set; } @@ -53,7 +53,7 @@ internal sealed class IncludeItem : IDisposable public bool Cache { get; set; } /// - /// Retruns or sets the collection of scopes associated with the current context. + /// Returns or sets the collection of scopes associated with the current context. /// /// /// The collection can be empty if no scopes are defined. Callers can set this property @@ -69,9 +69,10 @@ internal sealed class IncludeItem : IDisposable /// /// Initializes a new instance of the class with the specified component hub. /// - /// The component hub used to manage and interact with components. This parameter cannot - /// be null. + /// + /// The component hub used to manage and interact with components. This parameter cannot + /// be null. + /// public IncludeItem(IComponentHub componentHub) { _componentHub = componentHub; diff --git a/src/WebExpress.WebCore/WebMessage/ContentType.cs b/src/WebExpress.WebCore/WebMessage/ContentType.cs index 7505171..69a8e2f 100644 --- a/src/WebExpress.WebCore/WebMessage/ContentType.cs +++ b/src/WebExpress.WebCore/WebMessage/ContentType.cs @@ -285,7 +285,7 @@ public static string GetFilePattern(this ContentType extension) ContentType.Jpeg => "*.jpeg", ContentType.Jpg => "*.jpg", ContentType.Ico => "*.ico", - ContentType.WebP => ".webp", + ContentType.WebP => "*.webp", ContentType.Mp3 => "*.mp3", ContentType.Mp4 => "*.mp4", _ => "*.*", diff --git a/src/WebExpress.WebCore/WebMessage/ResponseUnprocessableEntity.cs b/src/WebExpress.WebCore/WebMessage/ResponseUnprocessableEntity.cs index 58df540..deb8d30 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseUnprocessableEntity.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseUnprocessableEntity.cs @@ -26,7 +26,7 @@ public ResponseUnprocessableEntity() /// The user defined status message or null. public ResponseUnprocessableEntity(StatusMessage message) { - var content = message?.Message ?? "404422 - Unprocessable Entity"; + var content = message?.Message ?? "422422 - Unprocessable Entity"; Reason = "unprocessable entity"; Header.ContentType = "text/html"; diff --git a/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs b/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs index 2ec4ecd..8de1471 100644 --- a/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs +++ b/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs @@ -110,12 +110,10 @@ public static void Create(string specFile, string config, string targets, string { try { - foreach (var licFilePath in Directory.GetFiles(rootDirectory, "*.lic", SearchOption.AllDirectories)) + foreach (var licFilePath in Directory.GetFiles(rootDirectory, "*.lic", SearchOption.AllDirectories) + .Where(x => !string.IsNullOrWhiteSpace(x) && File.Exists(x))) { - if (!string.IsNullOrWhiteSpace(licFilePath) && File.Exists(licFilePath)) - { - LicensesToZip(archive, licFilePath); - } + LicensesToZip(archive, licFilePath); } } catch (Exception) @@ -262,29 +260,26 @@ private static void ProjectToZip(ZipArchive archive, PackageItemSpec package, st continue; } - foreach (var fileName in files) + foreach (var fileName in files.Where(x => !string.IsNullOrWhiteSpace(x) && File.Exists(x))) { - if (!string.IsNullOrWhiteSpace(fileName) && File.Exists(fileName)) + // compute relative path robustly + string relativePath; + try + { + relativePath = Path.GetRelativePath(dir, fileName); + } + catch { - // compute relative path robustly - string relativePath; - try - { - relativePath = Path.GetRelativePath(dir, fileName); - } - catch - { - // fallback to file name if relative path fails - relativePath = Path.GetFileName(fileName); - } - - var entryPathRaw = $"{zipBinarys}/{safePluginName}/{safeTarget}/{relativePath}"; - var entryPath = SanitizeEntryPath(entryPathRaw); - - AddFileToZip(archive, entryPath, fileName); - - Console.WriteLine($"*** PackageBuilder: Copy the output file '{relativePath}' to {safePluginName}."); + // fallback to file name if relative path fails + relativePath = Path.GetFileName(fileName); } + + var entryPathRaw = $"{zipBinarys}/{safePluginName}/{safeTarget}/{relativePath}"; + var entryPath = SanitizeEntryPath(entryPathRaw); + + AddFileToZip(archive, entryPath, fileName); + + Console.WriteLine($"*** PackageBuilder: Copy the output file '{relativePath}' to {safePluginName}."); } } } diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs b/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs index 76f01df..7524032 100644 --- a/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs +++ b/src/WebExpress.WebCore/WebRestApi/RestApiValidationResult.cs @@ -55,7 +55,7 @@ public void Add(string message, string field = null, string code = null) ///
/// /// This method appends the specified errors to the existing collection. If - /// the array isempty, no changes are made. + /// the array is empty, no changes are made. /// /// An array of error objects to add. public void Add(params RestApiError[] errors) @@ -68,7 +68,7 @@ public void Add(params RestApiError[] errors) ///
/// /// This method appends the specified errors to the existing collection. If - /// the array isempty, no changes are made. + /// the array is empty, no changes are made. /// /// An array of error objects to add. public void AddRange(IEnumerable errors) diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs b/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs index 5324d8b..5cdf9b2 100644 --- a/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs +++ b/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs @@ -65,7 +65,10 @@ public RestApiValidator When(Func condition) /// The current instance, allowing for method chaining. public RestApiValidator Require(string parameter, string message = null) { - if (!_currentCondition) return this; + if (!_currentCondition) + { + return this; + } var value = _request.GetParameter(parameter)?.Value; if (string.IsNullOrWhiteSpace(value)) @@ -98,7 +101,10 @@ public RestApiValidator Require(string parameter, string message = null) /// The current instance, allowing for method chaining. public RestApiValidator MaxLength(string parameter, int max, string message = null) { - if (!_currentCondition) return this; + if (!_currentCondition) + { + return this; + } var value = _request.GetParameter(parameter)?.Value; if (!string.IsNullOrWhiteSpace(value) && value.Length > max) @@ -132,7 +138,10 @@ public RestApiValidator MaxLength(string parameter, int max, string message = nu /// The current instance, allowing for method chaining. public RestApiValidator MinLength(string parameter, int min, string message = null) { - if (!_currentCondition) return this; + if (!_currentCondition) + { + return this; + } var value = _request.GetParameter(parameter)?.Value; @@ -176,7 +185,10 @@ public RestApiValidator MinLength(string parameter, int min, string message = nu /// The current instance, allowing for method chaining. public RestApiValidator Regex(string parameter, string pattern, string message = null) { - if (!_currentCondition) return this; + if (!_currentCondition) + { + return this; + } var value = _request.GetParameter(parameter)?.Value; if (!string.IsNullOrWhiteSpace(value) && !System.Text.RegularExpressions.Regex.IsMatch(value, pattern)) @@ -209,7 +221,10 @@ public RestApiValidator Regex(string parameter, string pattern, string message = /// The current instance, allowing for method chaining. public RestApiValidator Range(string parameter, int min, int max, string message = null) { - if (!_currentCondition) return this; + if (!_currentCondition) + { + return this; + } var value = _request.GetParameter(parameter)?.Value; if (int.TryParse(value, out var number)) @@ -244,7 +259,10 @@ public RestApiValidator Range(string parameter, int min, int max, string message /// The current instance, allowing for method chaining. public RestApiValidator IsInt(string parameter, string message = null) { - if (!_currentCondition) return this; + if (!_currentCondition) + { + return this; + } var value = _request.GetParameter(parameter)?.Value; if (!int.TryParse(value, out _)) @@ -276,7 +294,10 @@ public RestApiValidator IsInt(string parameter, string message = null) /// The current instance, allowing for method chaining. public RestApiValidator Email(string parameter, string message = null) { - if (!_currentCondition) return this; + if (!_currentCondition) + { + return this; + } var value = _request.GetParameter(parameter)?.Value; if (!string.IsNullOrWhiteSpace(value) && !System.Text.RegularExpressions.Regex.IsMatch(value, @"^[^@\s]+@[^@\s]+\.[^@\s]+$")) @@ -308,7 +329,10 @@ public RestApiValidator Email(string parameter, string message = null) /// The current instance, allowing for method chaining. public RestApiValidator EqualTo(string parameter, string expected, string message = null) { - if (!_currentCondition) return this; + if (!_currentCondition) + { + return this; + } var value = _request.GetParameter(parameter)?.Value; if (!string.Equals(value, expected, StringComparison.OrdinalIgnoreCase)) @@ -340,7 +364,10 @@ public RestApiValidator EqualTo(string parameter, string expected, string messag /// The current instance, allowing for method chaining. public RestApiValidator NotEqualTo(string parameter, string notExpected, string message = null) { - if (!_currentCondition) return this; + if (!_currentCondition) + { + return this; + } var value = _request.GetParameter(parameter)?.Value; if (string.Equals(value, notExpected, StringComparison.OrdinalIgnoreCase)) @@ -372,7 +399,10 @@ public RestApiValidator NotEqualTo(string parameter, string notExpected, string /// The current instance, allowing for method chaining. public RestApiValidator StartsWith(string parameter, string prefix, string message = null) { - if (!_currentCondition) return this; + if (!_currentCondition) + { + return this; + } var value = _request.GetParameter(parameter)?.Value; if (!string.IsNullOrWhiteSpace(value) && !value.StartsWith(prefix)) @@ -403,7 +433,10 @@ public RestApiValidator StartsWith(string parameter, string prefix, string messa /// The current instance, allowing for method chaining. public RestApiValidator In(string parameter, params string[] allowedValues) { - if (!_currentCondition) return this; + if (!_currentCondition) + { + return this; + } var value = _request.GetParameter(parameter)?.Value; if (!string.IsNullOrWhiteSpace(value) && @@ -435,7 +468,10 @@ public RestApiValidator In(string parameter, params string[] allowedValues) /// The current instance, allowing for method chaining. public RestApiValidator Contains(string parameter, string text, string message = null) { - if (!_currentCondition) return this; + if (!_currentCondition) + { + return this; + } var value = _request.GetParameter(parameter)?.Value; if (string.IsNullOrWhiteSpace(value) || !value.Contains(text)) @@ -467,7 +503,10 @@ public RestApiValidator Contains(string parameter, string text, string message = /// The current instance, allowing for method chaining. public RestApiValidator EndsWith(string parameter, string suffix, string message = null) { - if (!_currentCondition) return this; + if (!_currentCondition) + { + return this; + } var value = _request.GetParameter(parameter)?.Value; if (!string.IsNullOrWhiteSpace(value) && !value.EndsWith(suffix)) @@ -503,7 +542,10 @@ public RestApiValidator EndsWith(string parameter, string suffix, string message public RestApiValidator MatchesEnum(string parameter, string message = null) where T : struct, Enum { - if (!_currentCondition) return this; + if (!_currentCondition) + { + return this; + } var value = _request.GetParameter(parameter)?.Value; if (!Enum.TryParse(value, true, out _)) @@ -534,7 +576,10 @@ public RestApiValidator MatchesEnum(string parameter, string message = null) /// The current instance, allowing for method chaining. public RestApiValidator IsDate(string parameter, string message = null) { - if (!_currentCondition) return this; + if (!_currentCondition) + { + return this; + } var value = _request.GetParameter(parameter)?.Value; if (!DateTime.TryParse(value, out _)) @@ -559,8 +604,8 @@ public RestApiValidator IsDate(string parameter, string message = null) /// validation result. /// /// - /// A function that evaluates the request and returns true" if the - /// condition is met; otherwise,false. + /// A function that evaluates the request and returns true if the + /// condition is met; otherwise, false. /// /// /// The error message to associate with the validation failure if @@ -577,7 +622,10 @@ public RestApiValidator IsDate(string parameter, string message = null) /// The current instance, allowing for method chaining. public RestApiValidator Custom(Func condition, string message, string parameter = null, string code = "CUSTOM") { - if (!_currentCondition) return this; + if (!_currentCondition) + { + return this; + } if (!condition(_request)) { diff --git a/src/WebExpress.WebCore/WebTask/TaskManager.cs b/src/WebExpress.WebCore/WebTask/TaskManager.cs index f15081a..50c2065 100644 --- a/src/WebExpress.WebCore/WebTask/TaskManager.cs +++ b/src/WebExpress.WebCore/WebTask/TaskManager.cs @@ -102,7 +102,7 @@ public ITask CreateTask(string id, EventHandler handler, params o /// The id of the task. /// The event handler. /// The event argument. - /// The type of the task.- + /// The type of the task. /// The task or null. public ITask CreateTask(string id, EventHandler handler, params object[] args) where TTask : Task diff --git a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs index 35f2c23..e98845d 100644 --- a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs +++ b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs @@ -154,7 +154,10 @@ public UriEndpoint(UriScheme scheme, UriAuthority authority, string uri) /// The uri. public UriEndpoint(string uri) { - if (string.IsNullOrWhiteSpace(uri) || uri == "/") return; + if (string.IsNullOrWhiteSpace(uri) || uri == "/") + { + return; + } if (Enum.GetNames().Where(x => uri.StartsWith(x, StringComparison.OrdinalIgnoreCase)).Any()) { @@ -461,6 +464,7 @@ public static IUri Combine(IUri uri, params string[] uris) /// /// Sets the fragment component of the URI. /// + /// The fragment to set (e.g., "section1"). /// A new IUri instance with the updated fragment. The original URI remains unchanged. public IUri SetFragment(string fragment) { From fc6d3a8b8742e5ac37042cfea7f26f7a086d9b38 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Thu, 30 Oct 2025 20:27:02 +0100 Subject: [PATCH 50/51] fix: applied improvements from code review --- .../Manager/UnitTestPackageManager.cs | 4 +- src/WebExpress.WebCore/WebHtml/HtmlElement.cs | 11 ++- .../WebIdentity/IIdentityManager.cs | 4 +- .../WebIdentity/IdentityManager.cs | 93 ++++++++----------- .../WebRestApi/RestApiValidator.cs | 17 ++-- src/WebExpress.WebCore/WebTask/Task.cs | 38 +++++++- 6 files changed, 91 insertions(+), 76 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs index 5e3af6b..93ce7da 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs @@ -236,8 +236,8 @@ public void LoadPackageReadsSpec() // validation Assert.NotNull(result); - Assert.Equal("dummy", result.Id); - Assert.Equal("DummyTitle", result.Metadata.Title); + Assert.Equal("dummy", result?.Id); + Assert.Equal("DummyTitle", result?.Metadata.Title); } finally { diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs index 6a48ae6..eb57a50 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs @@ -106,8 +106,9 @@ public string OnClick public bool Inline { get; set; } /// - /// Determines whether the element needs an end tag. - /// e.g.: true =
false =
+ /// Determines whether the element requires a closing tag. + /// Examples: true → <div></div>, false → <br/> + /// This affects rendering behavior in ToString and ToPostString. ///
public bool CloseTag { get; protected set; } @@ -416,12 +417,12 @@ public virtual void ToString(StringBuilder builder, int deep) if (_elements.Count == 0) { nl = false; + closeTag = CloseTag; } else if (ContainsOnlyTextNodes(_elements, out var text)) { - closeTag = CloseTag; nl = false; - + closeTag = true; builder.Append(text); } else @@ -440,7 +441,7 @@ public virtual void ToString(StringBuilder builder, int deep) } } - if (closeTag || CloseTag) + if (closeTag) { ToPostString(builder, deep, nl); } diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs index 6d217a1..9b4af9f 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs @@ -65,12 +65,12 @@ bool CheckAccess(IApplicationContext applicationContext, II where TIdentityPermission : IIdentityPermission; /// - /// Checks if the specified identity has the given permission. + /// Checks whether the given identity has the specified permission by evaluating all associated groups. /// /// The context of the application. /// The identity to check. /// The permission to check for. - /// True if the identity has the permission, false otherwise. + /// True if any group grants the permission, false otherwise. bool CheckAccess(IApplicationContext applicationContext, IIdentity identity, Type permission); /// diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs index d5c3f83..bf1f4ec 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs @@ -122,7 +122,7 @@ private void Register(IPluginContext pluginContext, IEnumerable x.IsClass == true && + x => x.IsClass && x.IsSealed && x.IsPublic && ( @@ -132,20 +132,20 @@ private void Register(IPluginContext pluginContext, IEnumerable(); + var matchingAttributes = permissionType.CustomAttributes + .Where + ( + x => x.AttributeType.GetInterfaces().Contains(typeof(IPolicyAttribute)) && + x.AttributeType.Name == typeof(PolicyAttribute<>).Name && + x.AttributeType.Namespace == typeof(PolicyAttribute<>).Namespace + ); - foreach (var customAttribute in permissionType.CustomAttributes - .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IPolicyAttribute)))) + foreach (var customAttribute in matchingAttributes) { - if - ( - customAttribute.AttributeType.Name == typeof(PolicyAttribute<>).Name && - customAttribute.AttributeType.Namespace == typeof(PolicyAttribute<>).Namespace) + var type = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); + if (type != null && !policyTypes.Contains(type)) { - var type = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); - if (type != null && !policyTypes.Contains(type)) - { - policyTypes.Add(type); - } + policyTypes.Add(type); } } @@ -195,7 +195,7 @@ private void Register(IPluginContext pluginContext, IEnumerable x.IsClass == true && + x => x.IsClass && x.IsSealed && x.IsPublic && ( @@ -205,17 +205,20 @@ private void Register(IPluginContext pluginContext, IEnumerable(); + var matchingAttributes = policyType.CustomAttributes + .Where + ( + x => x.AttributeType.GetInterfaces().Contains(typeof(IPermissionAttribute)) && + x.AttributeType.Name == typeof(PermissionAttribute<>).Name && + x.AttributeType.Namespace == typeof(PermissionAttribute<>).Namespace + ); - foreach (var customAttribute in policyType.CustomAttributes - .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IPermissionAttribute)))) + foreach (var customAttribute in matchingAttributes) { - if (customAttribute.AttributeType.Name == typeof(PermissionAttribute<>).Name && customAttribute.AttributeType.Namespace == typeof(PermissionAttribute<>).Namespace) + var type = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); + if (type != null && !permissionTypes.Contains(type)) { - var type = customAttribute.AttributeType.GenericTypeArguments.FirstOrDefault(); - if (type != null && !permissionTypes.Contains(type)) - { - permissionTypes.Add(type); - } + permissionTypes.Add(type); } } @@ -434,23 +437,15 @@ public bool CheckAccess(IApplicationContext applicationContext, IIdentity ide } /// - /// Checks if the specified identity has the given permission. + /// Checks whether the given identity has the specified permission by evaluating all associated groups. /// /// The context of the application. /// The identity to check. /// The permission to check for. - /// True if the identity has the permission, false otherwise. + /// True if any group grants the permission, false otherwise. public bool CheckAccess(IApplicationContext applicationContext, IIdentity identity, Type permission) { - foreach (var group in identity?.Groups ?? []) - { - if (CheckAccess(applicationContext, group, permission)) - { - return true; - } - } - - return false; + return (identity?.Groups ?? []).Any(group => CheckAccess(applicationContext, group, permission)); } /// @@ -474,15 +469,7 @@ public bool CheckAccess(IApplicationContext applicationContext, IIdentityGrou /// True if the identity group has the permission, false otherwise. public bool CheckAccess(IApplicationContext applicationContext, IIdentityGroup group, Type permission) { - foreach (var policy in group?.Policies ?? []) - { - if (CheckAccess(applicationContext, policy, permission)) - { - return true; - } - } - - return false; + return (group?.Policies ?? []).Any(policy => CheckAccess(applicationContext, policy, permission)); } /// @@ -519,29 +506,29 @@ public bool CheckAccess(IApplicationContext applicationContext, Type policyType, private bool CheckAccess(IApplicationContext applicationContext, string policyName, Type permissionType) { // policies to permissions - var policies = _policyDictionary.Values.SelectMany(x => x) + var policies = _policyDictionary.Values + .SelectMany(x => x) .Where(x => x.Key == applicationContext) .SelectMany(entry => entry.Value); - foreach (var policy in policies.Where(x => x.PolicyClass.FullName.Equals(policyName, StringComparison.CurrentCultureIgnoreCase))) + if (policies.Any(policy => + policy.PolicyClass.FullName.Equals(policyName, StringComparison.CurrentCultureIgnoreCase) && + policy.Permissions.Contains(permissionType))) { - if (policy.Permissions.Contains(permissionType)) - { - return true; - } + return true; } // permissions to policies - var permissions = _permissionDictionary.Values.SelectMany(x => x) + var permissions = _permissionDictionary.Values + .SelectMany(x => x) .Where(x => x.Key == applicationContext) .SelectMany(entry => entry.Value); - foreach (var permission in permissions.Where(x => x.PermissionClass == permissionType)) + if (permissions.Any(permission => + permission.PermissionClass == permissionType && + permission.Policies.Any(x => x.FullName.Equals(policyName, StringComparison.CurrentCultureIgnoreCase)))) { - if (permission.Policies.Any(x => x.FullName.Equals(policyName, StringComparison.CurrentCultureIgnoreCase))) - { - return true; - } + return true; } return false; diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs b/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs index 5cdf9b2..a354ebe 100644 --- a/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs +++ b/src/WebExpress.WebCore/WebRestApi/RestApiValidator.cs @@ -227,16 +227,13 @@ public RestApiValidator Range(string parameter, int min, int max, string message } var value = _request.GetParameter(parameter)?.Value; - if (int.TryParse(value, out var number)) - { - if (number < min || number > max) - { - _result.Add( - message ?? I18N.Translate(_request, "webexpress.webcore:validation.out_of_range", parameter, min.ToString(), max.ToString()), - parameter, - "OUT_OF_RANGE" - ); - } + if (int.TryParse(value, out var number) && (number < min || number > max)) + { + _result.Add( + message ?? I18N.Translate(_request, "webexpress.webcore:validation.out_of_range", parameter, min.ToString(), max.ToString()), + parameter, + "OUT_OF_RANGE" + ); } return this; diff --git a/src/WebExpress.WebCore/WebTask/Task.cs b/src/WebExpress.WebCore/WebTask/Task.cs index c40e585..17cf6e9 100644 --- a/src/WebExpress.WebCore/WebTask/Task.cs +++ b/src/WebExpress.WebCore/WebTask/Task.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading; +using System.Threading.Tasks; namespace WebExpress.WebCore.WebTask { @@ -101,10 +102,9 @@ public void Run() OnFinish(); - System.Threading.Tasks.Task.Delay(30000).ContinueWith(_ => - { - WebEx.ComponentHub.TaskManager.RemoveTask(this); - }); + var cts = new CancellationTokenSource(); + _ = ScheduleRemovalAsync(cts.Token); + }, TokenSource.Token); } @@ -128,5 +128,35 @@ public void Dispose() { Cancel(); } + + /// + /// Schedules the removal of the current task after a delay. + /// + /// + /// A that can be used to cancel the scheduled removal. + /// + /// + /// True if the task was successfully removed after the delay; otherwise, + /// false if the operation was canceled. + /// + public async Task ScheduleRemovalAsync(CancellationToken token) + { + if (token.IsCancellationRequested) + { + return false; + } + + try + { + await System.Threading.Tasks.Task.Delay(30000, token); + WebEx.ComponentHub.TaskManager.RemoveTask(this); + return true; + } + catch (TaskCanceledException) + { + return false; + } + } + } } From 7a8996bbc18f823c0d9088c5c6e73c591a9c6235 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Fri, 31 Oct 2025 09:47:59 +0100 Subject: [PATCH 51/51] fix: applied improvements from code review --- src/WebExpress.WebCore/WebHtml/HtmlElement.cs | 2 +- src/WebExpress.WebCore/WebHtml/IHtmlElement.cs | 2 +- src/WebExpress.WebCore/WebPackage/PackageBuilder.cs | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs index eb57a50..3b9410b 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs @@ -250,7 +250,7 @@ public IHtmlElement RemoveStyle(params string[] styles) } /// - /// Clear all elements frrom the html element. + /// Clear all elements from the html element. /// /// The current instance for method chaining. public IHtmlElement Clear() diff --git a/src/WebExpress.WebCore/WebHtml/IHtmlElement.cs b/src/WebExpress.WebCore/WebHtml/IHtmlElement.cs index 697c404..f397a41 100644 --- a/src/WebExpress.WebCore/WebHtml/IHtmlElement.cs +++ b/src/WebExpress.WebCore/WebHtml/IHtmlElement.cs @@ -71,7 +71,7 @@ public interface IHtmlElement : IHtmlNode IHtmlElement Add(params IHtmlAttribute[] attributes); /// - /// Clear all elements frrom the html element. + /// Clear all elements from the html element. /// /// The current instance for method chaining. IHtmlElement Clear(); diff --git a/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs b/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs index 8de1471..72b4d4b 100644 --- a/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs +++ b/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs @@ -333,7 +333,8 @@ private static string Find(string path, string fileName) { var matches = Directory .GetFiles(path, "*.*", SearchOption.AllDirectories) - .Where(x => x.Replace('\\', '/').EndsWith(normalizedTail, StringComparison.OrdinalIgnoreCase)); + .Select(x => x.Replace('\\', '/')) + .Where(x => x.EndsWith(normalizedTail, StringComparison.OrdinalIgnoreCase)); foreach (var f in matches) {