From 1a69e86f8c7f882b6b34c18a2cac17efee833dff Mon Sep 17 00:00:00 2001 From: cypherpotato Date: Thu, 8 Aug 2024 00:04:34 -0300 Subject: [PATCH] add ini parser --- .../IniConfigurationPipeline.cs | 111 +++++++++ .../Sisk.IniConfiguration/IniDocument.cs | 90 ++++++++ .../Sisk.IniConfiguration/IniSection.cs | 141 ++++++++++++ .../Sisk.IniConfiguration/Parser/IniParser.cs | 212 ++++++++++++++++++ .../Sisk.IniConfiguration.csproj | 59 +++++ 5 files changed, 613 insertions(+) create mode 100644 extensions/Sisk.IniConfiguration/IniConfigurationPipeline.cs create mode 100644 extensions/Sisk.IniConfiguration/IniDocument.cs create mode 100644 extensions/Sisk.IniConfiguration/IniSection.cs create mode 100644 extensions/Sisk.IniConfiguration/Parser/IniParser.cs create mode 100644 extensions/Sisk.IniConfiguration/Sisk.IniConfiguration.csproj diff --git a/extensions/Sisk.IniConfiguration/IniConfigurationPipeline.cs b/extensions/Sisk.IniConfiguration/IniConfigurationPipeline.cs new file mode 100644 index 0000000..06ecd57 --- /dev/null +++ b/extensions/Sisk.IniConfiguration/IniConfigurationPipeline.cs @@ -0,0 +1,111 @@ +using Sisk.Core.Http; +using Sisk.Core.Http.Hosting; +using Sisk.IniConfiguration.Parser; +using System.Text; + +namespace Sisk.IniConfiguration; + +/// +/// Provides an INI-Document based configuration-reader pipeline. +/// +public sealed class IniConfigurationPipeline : IConfigurationReader +{ + /// + public void ReadConfiguration(ConfigurationContext context) + { + IniDocument document = IniDocument.FromFile(context.ConfigurationFile); + + string parsingNode = ""; + try + { + var serverSection = document.GetSection("Server"); + if (serverSection is not null) + { + parsingNode = "Server.Listen"; + string[] listeningPorts = serverSection.GetMany("Listen"); + context.TargetListeningHost.Ports = listeningPorts.Select(n => ListeningPort.Parse(n, null)).ToArray(); + + parsingNode = "Server.Encoding"; + if (serverSection.GetOne("Encoding") is { } encoding) + context.Host.ServerConfiguration.DefaultEncoding = Encoding.GetEncoding(encoding); + + parsingNode = "Server.MaximumContentLength"; + if (serverSection.GetOne("MaximumContentLength") is { } MaximumContentLength) + context.Host.ServerConfiguration.MaximumContentLength = Int32.Parse(MaximumContentLength); + + parsingNode = "Server.IncludeRequestIdHeader"; + if (serverSection.GetOne("IncludeRequestIdHeader") is { } IncludeRequestIdHeader) + context.Host.ServerConfiguration.IncludeRequestIdHeader = IniParser.IniNamingComparer.Compare(IncludeRequestIdHeader, "true") == 0; + + parsingNode = "Server.ThrowExceptions"; + if (serverSection.GetOne("ThrowExceptions") is { } ThrowExceptions) + context.Host.ServerConfiguration.ThrowExceptions = IniParser.IniNamingComparer.Compare(ThrowExceptions, "true") == 0; + + parsingNode = "Server.AccessLogsStream"; + if (serverSection.GetOne("AccessLogsStream") is { } AccessLogsStream) + context.Host.ServerConfiguration.AccessLogsStream = string.Compare(AccessLogsStream, "console", true) == 0 ? + LogStream.ConsoleOutput : new LogStream(AccessLogsStream); + + parsingNode = "Server.ErrorsLogsStream"; + if (serverSection.GetOne("ErrorsLogsStream") is { } ErrorsLogsStream) + context.Host.ServerConfiguration.ErrorsLogsStream = string.Compare(ErrorsLogsStream, "console", true) == 0 ? + LogStream.ConsoleOutput : new LogStream(ErrorsLogsStream); + } + + var paramsSection = document.GetSection("Parameters"); + if (paramsSection is not null) + { + foreach (string key in paramsSection.Keys) + { + string? value = paramsSection.GetOne(key); + if (value is not null) + context.Parameters.Add(key, value); + } + } + + parsingNode = "Cors"; + var corsSection = document.GetSection("CORS"); + if (corsSection is not null) + { + parsingNode = "Cors.AllowMethods"; + if (corsSection.GetOne("AllowMethods") is { } AllowMethods) + context.TargetListeningHost.CrossOriginResourceSharingPolicy.AllowMethods + = AllowMethods.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + parsingNode = "Cors.AllowHeaders"; + if (corsSection.GetOne("AllowHeaders") is { } AllowHeaders) + context.TargetListeningHost.CrossOriginResourceSharingPolicy.AllowHeaders + = AllowHeaders.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + parsingNode = "Cors.AllowOrigins"; + if (corsSection.GetOne("AllowOrigins") is { } AllowOrigins) + context.TargetListeningHost.CrossOriginResourceSharingPolicy.AllowOrigins + = AllowOrigins.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + parsingNode = "Cors.AllowOrigin"; + if (corsSection.GetOne("AllowOrigin") is { } AllowOrigin) + context.TargetListeningHost.CrossOriginResourceSharingPolicy.AllowOrigin + = AllowOrigin; + + parsingNode = "Cors.ExposeHeaders"; + if (corsSection.GetOne("ExposeHeaders") is { } ExposeHeaders) + context.TargetListeningHost.CrossOriginResourceSharingPolicy.ExposeHeaders + = ExposeHeaders.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + parsingNode = "Cors.AllowCredentials"; + if (corsSection.GetOne("AllowCredentials") is { } AllowCredentials) + context.TargetListeningHost.CrossOriginResourceSharingPolicy.AllowCredentials + = string.Compare(AllowCredentials, "True", true) == 0; + + parsingNode = "Cors.MaxAge"; + if (corsSection.GetOne("MaxAge") is { } MaxAge) + context.TargetListeningHost.CrossOriginResourceSharingPolicy.MaxAge + = TimeSpan.FromSeconds(int.Parse(MaxAge)); + } + } + catch (Exception ex) + { + throw new Exception($"Caught exception while trying to read the property {parsingNode}: {ex.Message}", ex); + } + } +} diff --git a/extensions/Sisk.IniConfiguration/IniDocument.cs b/extensions/Sisk.IniConfiguration/IniDocument.cs new file mode 100644 index 0000000..bc8aef4 --- /dev/null +++ b/extensions/Sisk.IniConfiguration/IniDocument.cs @@ -0,0 +1,90 @@ +using Sisk.IniConfiguration.Parser; +using System.Text; + +namespace Sisk.IniConfiguration; + +/// +/// Represents an INI configuration document. +/// +public sealed class IniDocument +{ + /// + /// Gets all INI sections defined in this INI document. + /// + public IniSection[] Sections { get; } + + /// + /// Gets the global INI section, which is the primary section in the document. + /// + public IniSection Global { get => Sections[0]; } + + /// + /// Creates an new document from the specified + /// string, reading it as an UTF-8 string. + /// + /// The UTF-8 string. + public static IniDocument FromString(string iniConfiguration) + { + using TextReader reader = new StringReader(iniConfiguration); + using IniParser parser = new IniParser(reader); + return parser.Parse(); + } + + /// + /// Creates an new document from the specified + /// file using the specified encoding. + /// + /// The absolute or relative file path to the INI document. + /// Optional. The encoding used to read the file. Defaults to UTF-8. + public static IniDocument FromFile(string filePath, Encoding? encoding = null) + { + using TextReader reader = new StreamReader(filePath, encoding ?? Encoding.UTF8); + using IniParser parser = new IniParser(reader); + return parser.Parse(); + } + + /// + /// Creates an new document from the specified + /// stream using the specified encoding. + /// + /// The input stream where the INI document is. + /// Optional. The encoding used to read the stream. Defaults to UTF-8. + public static IniDocument FromStream(Stream stream, Encoding? encoding = null) + { + using TextReader reader = new StreamReader(stream, encoding ?? Encoding.UTF8); + using IniParser parser = new IniParser(reader); + return parser.Parse(); + } + + /// + /// Creates an new document from the specified + /// . + /// + /// The instance. + public static IniDocument FromStream(TextReader reader) + { + using IniParser parser = new IniParser(reader); + return parser.Parse(); + } + + internal IniDocument(IniSection[] sections) + { + Sections = sections; + } + + /// + /// Gets an defined INI section from this document. The search is case-insensitive. + /// + /// The section name. + /// The object if found, or null if not defined. + public IniSection? GetSection(string sectionName) + { + for (int i = 0; i < Sections.Length; i++) + { + IniSection section = Sections[i]; + if (IniParser.IniNamingComparer.Compare(section.Name, sectionName) == 0) + return section; + } + return null; + } +} diff --git a/extensions/Sisk.IniConfiguration/IniSection.cs b/extensions/Sisk.IniConfiguration/IniSection.cs new file mode 100644 index 0000000..a4069d5 --- /dev/null +++ b/extensions/Sisk.IniConfiguration/IniSection.cs @@ -0,0 +1,141 @@ +using Sisk.IniConfiguration.Parser; +using System.Collections; +using System.Diagnostics.CodeAnalysis; + +namespace Sisk.IniConfiguration; + +/// +/// Represents an INI section, which contains it's own properties. +/// +public sealed class IniSection : IReadOnlyDictionary +{ + internal (string, string)[] items; + + /// + /// Gets the INI section name. + /// + public string Name { get; } + + internal IniSection(string name, (string, string)[] items) + { + this.items = items; + Name = name; + } + + /// + /// Gets all values associated with the specified property name, performing an case-insensitive search. + /// + /// The property name. + public string[] this[string key] + { + get + { + return items + .Where(k => IniParser.IniNamingComparer.Compare(key, k.Item1) == 0) + .Select(k => k.Item2) + .ToArray(); + } + } + + /// + /// Gets all keys defined in this INI section, without duplicates. + /// + public IEnumerable Keys + { + get + { + return items.Select(i => i.Item1).Distinct().ToArray(); + } + } + + /// + /// Gets all values defined in this INI section. + /// + public IEnumerable Values + { + get + { + using (var e = GetEnumerator()) + { + while (e.MoveNext()) + { + yield return e.Current.Value; + } + } + } + } + + /// + /// Gets the number of properties in this INI section. + /// + public int Count => items.Length; + + /// + /// Gets the last value defined in this INI section by their property name. + /// + /// The property name. + /// The last value associated with the specified property name, or null if nothing is found. + public string? GetOne(string key) + { + return items + .Where(k => IniParser.IniNamingComparer.Compare(key, k.Item1) == 0) + .Select(k => k.Item2) + .LastOrDefault(); + } + + /// + /// Gets all values defined in this INI section by their property name. + /// + /// The property name. + /// All values associated with the specified property name. + public string[] GetMany(string key) + { + return this[key]; + } + + /// + /// Gets an boolean indicating if the specified key/property name is + /// defined in this . + /// + /// The property name. + /// An indicating if the specified property name is defined or not. + public bool ContainsKey(string key) + { + for (int i = 0; i < items.Length; i++) + { + (string, string?) item = items[i]; + + if (IniParser.IniNamingComparer.Compare(item.Item1, key) == 0) + return true; + } + return false; + } + + /// + public IEnumerator> GetEnumerator() + { + string[] keysDistinct = items.Select(i => i.Item1).Distinct().ToArray(); + + foreach (string key in keysDistinct) + { + string[] valuesByKey = items + .Where(i => i.Item1 == key) + .Select(i => i.Item2) + .ToArray(); + + yield return new KeyValuePair(key, valuesByKey); + } + } + + /// + public bool TryGetValue(string key, [MaybeNullWhen(false)] out string[] value) + { + value = this[key]; + return value.Length > 0; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} diff --git a/extensions/Sisk.IniConfiguration/Parser/IniParser.cs b/extensions/Sisk.IniConfiguration/Parser/IniParser.cs new file mode 100644 index 0000000..6ccc934 --- /dev/null +++ b/extensions/Sisk.IniConfiguration/Parser/IniParser.cs @@ -0,0 +1,212 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Sisk.IniConfiguration.Parser; + +/// +/// Provides an INI-document parser. +/// +public sealed class IniParser : IDisposable +{ + internal static readonly StringComparer IniNamingComparer = StringComparer.InvariantCultureIgnoreCase; + + private TextReader reader; + private bool disposedValue; + + /// + /// Creates an new with the specified text reader. + /// + /// The instace to read the INI document. + public IniParser(TextReader reader) + { + this.reader = reader; + } + + const char SECTION_START = '['; + const char SECTION_END = ']'; + const char COMMENT_1 = '#'; + const char COMMENT_2 = ';'; + const char STRING_QUOTE_1 = '\''; + const char STRING_QUOTE_2 = '\"'; + const char PROPERTY_DELIMITER = '='; + const char NEW_LINE = '\n'; + + /// + /// Reads the INI document from the input stream. + /// + /// An file containing all properties and data from the input stream. + public IniDocument Parse() + { + ThrowIfDisposed(); + + string lastSectionName = "__GLOBAL__"; + List<(string, string)> items = new List<(string, string)>(); + List creatingSections = new List(); + + int read = 0; + while ((read = reader.Peek()) >= 0) + { + char c = (char)read; + + if (c == SECTION_START) + { + reader.Read(); + string? sectionName = ReadUntil(new char[] { SECTION_END })?.Trim(); + + if (sectionName is null) + break; + + var closingSection = new IniSection(lastSectionName, items.ToArray()); + creatingSections.Add(closingSection); + + items.Clear(); + lastSectionName = sectionName; + + SkipWhiteSpace(); + } + else if (c == COMMENT_1 || c == COMMENT_2) + { + SkipUntilNewLine(); + } + else + { + string? propertyName = ReadUntil(new char[] { PROPERTY_DELIMITER }, true); + if (propertyName is null) + break; + + string? propertyValue = ReadValue(); + if (propertyValue is null) + break; + + if ((string.IsNullOrWhiteSpace(propertyName) && string.IsNullOrWhiteSpace(propertyValue)) == false) + items.Add((propertyName.Trim(), propertyValue)); + + SkipWhiteSpace(); + } + } + + if (items.Count > 0) + { + var closingSection = new IniSection(lastSectionName, items.ToArray()); + creatingSections.Add(closingSection); + } + + return new IniDocument(creatingSections.ToArray()); + } + + void SkipUntilNewLine() + { + ReadUntil(new char[] { NEW_LINE }, false); + } + + void SkipWhiteSpace() + { + int read = 0; + while ((read = reader.Peek()) >= 0) + { + char c = (char)read; + if (!char.IsWhiteSpace(c)) + { + break; + } + else reader.Read(); + } + } + + string? ReadValue() + { + readNext: + int read = reader.Read(); + if (read < 0) + { + return ""; + } + else + { + char c = (char)read; + + if (c == ' ' || c == '\t') + { + goto readNext; + } + else if (c == '\r' || c == '\n') + { + return ""; + } + if (c == STRING_QUOTE_1) + { + return ReadUntil(new char[] { STRING_QUOTE_1 }, false); + } + else if (c == STRING_QUOTE_2) + { + return ReadUntil(new char[] { STRING_QUOTE_2 }, false); + } + else + { + return (c + ReadUntil(new char[] { NEW_LINE }, true, true)).Trim(); + } + } + } + + string? ReadUntil(in char[] until, bool canExplode = false, bool breakOnComment = false) + { + StringBuilder sb = new StringBuilder(); + int read = 0; + while ((read = reader.Read()) >= 0) + { + char c = (char)read; + + if (until.Contains(c)) + { + return sb.ToString(); + } + else if (breakOnComment && (c == COMMENT_1 || c == COMMENT_2)) + { + var s = sb.ToString(); + SkipUntilNewLine(); + return s; + } + else + { + sb.Append(c); + } + } + + if (canExplode) + { + return sb.ToString(); + } + else + { + return null; + } + } + + void ThrowIfDisposed() + { + if (disposedValue) + throw new ObjectDisposedException(nameof(IniParser)); + } + + void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + reader.Dispose(); + } + + disposedValue = true; + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/extensions/Sisk.IniConfiguration/Sisk.IniConfiguration.csproj b/extensions/Sisk.IniConfiguration/Sisk.IniConfiguration.csproj new file mode 100644 index 0000000..c6e9b8a --- /dev/null +++ b/extensions/Sisk.IniConfiguration/Sisk.IniConfiguration.csproj @@ -0,0 +1,59 @@ + + + + net6.0 + enable + enable + True + True + + Sisk.IniConfiguration + Sisk.IniConfiguration + + CypherPotato + Project Principium + Sisk.IniConfiguration + This package provides an INI configuration parser for the Sisk Framework and other .NET projects. + https://sisk.proj.pw/ + Icon.png + README.md + https://github.com/sisk-http/core + http-server,http,web framework + git + + 1.0.0.0 + 1.0.0.0 + 1.0.0.0 + + en + LICENSE.txt + True + + + + true + + + $(NoWarn);SYSLIB0020 + + + + + True + \ + + + True + \ + + + True + \ + + + + + + + + \ No newline at end of file