diff --git a/Cortex.sln b/Cortex.sln index de5bc21..02961c6 100644 --- a/Cortex.sln +++ b/Cortex.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.10.34607.79 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11111.16 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cortex.Mediator", "src\Cortex.Mediator\Cortex.Mediator.csproj", "{F1CC775A-95DA-4A5A-879F-66BFCB0FDCC9}" EndProject @@ -60,6 +60,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cortex.Vectors", "src\Corte EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cortex.Mediator.Behaviors.FluentValidation", "src\Cortex.Mediator.Behaviors.FluentValidation\Cortex.Mediator.Behaviors.FluentValidation.csproj", "{44A166BD-01E9-4A4B-9BC5-7DE01B472E73}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cortex.Serialization.Yaml", "src\Cortex.Serialization.Yaml\Cortex.Serialization.Yaml.csproj", "{472BC645-9E2F-4205-A571-4D9184747EC5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Mediator", "Mediator", "{1C5D462D-168D-4D3F-B96E-CCE5517DB197}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Streams", "Streams", "{4C68702C-1661-4AD9-83FD-E0B52B791969}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "States", "States", "{C31F8C0F-8BCF-4959-9BA1-8645D058EAA0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Serialization", "Serialization", "{7F9E0AEA-721E-46F8-90ED-8EA8423647FB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -119,6 +129,7 @@ Global {D51C6B82-ABD9-4C43-820E-237EDBD706A1}.Release|Any CPU.ActiveCfg = Release|AnyCPU {D51C6B82-ABD9-4C43-820E-237EDBD706A1}.Release|Any CPU.Build.0 = Release|AnyCPU {C9A7699A-9BCF-4B51-B29F-ABF78DCEA553}.Debug|Any CPU.ActiveCfg = Debug|AnyCPU + {C9A7699A-9BCF-4B51-B29F-ABF78DCEA553}.Debug|Any CPU.Build.0 = Debug|AnyCPU {C9A7699A-9BCF-4B51-B29F-ABF78DCEA553}.Release|Any CPU.ActiveCfg = Release|AnyCPU {C9A7699A-9BCF-4B51-B29F-ABF78DCEA553}.Release|Any CPU.Build.0 = Release|AnyCPU {D376D6CA-3192-4EDC-B840-31F58B6457DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -181,10 +192,41 @@ Global {44A166BD-01E9-4A4B-9BC5-7DE01B472E73}.Debug|Any CPU.Build.0 = Debug|Any CPU {44A166BD-01E9-4A4B-9BC5-7DE01B472E73}.Release|Any CPU.ActiveCfg = Release|Any CPU {44A166BD-01E9-4A4B-9BC5-7DE01B472E73}.Release|Any CPU.Build.0 = Release|Any CPU + {472BC645-9E2F-4205-A571-4D9184747EC5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {472BC645-9E2F-4205-A571-4D9184747EC5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {472BC645-9E2F-4205-A571-4D9184747EC5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {472BC645-9E2F-4205-A571-4D9184747EC5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {F1CC775A-95DA-4A5A-879F-66BFCB0FDCC9} = {1C5D462D-168D-4D3F-B96E-CCE5517DB197} + {1C8605F4-91CB-49A4-A080-0A6DFE1FB010} = {4C68702C-1661-4AD9-83FD-E0B52B791969} + {96701658-663E-41C5-9EF9-843C79CE727A} = {4C68702C-1661-4AD9-83FD-E0B52B791969} + {EE799E10-7469-428E-AF8C-F2807F6CE7E5} = {4C68702C-1661-4AD9-83FD-E0B52B791969} + {34CA231A-1E3A-4CA4-820C-946DC8E2737F} = {C31F8C0F-8BCF-4959-9BA1-8645D058EAA0} + {16FA00B1-973B-443C-BF84-9B76DCAF341B} = {C31F8C0F-8BCF-4959-9BA1-8645D058EAA0} + {1F60F66A-5DC0-4C1D-A7B7-66F568A26911} = {4C68702C-1661-4AD9-83FD-E0B52B791969} + {2B949637-FC31-4F51-A391-19F76B57CC28} = {4C68702C-1661-4AD9-83FD-E0B52B791969} + {6E5AC0AC-A364-4DB3-9E9A-C2FB54BD6D1E} = {4C68702C-1661-4AD9-83FD-E0B52B791969} + {6019F9E6-C377-416D-9E3B-3D7104FAEB63} = {4C68702C-1661-4AD9-83FD-E0B52B791969} + {20FAE1F1-D677-4E0E-B3A7-E2B1485C874C} = {4C68702C-1661-4AD9-83FD-E0B52B791969} + {D376D6CA-3192-4EDC-B840-31F58B6457DD} = {4C68702C-1661-4AD9-83FD-E0B52B791969} + {447970B9-C5AA-41D9-A07F-330A251597D0} = {C31F8C0F-8BCF-4959-9BA1-8645D058EAA0} + {00358701-D117-4953-A673-D60625D38466} = {C31F8C0F-8BCF-4959-9BA1-8645D058EAA0} + {77AD462F-A248-43AF-9212-43031F22F23D} = {C31F8C0F-8BCF-4959-9BA1-8645D058EAA0} + {980EDBFE-40C2-4EFD-96C2-FED1032FB5E6} = {C31F8C0F-8BCF-4959-9BA1-8645D058EAA0} + {0F9FCB99-D00F-4396-8E2B-6E627076ADA0} = {C31F8C0F-8BCF-4959-9BA1-8645D058EAA0} + {20BD7107-8199-4CA8-815B-4D156B522B82} = {4C68702C-1661-4AD9-83FD-E0B52B791969} + {19167D25-6383-46B4-9449-B9E364F809FF} = {C31F8C0F-8BCF-4959-9BA1-8645D058EAA0} + {81A01446-A8AA-4F9D-BB9B-B66E21B2C348} = {4C68702C-1661-4AD9-83FD-E0B52B791969} + {0E60F75D-C44B-428A-9252-A11C365E2C56} = {4C68702C-1661-4AD9-83FD-E0B52B791969} + {FC86D3AB-778D-45D7-AF36-1F89FC16DE55} = {4C68702C-1661-4AD9-83FD-E0B52B791969} + {4D1F117D-48D7-47AD-9DAC-3B2DB45E628A} = {4C68702C-1661-4AD9-83FD-E0B52B791969} + {44A166BD-01E9-4A4B-9BC5-7DE01B472E73} = {1C5D462D-168D-4D3F-B96E-CCE5517DB197} + {472BC645-9E2F-4205-A571-4D9184747EC5} = {7F9E0AEA-721E-46F8-90ED-8EA8423647FB} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E20303B6-8AC9-4FFF-B645-4608309ADA94} EndGlobalSection diff --git a/src/Cortex.Mediator.Behaviors.FluentValidation/Assets/andyX.png b/src/Cortex.Mediator.Behaviors.FluentValidation/Assets/cortex.png similarity index 100% rename from src/Cortex.Mediator.Behaviors.FluentValidation/Assets/andyX.png rename to src/Cortex.Mediator.Behaviors.FluentValidation/Assets/cortex.png diff --git a/src/Cortex.Mediator.Behaviors.FluentValidation/Cortex.Mediator.Behaviors.FluentValidation.csproj b/src/Cortex.Mediator.Behaviors.FluentValidation/Cortex.Mediator.Behaviors.FluentValidation.csproj index f367543..e46489f 100644 --- a/src/Cortex.Mediator.Behaviors.FluentValidation/Cortex.Mediator.Behaviors.FluentValidation.csproj +++ b/src/Cortex.Mediator.Behaviors.FluentValidation/Cortex.Mediator.Behaviors.FluentValidation.csproj @@ -21,7 +21,7 @@ 1.0.0 license.md - andyX.png + cortex.png Cortex.Mediator.Behaviors.FluentValidation True True @@ -46,7 +46,7 @@ - + True diff --git a/src/Cortex.Mediator.Behaviors.FluentValidation/DependencyInjection/MediatorOptionsExtensions.cs b/src/Cortex.Mediator.Behaviors.FluentValidation/DependencyInjection/MediatorOptionsExtensions.cs new file mode 100644 index 0000000..efc042f --- /dev/null +++ b/src/Cortex.Mediator.Behaviors.FluentValidation/DependencyInjection/MediatorOptionsExtensions.cs @@ -0,0 +1,16 @@ +using Cortex.Mediator.DependencyInjection; + +namespace Cortex.Mediator.Behaviors.FluentValidation.DependencyInjection +{ + public static class MediatorOptionsExtensions + { + public static MediatorOptions AddFluentValidationBehaviors(this MediatorOptions options) + { + options.AddOpenCommandPipelineBehavior(typeof(ValidationCommandBehavior<,>)) + .AddOpenQueryPipelineBehavior(typeof(ValidationQueryBehavior<,>)) + .AddOpenCommandPipelineBehavior(typeof(ValidationCommandBehavior<>)); + + return options; + } + } +} diff --git a/src/Cortex.Mediator.Behaviors.FluentValidation/DependencyInjection/ServiceCollectionExtensions.cs b/src/Cortex.Mediator.Behaviors.FluentValidation/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..9f2f5db --- /dev/null +++ b/src/Cortex.Mediator.Behaviors.FluentValidation/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Linq; + +namespace Cortex.Mediator.Behaviors.FluentValidation.DependencyInjection +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddFluentValidationValidators(this IServiceCollection services, Type[] validationAssemblyMarkerTypes) + { + services.AddValidatorsFromAssemblies(validationAssemblyMarkerTypes.Select(t => t.Assembly)); + return services; + } + } +} diff --git a/src/Cortex.Mediator.Behaviors.FluentValidation/ValidationCommandBehavior.cs b/src/Cortex.Mediator.Behaviors.FluentValidation/ValidationCommandBehavior.cs index b9eb2e3..4baaf02 100644 --- a/src/Cortex.Mediator.Behaviors.FluentValidation/ValidationCommandBehavior.cs +++ b/src/Cortex.Mediator.Behaviors.FluentValidation/ValidationCommandBehavior.cs @@ -1,6 +1,5 @@ using Cortex.Mediator.Commands; using FluentValidation; -using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -16,12 +15,17 @@ public sealed class ValidationCommandBehavior : ICommandPipel { private readonly IEnumerable> _validators; + public ValidationCommandBehavior(IEnumerable> validators) + { + _validators = validators; + } public async Task Handle(TCommand command, CommandHandlerDelegate next, CancellationToken cancellationToken) { var context = new ValidationContext(command); var failures = _validators - .Select(v => v.Validate(context)) + .Select(async v => await v.ValidateAsync(context)) + .Select(r => r.Result) .SelectMany(r => r.Errors) .Where(f => f != null) .ToList(); diff --git a/src/Cortex.Mediator.Behaviors.FluentValidation/ValidationQueryBehavior.cs b/src/Cortex.Mediator.Behaviors.FluentValidation/ValidationQueryBehavior.cs index b48c633..b446524 100644 --- a/src/Cortex.Mediator.Behaviors.FluentValidation/ValidationQueryBehavior.cs +++ b/src/Cortex.Mediator.Behaviors.FluentValidation/ValidationQueryBehavior.cs @@ -13,12 +13,18 @@ public sealed class ValidationQueryBehavior : IQueryPipelineBeh private readonly IEnumerable> _validators; + public ValidationQueryBehavior(IEnumerable> validators) + { + _validators = validators; + } + public async Task Handle(TQuery query, QueryHandlerDelegate next, CancellationToken cancellationToken) { var context = new ValidationContext(query); var failures = _validators - .Select(v => v.Validate(context)) + .Select(async v => await v.ValidateAsync(context)) + .Select(r => r.Result) .SelectMany(r => r.Errors) .Where(f => f != null) .ToList(); diff --git a/src/Cortex.Mediator.Behaviors.FluentValidation/VoidValidationCommandBehavior.cs b/src/Cortex.Mediator.Behaviors.FluentValidation/VoidValidationCommandBehavior.cs new file mode 100644 index 0000000..e04a43d --- /dev/null +++ b/src/Cortex.Mediator.Behaviors.FluentValidation/VoidValidationCommandBehavior.cs @@ -0,0 +1,48 @@ +using Cortex.Mediator.Commands; +using FluentValidation; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Cortex.Mediator.Behaviors +{ + /// + /// Pipeline behavior for validation command execution. + /// + public sealed class ValidationCommandBehavior : ICommandPipelineBehavior + where TCommand : ICommand + { + private readonly IEnumerable> _validators; + + public ValidationCommandBehavior(IEnumerable> validators) + { + _validators = validators; + } + + + public async Task Handle(TCommand command, CommandHandlerDelegate next, CancellationToken cancellationToken) + { + var context = new ValidationContext(command); + var failures = _validators + .Select(async v => await v.ValidateAsync(context)) + .Select(r => r.Result) + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count() > 0) + { + var errors = failures + .GroupBy(f => f.PropertyName) + .ToDictionary( + g => g.Key, + g => g.Select(f => f.ErrorMessage).ToArray()); + + throw new Exceptions.ValidationException(errors); + } + + await next(); + } + } +} diff --git a/src/Cortex.Mediator/DependencyInjection/ServiceCollectionExtensions.cs b/src/Cortex.Mediator/DependencyInjection/ServiceCollectionExtensions.cs index 837fe50..fbc50ec 100644 --- a/src/Cortex.Mediator/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Cortex.Mediator/DependencyInjection/ServiceCollectionExtensions.cs @@ -15,9 +15,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddCortexMediator( this IServiceCollection services, - IConfiguration configuration, Type[] handlerAssemblyMarkerTypes, - Action? configure = null) + Action configure = null) { var options = new MediatorOptions(); configure?.Invoke(options); diff --git a/src/Cortex.Serialization.Yaml/Assets/cortex.png b/src/Cortex.Serialization.Yaml/Assets/cortex.png new file mode 100644 index 0000000..101a1fb Binary files /dev/null and b/src/Cortex.Serialization.Yaml/Assets/cortex.png differ diff --git a/src/Cortex.Serialization.Yaml/Assets/license.md b/src/Cortex.Serialization.Yaml/Assets/license.md new file mode 100644 index 0000000..3c845d4 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Assets/license.md @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2025 Buildersoft + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/Cortex.Serialization.Yaml/Attributes/YamlIgnoreAttribute.cs b/src/Cortex.Serialization.Yaml/Attributes/YamlIgnoreAttribute.cs new file mode 100644 index 0000000..5176221 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Attributes/YamlIgnoreAttribute.cs @@ -0,0 +1,47 @@ +using System; + +namespace Cortex.Serialization.Yaml.Attributes +{ + /// + /// Indicates that a field or property should be ignored during YAML serialization and deserialization. + /// + /// + /// When applied to a field or property, this attribute instructs the YAML serializer to completely + /// exclude the member from both serialization (writing to YAML) and deserialization (reading from YAML). + /// This is useful for: + /// + /// Excluding computed or derived properties that shouldn't be persisted + /// Ignoring internal state or temporary fields that are irrelevant to serialization + /// Preventing sensitive data (like passwords or tokens) from being serialized + /// Omitting properties that would cause circular references during serialization + /// + /// + /// + /// The following example shows how to use YamlIgnoreAttribute to exclude properties from YAML serialization: + /// + /// public class UserSettings + /// { + /// public string UserName { get; set; } + /// public string Email { get; set; } + /// + /// [YamlIgnore] + /// public string PasswordHash { get; set; } + /// + /// [YamlIgnore] + /// public DateTime LastAccess { get; set; } // Computed property + /// + /// // This property will be serialized normally + /// public bool IsActive { get; set; } + /// } + /// + /// When serialized to YAML, the output will not include the PasswordHash and LastAccess properties: + /// + /// UserName: john_doe + /// Email: john@example.com + /// IsActive: true + /// + /// + /// + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] + public sealed class YamlIgnoreAttribute : Attribute { } +} \ No newline at end of file diff --git a/src/Cortex.Serialization.Yaml/Attributes/YamlPropertyAttribute.cs b/src/Cortex.Serialization.Yaml/Attributes/YamlPropertyAttribute.cs new file mode 100644 index 0000000..59d035e --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Attributes/YamlPropertyAttribute.cs @@ -0,0 +1,49 @@ +using System; + +namespace Cortex.Serialization.Yaml.Attributes +{ + /// + /// Specifies a custom YAML property name for a field or property during serialization and deserialization. + /// + /// + /// When applied to a field or property, this attribute allows you to specify an alternative name + /// that should be used in the YAML representation, different from the actual member name in code. + /// This is particularly useful for: + /// + /// Mapping between different naming conventions (e.g., camelCase in YAML and PascalCase in C#) + /// Using YAML property names that are not valid C# identifiers + /// Maintaining compatibility with existing YAML schemas + /// + /// + /// + /// The following example shows how to use YamlPropertyAttribute to specify custom YAML property names: + /// + /// public class SerializableObject + /// { + /// [YamlProperty(Name = "output-format")] + /// public string OutputFormat { get; set; } + /// + /// [YamlProperty(Name = "maxItems")] + /// public int MaximumItems { get; set; } + /// } + /// + /// This will serialize to YAML as: + /// + /// output-format: SomeValue + /// maxItems: 42 + /// + /// + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] + public sealed class YamlPropertyAttribute : Attribute + { + /// + /// Gets the custom name to use for the YAML property. + /// If null or empty, the default name (the member name) will be used. + /// + /// + /// A string representing the custom property name to use in YAML serialization, + /// or null to use the default behavior. + /// + public string? Name { get; init; } + } +} \ No newline at end of file diff --git a/src/Cortex.Serialization.Yaml/Common/StringUtils.cs b/src/Cortex.Serialization.Yaml/Common/StringUtils.cs new file mode 100644 index 0000000..79a7df0 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Common/StringUtils.cs @@ -0,0 +1,25 @@ +namespace Cortex.Serialization.Yaml.Common +{ + internal static class StringUtils + { + public static string ToSnakeCase(string s) + { + if (string.IsNullOrEmpty(s)) return s; + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < s.Length; i++) + { + var c = s[i]; + if (char.IsUpper(c)) + { + if (i > 0) sb.Append('_'); + sb.Append(char.ToLowerInvariant(c)); + } + else + sb.Append(c); + } + return sb.ToString(); + } + public static string ToKebabCase(string s) => ToSnakeCase(s).Replace('_', '-'); + public static string ToCamelCase(string s) => string.IsNullOrEmpty(s) || !char.IsUpper(s[0]) ? s : char.ToLowerInvariant(s[0]) + s[1..]; + } +} diff --git a/src/Cortex.Serialization.Yaml/Common/YamlException.cs b/src/Cortex.Serialization.Yaml/Common/YamlException.cs new file mode 100644 index 0000000..5b3fff1 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Common/YamlException.cs @@ -0,0 +1,89 @@ +using System; + +namespace Cortex.Serialization.Yaml.Common +{ + /// + /// The exception that is thrown when an error occurs during YAML serialization or deserialization. + /// + /// + /// + /// This exception provides detailed information about YAML processing errors, including the specific + /// location in the YAML document where the error occurred. The and + /// properties indicate the position in the YAML source, making it easier to diagnose and fix issues + /// in YAML files. + /// + /// + /// Common scenarios that may throw a include: + /// + /// Invalid YAML syntax or formatting errors + /// Type conversion failures during deserialization + /// Missing required properties or invalid property values + /// Circular references that cannot be serialized + /// I/O errors when reading from or writing to streams + /// + /// + /// + /// + /// The following example shows how to catch and handle a YamlException: + /// + /// try + /// { + /// var obj = YamlSerializer.Deserialize<MyClass>(yamlString); + /// } + /// catch (YamlException ex) + /// { + /// Console.WriteLine($"YAML Error at line {ex.Line}, column {ex.Column}:"); + /// Console.WriteLine(ex.Message); + /// + /// if (ex.InnerException != null) + /// { + /// Console.WriteLine($"Inner exception: {ex.InnerException.Message}"); + /// } + /// } + /// + /// Example error message format: + /// + /// "Expected scalar value but found sequence (line 5, col 12)" + /// + /// + /// + public class YamlException : Exception + { + /// + /// Gets the line number in the YAML document where the error occurred. + /// + /// + /// The one-based line number where the error was detected. Returns 0 if the line number + /// is not available or applicable to the specific error. + /// + public int Line { get; } + + /// + /// Gets the column position in the YAML document where the error occurred. + /// + /// + /// The one-based column number where the error was detected. Returns 0 if the column position + /// is not available or applicable to the specific error. + /// + public int Column { get; } + + /// + /// Initializes a new instance of the class with a specified error message, + /// line and column numbers, and an optional inner exception. + /// + /// The error message that explains the reason for the exception. + /// The line number in the YAML document where the error occurred. + /// The column position in the YAML document where the error occurred. + /// The exception that is the cause of the current exception, or null if no inner exception is specified. + /// + /// The exception message is automatically formatted to include the line and column information + /// in the format: "{message} (line {line}, col {column})". + /// + public YamlException(string message, int line = 0, int column = 0, Exception? inner = null) + : base($"{message} (line {line}, col {column})", inner) + { + Line = line; + Column = column; + } + } +} \ No newline at end of file diff --git a/src/Cortex.Serialization.Yaml/Converters/INamingConvention.cs b/src/Cortex.Serialization.Yaml/Converters/INamingConvention.cs new file mode 100644 index 0000000..ed9dc8b --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Converters/INamingConvention.cs @@ -0,0 +1,7 @@ +namespace Cortex.Serialization.Yaml.Converters +{ + public interface INamingConvention + { + string Convert(string name); + } +} diff --git a/src/Cortex.Serialization.Yaml/Converters/IYamlTypeConverter.cs b/src/Cortex.Serialization.Yaml/Converters/IYamlTypeConverter.cs new file mode 100644 index 0000000..bf06292 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Converters/IYamlTypeConverter.cs @@ -0,0 +1,11 @@ +using System; + +namespace Cortex.Serialization.Yaml.Converters +{ + public interface IYamlTypeConverter + { + bool CanConvert(Type t); + object? Read(object? yamlNode, Type targetType); + object? Write(object? clrObject, Type declaredType); + } +} \ No newline at end of file diff --git a/src/Cortex.Serialization.Yaml/Converters/PrimitiveConverter.cs b/src/Cortex.Serialization.Yaml/Converters/PrimitiveConverter.cs new file mode 100644 index 0000000..3ca86a3 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Converters/PrimitiveConverter.cs @@ -0,0 +1,37 @@ +using System; + +namespace Cortex.Serialization.Yaml.Converters +{ + public sealed class PrimitiveConverter : IYamlTypeConverter + { + public bool CanConvert(Type t) => t.IsPrimitive || t == typeof(string) || t == typeof(decimal) || t == typeof(DateTime) || t == typeof(Guid) || t == typeof(DateOnly) || t == typeof(TimeOnly); + public object? Read(object? yamlNode, Type targetType) + { + if (yamlNode is null) + return null; + if (targetType == typeof(string)) + return yamlNode.ToString(); + if (targetType == typeof(bool)) + return yamlNode is bool b ? b : bool.Parse(yamlNode.ToString()!); + if (targetType == typeof(int)) + return Convert.ToInt32(yamlNode, System.Globalization.CultureInfo.InvariantCulture); + if (targetType == typeof(long)) + return Convert.ToInt64(yamlNode, System.Globalization.CultureInfo.InvariantCulture); + if (targetType == typeof(double)) + return Convert.ToDouble(yamlNode, System.Globalization.CultureInfo.InvariantCulture); + if (targetType == typeof(decimal)) + return Convert.ToDecimal(yamlNode, System.Globalization.CultureInfo.InvariantCulture); + if (targetType == typeof(Guid)) + return Guid.Parse(yamlNode.ToString()!); + if (targetType == typeof(DateTime)) + return DateTime.Parse(yamlNode.ToString()!, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind); + if (targetType == typeof(DateOnly)) + return DateOnly.Parse(yamlNode.ToString()!, System.Globalization.CultureInfo.InvariantCulture); + if (targetType == typeof(TimeOnly)) + return TimeOnly.Parse(yamlNode.ToString()!, System.Globalization.CultureInfo.InvariantCulture); + + return yamlNode; + } + public object? Write(object? clrObject, Type declaredType) => clrObject; + } +} diff --git a/src/Cortex.Serialization.Yaml/Cortex.Serialization.Yaml.csproj b/src/Cortex.Serialization.Yaml/Cortex.Serialization.Yaml.csproj new file mode 100644 index 0000000..c495991 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Cortex.Serialization.Yaml.csproj @@ -0,0 +1,57 @@ + + + + net9.0;net8.0 + + 1.0.1 + 1.0.1 + Buildersoft Cortex Framework + Buildersoft + Buildersoft,EnesHoxha + Copyright © Buildersoft 2025 + + Cortex Data Framework is a robust, extensible platform designed to facilitate real-time data streaming, processing, and state management. It provides developers with a comprehensive suite of tools and libraries to build scalable, high-performance data pipelines tailored to diverse use cases. By abstracting underlying streaming technologies and state management solutions, Cortex Data Framework enables seamless integration, simplified development workflows, and enhanced maintainability for complex data-driven applications. + + + https://github.com/buildersoftio/cortex + cortex vortex mediator eda streaming distributed streams states kafka pulsar rocksdb + + 1.0.1 + license.md + cortex.png + Cortex.Serialization.Yaml + True + True + True + git + Just as the Cortex in our brains handles complex processing efficiently, Cortex Data Framework brings brainpower to your data management! + https://buildersoft.io/ + README.md + + + + + 9.0 + + + + + True + \ + Always + + + + + + True + + + + True + + + + + + diff --git a/src/Cortex.Serialization.Yaml/Emitter/Emitter.cs b/src/Cortex.Serialization.Yaml/Emitter/Emitter.cs new file mode 100644 index 0000000..f670538 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Emitter/Emitter.cs @@ -0,0 +1,79 @@ +using Cortex.Serialization.Yaml.Parser; +using System; +using System.Text; + +namespace Cortex.Serialization.Yaml.Emitter +{ + internal sealed class Emitter + { + private readonly StringBuilder _sb = new(); + private readonly int _indentSize; + + public Emitter(int indentSize = 2) => _indentSize = indentSize; + public string Emit(YamlNode node) + { + WriteNode(node, 0); + return _sb.ToString(); + } + + private void Indent(int level) => _sb.Append(' ', _indentSize * level); + + private void WriteNode(YamlNode node, int level) + { + switch (node) + { + case YamlScalar s: WriteScalar(s.Value); _sb.Append('\n'); break; + case YamlSequence seq: + foreach (var item in seq.Items) + { + Indent(level); _sb.Append("- "); + if (item is YamlScalar sc) + { + WriteScalar(sc.Value); + _sb.Append('\n'); + } + else + { + _sb.Append('\n'); + WriteNode(item, level + 1); + } + } + break; + case YamlMapping map: + foreach (var kvp in map.Entries) + { + Indent(level); _sb.Append(kvp.Key).Append(": "); + if (kvp.Value is YamlScalar sv) { WriteScalar(sv.Value); _sb.Append('\n'); } + else { _sb.Append('\n'); WriteNode(kvp.Value, level + 1); } + } + break; + } + } + private void WriteScalar(object? value) + { + if (value is null) + { + _sb.Append("null"); + return; + } + if (value is bool b) + { + _sb.Append(b ? "true" : "false"); + return; + } + if (value is string s) + { + if (s.Contains(':') || s.StartsWith(' ') || s.EndsWith(' ') || s.Contains('#') || s.Contains('\n')) + { + _sb.Append('"') + .Append(s.Replace("\"", "\\\"")) + .Append('"'); + } + else _sb.Append(s); return; + } + + _sb + .Append(Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)); + } + } +} diff --git a/src/Cortex.Serialization.Yaml/Parser/Parser.cs b/src/Cortex.Serialization.Yaml/Parser/Parser.cs new file mode 100644 index 0000000..3b59445 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Parser/Parser.cs @@ -0,0 +1,161 @@ +using Cortex.Serialization.Yaml.Common; +using System.Collections.Generic; +using System.Linq; + +namespace Cortex.Serialization.Yaml.Parser +{ + internal sealed class Parser + { + private readonly List _tokens; + private int _idx; + public Parser(IEnumerable tokens) => _tokens = tokens.ToList(); + private Token Peek() => _tokens[_idx]; + private Token Next() => _tokens[_idx++]; + private bool Match(TokenType t) + { + if (Peek().Type == t) + { + _idx++; + return true; + } + return false; + } + public YamlNode ParseDocument() => ParseNode(); + private YamlNode ParseNode() + { + if (Peek().Type == TokenType.Key) + return ParseMapping(); + + if (Peek().Type == TokenType.Dash) + return ParseSequence(); + + return ParseScalar(); + } + private YamlNode ParseMapping() + { + var dict = new Dictionary(); + while (Peek().Type == TokenType.Key) + { + var keyTok = Next(); + var key = keyTok.Value!; + var after = Next(); + + if (after.Type == TokenType.BlockLiteral) + { + var text = ReadBlock(true); + dict[key] = new YamlScalar(text); + } + else if (after.Type == TokenType.BlockFolded) + { + var text = ReadBlock(false); + dict[key] = new YamlScalar(text); + } + else if (after.Type == TokenType.Scalar) + { + dict[key] = ParseScalarValue(after.Value!); + } + if (Match(TokenType.NewLine)) { } + + while (Match(TokenType.Dedent)) { } + + while (Match(TokenType.Indent)) + { + dict[key] = ParseNode(); + } + } + return new YamlMapping(dict); + } + private YamlNode ParseSequence() + { + var list = new List(); + while (Peek().Type == TokenType.Dash) + { + Next(); + + if (Peek().Type == TokenType.NewLine) + { + Next(); + Match(TokenType.Indent); + list.Add(ParseNode()); + while (Match(TokenType.Dedent)) { } + } + else + { + if (Peek().Type == TokenType.Key) + list.Add(ParseMapping()); + else if (Peek().Type == TokenType.Scalar) + list.Add(ParseScalar()); + } + + Match(TokenType.NewLine); + } + return new YamlSequence(list); + } + + private YamlNode ParseScalar() + { + var tok = Next(); + if (tok.Type != TokenType.Scalar) + throw new YamlException($"Expected scalar but got {tok.Type}", tok.Line, tok.Column); + return ParseScalarValue(tok.Value!); + } + + private YamlNode ParseScalarValue(string raw) + { + if (raw == "null" || raw == "~") + return new YamlScalar(null); + + if (raw == "true" || raw == "false") + return new YamlScalar(bool.Parse(raw)); + + if (int.TryParse(raw, out var i)) + return new YamlScalar(i); + + if (double.TryParse(raw, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var d)) + return new YamlScalar(d); + + if ((raw.StartsWith('"') && raw.EndsWith('"')) || (raw.StartsWith('\'') && raw.EndsWith('\''))) + return new YamlScalar(raw[1..^1]); + + return new YamlScalar(raw); + } + private string ReadBlock(bool literal) + { + if (!Match(TokenType.NewLine)) { } + if (!Match(TokenType.Indent)) + throw new YamlException("Expected indentation for block scalar", Peek().Line, Peek().Column); + + var sb = new System.Text.StringBuilder(); + while (Peek().Type is TokenType.Scalar or TokenType.Key or TokenType.Dash) + { + var tok = Next(); + if (tok.Type == TokenType.Scalar) + { + if (!literal && sb.Length > 0) + sb.Append(' '); + sb.Append(tok.Value); + } + else if (tok.Type == TokenType.Key) + { + sb.Append(tok.Value).Append(':'); + if (Peek().Type == TokenType.Scalar) + sb.Append(' ').Append(Next().Value); + } + else if (tok.Type == TokenType.Dash) + { + sb.Append("- "); + if (Peek().Type == TokenType.Scalar) + sb.Append(Next().Value); + } + + Match(TokenType.NewLine); + + if (Peek().Type == TokenType.Dedent) + break; + } + Match(TokenType.Dedent); + + return sb.ToString(); + } + } +} \ No newline at end of file diff --git a/src/Cortex.Serialization.Yaml/Parser/Scanner.cs b/src/Cortex.Serialization.Yaml/Parser/Scanner.cs new file mode 100644 index 0000000..3f4305b --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Parser/Scanner.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; + +namespace Cortex.Serialization.Yaml.Parser +{ + internal sealed class Scanner + { + private readonly string[] _lines; + private int _lineIdx; + + public Scanner(string input) + { + _lines = input.Replace("\r\n", "\n") + .Replace('\r', '\n') + .Split('\n'); + } + public IEnumerable Scan() + { + var indentStack = new Stack(); + indentStack.Push(0); + + for (_lineIdx = 0; _lineIdx < _lines.Length; _lineIdx++) + { + var raw = _lines[_lineIdx]; + if (string.IsNullOrWhiteSpace(raw)) + continue; + + int i = 0; + int spaces = 0; + while (i < raw.Length && raw[i] == ' ') + { + spaces++; + i++; + } + + while (spaces > indentStack.Peek()) + { + indentStack.Push(indentStack.Peek() + 2); + yield return new Token(TokenType.Indent, null, _lineIdx + 1, 1); + } + + while (spaces < indentStack.Peek()) + { + indentStack.Pop(); + yield return new Token(TokenType.Dedent, null, _lineIdx + 1, 1); + } + + if (i < raw.Length && raw[i] == '-') + { + yield return new Token(TokenType.Dash, null, _lineIdx + 1, i + 1); + i++; + if (i < raw.Length && raw[i] == ' ') + i++; + } + + var rest = raw[i..]; + if (rest.Contains(": ")) + { + var idx = rest.IndexOf(": "); + var key = rest[..idx]; + var val = rest[(idx + 2)..]; + yield return new Token(TokenType.Key, key, _lineIdx + 1, i + 1); + if (val == "|") + yield return new Token(TokenType.BlockLiteral, null, _lineIdx + 1, i + 1); + + else if (val == ">") + yield return new Token(TokenType.BlockFolded, null, _lineIdx + 1, i + 1); + + else yield return new Token(TokenType.Scalar, val, _lineIdx + 1, i + 1); + } + else + { + yield return new Token(TokenType.Scalar, rest, _lineIdx + 1, i + 1); + } + + yield return new Token(TokenType.NewLine, null, _lineIdx + 1, raw.Length + 1); + } + while (indentStack.Count > 1) + { + indentStack.Pop(); + yield return new Token(TokenType.Dedent, null, _lineIdx + 1, 1); + } + + yield return new Token(TokenType.EOF, null, _lineIdx + 1, 1); + } + } + +} diff --git a/src/Cortex.Serialization.Yaml/Parser/Token.cs b/src/Cortex.Serialization.Yaml/Parser/Token.cs new file mode 100644 index 0000000..b71748a --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Parser/Token.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace Cortex.Serialization.Yaml.Parser +{ + internal sealed record Token(TokenType Type, string? Value, int Line, int Column); + internal abstract record YamlNode; + internal sealed record YamlScalar(object? Value) : YamlNode; + internal sealed record YamlSequence(List Items) : YamlNode; + internal sealed record YamlMapping(Dictionary Entries) : YamlNode; +} diff --git a/src/Cortex.Serialization.Yaml/Parser/TokenType.cs b/src/Cortex.Serialization.Yaml/Parser/TokenType.cs new file mode 100644 index 0000000..d786d81 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Parser/TokenType.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Cortex.Serialization.Yaml.Parser +{ + internal enum TokenType + { + Scalar, + Key, + Dash, + NewLine, + Indent, + Dedent, + BlockLiteral, + BlockFolded, + EOF + } + +} diff --git a/src/Cortex.Serialization.Yaml/README.md b/src/Cortex.Serialization.Yaml/README.md new file mode 100644 index 0000000..df1dec6 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/README.md @@ -0,0 +1,215 @@ +# Cortex.Serialization.Yaml 🧠 + +**Cortex.Serialization.Yaml** A lightweight, dependency‑free YAML serializer/deserializer for .NET 8+. + +Built as part of the [Cortex Data Framework](https://github.com/buildersoftio/cortex), this library simplifies serializer/deserializer for YAML: + + +- ✅ **Serialize & Deserialize** POCOs, collections, and dictionaries +- ✅ **Naming conventions**: CamelCase, PascalCase, SnakeCase, KebabCase, Original +- ✅ **Attributes** `[YamlProperty(Name=…)]`, `[YamlIgnore]` +- ✅ **Custom type converters** via `IYamlTypeConverter` (primitive/date/guid built‑ins included) +- ✅ **Settings**: indentation, emit nulls/defaults, sort properties, case‑insensitive matching + +This version doesnot include: flow style ([], {}), comments preservation, anchors/aliases & merge keys, custom tags, streaming APIs + +--- + +[![GitHub License](https://img.shields.io/github/license/buildersoftio/cortex)](https://github.com/buildersoftio/cortex/blob/master/LICENSE) +[![NuGet Version](https://img.shields.io/nuget/v/Cortex.Serialization.Yaml?label=Cortex.Serialization.Yaml)](https://www.nuget.org/packages/Cortex.Serialization.Yaml) +[![GitHub contributors](https://img.shields.io/github/contributors/buildersoftio/cortex)](https://github.com/buildersoftio/cortex) +[![Discord Shield](https://discord.com/api/guilds/1310034212371566612/widget.png?style=shield)](https://discord.gg/JnMJV33QHu) + + +## 🚀 Getting Started + +### Install via NuGet + +```bash +dotnet add package Cortex.Serialization.Yaml +``` + +## 🛠️ Quick Start +```csharp +using Cortex.Serialization.Yaml.Serialization; +using Cortex.Serialization.Yaml.Serialization.Conventions; + +public sealed record Address(string Street, string City); +public sealed class Person +{ + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public int Age { get; set; } + public List Tags { get; set; } = new(); + public Address? Address { get; set; } +} + +var person = new Person +{ + FirstName = "Ada", + LastName = "Lovelace", + Age = 36, + Tags = ["math", "poet"], + Address = new("12 St James's Sq", "London") +}; + +var serializer = new YamlSerializer(new YamlSerializerSettings +{ + NamingConvention = new SnakeCaseConvention(), + EmitNulls = false +}); + +string yaml = serializer.Serialize(person); +Console.WriteLine(yaml); + +var deserializer = new YamlDeserializer(new YamlDeserializerSettings +{ + NamingConvention = new SnakeCaseConvention() +}); + +var model = deserializer.Deserialize(yaml); +Console.WriteLine($"Hello {model.FirstName} {model.LastName}, {model.Age}"); +``` + +## 🔧 Configuration & Options + +### Serializer settings +```csharp +var settings = new YamlSerializerSettings +{ + NamingConvention = new CamelCaseConvention(), // how CLR names map to YAML keys + EmitNulls = true, // include null properties + EmitDefaults = true, // include default(T) values + SortProperties = false, // keep reflection order + Indentation = 2 // spaces per indent level +}; +``` + +### Deserializer settings + +```csharp +var settings = new YamlDeserializerSettings +{ + NamingConvention = new SnakeCaseConvention(), + CaseInsensitive = true, + IgnoreUnmatchedProperties = true +}; +``` + +## 📚 Examples + +### 1) Lists and nested objects + +```csharp +var yaml = """ +first_name: Ada +last_name: Lovelace +age: 36 +tags: + - math + - poet +address: + street: 12 St James's Sq + city: London +"""; + +var des = new YamlDeserializer(new YamlDeserializerSettings { NamingConvention = new SnakeCaseConvention() }); +var p = des.Deserialize(yaml); + +var s = new YamlSerializer(new YamlSerializerSettings { NamingConvention = new SnakeCaseConvention(), EmitNulls = false }); +var outYaml = s.Serialize(p); +``` + +### 2) Block scalars (| and >) + +```yaml +description: | + First line kept + Second line kept +note: > + Lines are folded + into a single paragraph +``` +These map to string properties on your CLR model. + +### 3) Attributes and explicit names + +```csharp +public sealed class Product +{ + [YamlProperty(Name = "product_id")] // explicit YAML key + public Guid Id { get; set; } + + [YamlIgnore] + public string? InternalNotes { get; set; } +} +``` + +### 4) Custom converters + +```csharp +public sealed class YesNoBoolConverter : IYamlTypeConverter +{ + public bool CanConvert(Type t) => t == typeof(bool); + public object? Read(object? node, Type targetType) => string.Equals(node?.ToString(), "yes", StringComparison.OrdinalIgnoreCase); + public object? Write(object? value, Type declared) => (bool?)value == true ? "yes" : "no"; +} + +var s = new YamlSerializer(new YamlSerializerSettings()); +s.Converters.Add(new YesNoBoolConverter()); +``` + +## ⚠️ Limits (current version) +- No flow style (`[]`, `{}`) collections +- No **comments** preservation/round‑trip of `# …` +- No **anchors/aliases/merge keys** +- No **custom tags** +- **Pragmatic YAML subset**; quoting/escaping is intentionally simple + + +## 💬 Contributing +We welcome contributions from the community! Whether it's reporting bugs, suggesting features, or submitting pull requests, your involvement helps improve Cortex for everyone. + +### 💬 How to Contribute +1. **Fork the Repository** +2. **Create a Feature Branch** +```bash +git checkout -b feature/YourFeature +``` +3. **Commit Your Changes** +```bash +git commit -m "Add your feature" +``` +4. **Push to Your Fork** +```bash +git push origin feature/YourFeature +``` +5. **Open a Pull Request** + +Describe your changes and submit the pull request for review. + +## 📄 License +This project is licensed under the MIT License. + +## 📚 Sponsorship +Cortex is an open-source project maintained by BuilderSoft. Your support helps us continue developing and improving Cortex. Consider sponsoring us to contribute to the future of resilient streaming platforms. + +### How to Sponsor +* **Financial Contributions**: Support us through [GitHub Sponsors](https://github.com/sponsors/buildersoftio) or other preferred platforms. +* **Corporate Sponsorship**: If your organization is interested in sponsoring Cortex, please contact us directly. + +Contact Us: cortex@buildersoft.io + + +## Contact +We'd love to hear from you! Whether you have questions, feedback, or need support, feel free to reach out. + +- Email: cortex@buildersoft.io +- Website: https://buildersoft.io +- GitHub Issues: [Cortex Data Framework Issues](https://github.com/buildersoftio/cortex/issues) +- Join our Discord Community: [![Discord Shield](https://discord.com/api/guilds/1310034212371566612/widget.png?style=shield)](https://discord.gg/JnMJV33QHu) + + +Thank you for using Cortex Data Framework! We hope it empowers you to build scalable and efficient data processing pipelines effortlessly. + +Built with ❤️ by the Buildersoft team. diff --git a/src/Cortex.Serialization.Yaml/Reflection/CachedTypeInfo.cs b/src/Cortex.Serialization.Yaml/Reflection/CachedTypeInfo.cs new file mode 100644 index 0000000..196e64e --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Reflection/CachedTypeInfo.cs @@ -0,0 +1,11 @@ +using System; +using System.Linq; + +namespace Cortex.Serialization.Yaml.Reflection +{ + internal sealed class CachedTypeInfo + { + private static readonly System.Collections.Concurrent.ConcurrentDictionary Cache = new(); + public static PropertyMap[] GetProps(Type t) => Cache.GetOrAdd(t, x => PropertyMap.FromType(x).ToArray()); + } +} diff --git a/src/Cortex.Serialization.Yaml/Reflection/PropertyMap.cs b/src/Cortex.Serialization.Yaml/Reflection/PropertyMap.cs new file mode 100644 index 0000000..d2dfd89 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Reflection/PropertyMap.cs @@ -0,0 +1,46 @@ +using Cortex.Serialization.Yaml.Attributes; +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Cortex.Serialization.Yaml.Reflection +{ + internal sealed record PropertyMap(string Name, MemberInfo Member, Type MemberType, bool canRead, bool canWrite, bool Ignored) + { + public object? GetValue(object instance) => Member switch { PropertyInfo p when canRead => p.GetValue(instance), FieldInfo f => f.GetValue(instance), _ => null }; + public void SetValue(object instance, object? value) + { + switch (Member) + { + case PropertyInfo p when canWrite: + p.SetValue(instance, value); + break; + case FieldInfo f: + f.SetValue(instance, value); + break; + } + } + public static IEnumerable FromType(Type t) + { + const BindingFlags Flags = BindingFlags.Instance | BindingFlags.Public; + foreach (var p in t.GetProperties(Flags)) + { + var ignore = p.GetCustomAttribute() != null; + var attr = p.GetCustomAttribute(); + var logical = attr?.Name ?? p.Name; + + yield return new PropertyMap(logical, p, p.PropertyType, p.CanRead, p.CanWrite, ignore); + } + + foreach (var f in t.GetFields(Flags)) + { + var ignore = f.GetCustomAttribute() != null; + var attr = f.GetCustomAttribute(); + var logical = attr?.Name ?? f.Name; + + yield return new PropertyMap(logical, f, f.FieldType, canRead: true, canWrite: !f.IsInitOnly, Ignored: ignore); + } + } + } + +} diff --git a/src/Cortex.Serialization.Yaml/Reflection/TypeInspector.cs b/src/Cortex.Serialization.Yaml/Reflection/TypeInspector.cs new file mode 100644 index 0000000..1c3ed17 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Reflection/TypeInspector.cs @@ -0,0 +1,26 @@ +using Cortex.Serialization.Yaml.Converters; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Cortex.Serialization.Yaml.Reflection +{ + internal sealed class TypeInspector + { + private readonly INamingConvention _conv; + public TypeInspector(INamingConvention c) => _conv = c; + public IEnumerable GetSerializableMembers(Type t, bool includeReadOnly) + { + foreach (var m in CachedTypeInfo.GetProps(t)) + { + if (m.Ignored) continue; + if (!includeReadOnly && !m.canWrite) continue; + yield return m with { Name = _conv.Convert(m.Name) }; + } + } + + public IEnumerable GetDeserializableMembers(Type t) => CachedTypeInfo + .GetProps(t) + .Where(m => !m.Ignored && m.canWrite); + } +} diff --git a/src/Cortex.Serialization.Yaml/Serialization/Converters/CamelCaseConvention.cs b/src/Cortex.Serialization.Yaml/Serialization/Converters/CamelCaseConvention.cs new file mode 100644 index 0000000..92d8508 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Serialization/Converters/CamelCaseConvention.cs @@ -0,0 +1,10 @@ +namespace Cortex.Serialization.Yaml.Converters +{ + public sealed class CamelCaseConvention : INamingConvention + { + public string Convert(string name) + { + return Common.StringUtils.ToCamelCase(name); + } + } +} diff --git a/src/Cortex.Serialization.Yaml/Serialization/Converters/KebabCaseConvention.cs b/src/Cortex.Serialization.Yaml/Serialization/Converters/KebabCaseConvention.cs new file mode 100644 index 0000000..3d846d7 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Serialization/Converters/KebabCaseConvention.cs @@ -0,0 +1,7 @@ +namespace Cortex.Serialization.Yaml.Converters +{ + public sealed class KebabCaseConvention : INamingConvention + { + public string Convert(string n) => Common.StringUtils.ToKebabCase(n); + } +} diff --git a/src/Cortex.Serialization.Yaml/Serialization/Converters/OriginalCaseConvention.cs b/src/Cortex.Serialization.Yaml/Serialization/Converters/OriginalCaseConvention.cs new file mode 100644 index 0000000..e61715b --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Serialization/Converters/OriginalCaseConvention.cs @@ -0,0 +1,7 @@ +namespace Cortex.Serialization.Yaml.Converters +{ + public sealed class OriginalCaseConvention : INamingConvention + { + public string Convert(string n) => n; + } +} diff --git a/src/Cortex.Serialization.Yaml/Serialization/Converters/PascalCaseConvention.cs b/src/Cortex.Serialization.Yaml/Serialization/Converters/PascalCaseConvention.cs new file mode 100644 index 0000000..3d2a70b --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Serialization/Converters/PascalCaseConvention.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Cortex.Serialization.Yaml.Converters +{ + public sealed class PascalCaseConvention : INamingConvention + { + public string Convert(string n) => char.ToUpperInvariant(n[0]) + n[1..]; + } +} diff --git a/src/Cortex.Serialization.Yaml/Serialization/Converters/SnakeCaseConvention.cs b/src/Cortex.Serialization.Yaml/Serialization/Converters/SnakeCaseConvention.cs new file mode 100644 index 0000000..f346cc4 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Serialization/Converters/SnakeCaseConvention.cs @@ -0,0 +1,7 @@ +namespace Cortex.Serialization.Yaml.Converters +{ + public sealed class SnakeCaseConvention : INamingConvention + { + public string Convert(string n) => Common.StringUtils.ToSnakeCase(n); + } +} diff --git a/src/Cortex.Serialization.Yaml/Serialization/Converters/YamlConverterAttribute.cs b/src/Cortex.Serialization.Yaml/Serialization/Converters/YamlConverterAttribute.cs new file mode 100644 index 0000000..e2cae96 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Serialization/Converters/YamlConverterAttribute.cs @@ -0,0 +1,11 @@ +using System; + +namespace Cortex.Serialization.Yaml.Converters +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Enum)] + public sealed class YamlConverterAttribute : Attribute + { + public Type ConverterType { get; } + public YamlConverterAttribute(Type t) => ConverterType = t; + } +} diff --git a/src/Cortex.Serialization.Yaml/Serialization/YamlDeserializerSettings.cs b/src/Cortex.Serialization.Yaml/Serialization/YamlDeserializerSettings.cs new file mode 100644 index 0000000..16cf4dd --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Serialization/YamlDeserializerSettings.cs @@ -0,0 +1,139 @@ +using Cortex.Serialization.Yaml.Converters; + +namespace Cortex.Serialization.Yaml +{ + /// + /// Provides configuration options for customizing the behavior of YAML deserialization. + /// + /// + /// + /// This class allows you to control how YAML content is parsed and converted into .NET objects. + /// You can customize naming conventions, case sensitivity, and how unknown properties are handled + /// to match your specific requirements and data format. + /// + /// + /// The default settings are optimized for flexibility and compatibility with common YAML conventions: + /// + /// : camelCase (common in YAML/JSON ecosystems) + /// : true (for robust property matching) + /// : true (for forward/backward compatibility) + /// + /// + /// + /// + /// The following example shows how to configure custom deserializer settings: + /// + /// var settings = new YamlDeserializerSettings + /// { + /// NamingConvention = new SnakeCaseNamingConvention(), + /// CaseInsensitive = false, // Require exact case matching + /// IgnoreUnmatchedProperties = false // Throw on unknown properties + /// }; + /// + /// var obj = YamlDeserializer.Deserialize<MyClass>(yamlString, settings); + /// + /// + /// Example with custom naming convention: + /// + /// public class KebabCaseNamingConvention : INamingConvention + /// { + /// public string Convert(string name) + /// { + /// // Convert "FirstName" to "first-name" + /// return Regex.Replace(name, @"([a-z])([A-Z])", "$1-$2").ToLowerInvariant(); + /// } + /// } + /// + /// var settings = new YamlDeserializerSettings + /// { + /// NamingConvention = new KebabCaseNamingConvention() + /// }; + /// + /// + /// + public sealed class YamlDeserializerSettings + { + /// + /// Gets or sets the naming convention used to map between YAML property names and .NET member names. + /// + /// + /// An implementation of that defines how property names are transformed. + /// Default is . + /// + /// + /// + /// This convention is applied bidirectionally during deserialization to match YAML property names + /// with the corresponding .NET class members. For example, with camelCase convention, a YAML property + /// named "firstName" would map to a C# property named "FirstName". + /// + /// + /// Set to null to use exact name matching without any transformation. + /// + /// + /// + /// With camelCase convention: + /// - YAML: "userName" → C#: "UserName" + /// - YAML: "emailAddress" → C#: "EmailAddress" + /// + public INamingConvention NamingConvention { get; init; } = new CamelCaseConvention(); + + /// + /// Gets or sets a value indicating whether property name matching should be case-insensitive. + /// + /// + /// true to ignore case when matching YAML properties to .NET members; false to require exact case matching. + /// Default is true. + /// + /// + /// + /// When set to true, the deserializer will match properties regardless of case differences. + /// This provides flexibility when working with YAML files that may have inconsistent casing. + /// + /// + /// When set to false, property names must match exactly, including case. + /// + /// + /// + /// With CaseInsensitive = true: + /// - YAML: "username", "UserName", "USERNAME" all map to C# property "UserName" + /// + /// With CaseInsensitive = false: + /// - Only "UserName" in YAML maps to C# property "UserName" + /// - "username" and "USERNAME" would be treated as unmatched properties + /// + public bool CaseInsensitive { get; init; } = true; + + /// + /// Gets or sets a value indicating whether unmatched YAML properties should be ignored during deserialization. + /// + /// + /// true to silently ignore properties in the YAML that don't have matching members in the target type; + /// false to throw a when unmatched properties are encountered. + /// Default is true. + /// + /// + /// + /// Setting this to true provides better forward and backward compatibility, allowing your application + /// to work with YAML files that contain additional properties not defined in your .NET types. + /// + /// + /// Setting this to false is useful for strict validation, ensuring that the YAML structure exactly + /// matches the expected schema and helping to catch typos or structural errors. + /// + /// + /// + /// Given a class: + /// + /// public class User { public string Name { get; set; } } + /// + /// And YAML: + /// + /// name: John + /// age: 30 + /// + /// With IgnoreUnmatchedProperties = true: Deserialization succeeds, "age" is ignored + /// With IgnoreUnmatchedProperties = false: YamlException is thrown for unmatched property "age" + /// + public bool IgnoreUnmatchedProperties { get; init; } = true; + } +} \ No newline at end of file diff --git a/src/Cortex.Serialization.Yaml/Serialization/YamlSerializerSettings.cs b/src/Cortex.Serialization.Yaml/Serialization/YamlSerializerSettings.cs new file mode 100644 index 0000000..7ee372f --- /dev/null +++ b/src/Cortex.Serialization.Yaml/Serialization/YamlSerializerSettings.cs @@ -0,0 +1,264 @@ +using Cortex.Serialization.Yaml.Converters; +using System.Collections.Generic; + +namespace Cortex.Serialization.Yaml +{ + /// + /// Provides configuration options for customizing the behavior of YAML serialization. + /// + /// + /// + /// This class allows you to control how .NET objects are converted to YAML format. + /// You can customize naming conventions, formatting, null handling, and type conversion + /// to produce YAML output that matches your specific requirements and style preferences. + /// + /// + /// The default settings are optimized for readability and compatibility: + /// + /// : camelCase (common in YAML/JSON ecosystems) + /// : true (include null values in output) + /// : true (include default values in output) + /// : false (maintain property declaration order) + /// : 2 (standard YAML indentation) + /// : Includes for basic types + /// + /// + /// + /// + /// The following example shows how to configure custom serializer settings: + /// + /// var settings = new YamlSerializerSettings + /// { + /// NamingConvention = new PascalCaseNamingConvention(), + /// EmitNulls = false, // Skip null values + /// SortProperties = true, // Alphabetical order + /// Indentation = 4 // Use 4 spaces for indentation + /// }; + /// + /// string yaml = YamlSerializer.Serialize(obj, settings); + /// + /// + /// Example for compact YAML output: + /// + /// var compactSettings = new YamlSerializerSettings + /// { + /// EmitNulls = false, + /// EmitDefaults = false, + /// Indentation = 2 + /// }; + /// + /// + /// + /// + public sealed class YamlSerializerSettings + { + /// + /// Gets or sets the naming convention used to convert .NET property names to YAML property names. + /// + /// + /// An implementation of that defines how property names are transformed. + /// Default is . + /// + /// + /// + /// This convention is applied during serialization to convert .NET property names (typically PascalCase) + /// to the desired naming convention in the output YAML. + /// + /// + /// Set to null to use exact .NET property names without transformation. + /// + /// + /// + /// With camelCase convention: + /// - C#: "FirstName" → YAML: "firstName" + /// - C#: "EmailAddress" → YAML: "emailAddress" + /// + public INamingConvention NamingConvention { get; init; } = new CamelCaseConvention(); + + /// + /// Gets or sets a value indicating whether null values should be included in the YAML output. + /// + /// + /// true to emit null values as explicit nulls in the YAML; false to omit null properties entirely. + /// Default is true. + /// + /// + /// + /// When set to true, null properties are written as property: null in the YAML output. + /// When set to false, properties with null values are completely omitted from the output. + /// + /// + /// Setting this to false can produce more compact YAML but may lose information about + /// which properties were explicitly set to null versus omitted. + /// + /// + /// + /// Given an object: new { Name = "John", Address = null } + /// + /// With EmitNulls = true: + /// + /// name: John + /// address: null + /// + /// + /// With EmitNulls = false: + /// + /// name: John + /// + /// + public bool EmitNulls { get; init; } = true; + + /// + /// Gets or sets a value indicating whether default values should be included in the YAML output. + /// + /// + /// true to emit default values (0, false, empty strings, etc.) in the YAML; + /// false to omit properties with default values. + /// Default is true. + /// + /// + /// + /// When set to false, properties with default values (0 for numbers, false for booleans, + /// default for structs, empty collections, etc.) are omitted from the YAML output. + /// + /// + /// This setting works in conjunction with - null values are handled + /// by that setting regardless of this one. + /// + /// + /// + /// Given an object: new { Count = 0, Enabled = false, Name = "" } + /// + /// With EmitDefaults = true: + /// + /// count: 0 + /// enabled: false + /// name: "" + /// + /// + /// With EmitDefaults = false: + /// + /// # All properties omitted since they have default values + /// + /// + public bool EmitDefaults { get; init; } = true; + + /// + /// Gets or sets a value indicating whether properties should be sorted alphabetically in the YAML output. + /// + /// + /// true to sort properties alphabetically; false to maintain the order in which + /// properties are declared in the class. Default is false. + /// + /// + /// + /// When set to false (default), properties are emitted in the order they are declared + /// in the class, which can provide more logical grouping of related properties. + /// + /// + /// When set to true, properties are sorted alphabetically by their YAML names (after + /// applying the naming convention), which can provide consistent ordering across different + /// .NET runtime implementations. + /// + /// + /// + /// Given a class: + /// + /// public class Example + /// { + /// public string LastName { get; set; } + /// public string FirstName { get; set; } + /// } + /// + /// + /// With SortProperties = false (declaration order): + /// + /// lastName: Smith + /// firstName: John + /// + /// + /// With SortProperties = true (alphabetical order): + /// + /// firstName: John + /// lastName: Smith + /// + /// + public bool SortProperties { get; init; } = false; + + /// + /// Gets or sets the number of spaces used for each level of indentation in the YAML output. + /// + /// + /// The number of spaces for each indentation level. Default is 2. + /// + /// + /// + /// Common values are 2 (standard YAML convention) or 4 (common in some codebases). + /// The value must be positive. + /// + /// + /// This setting affects the readability and compactness of the generated YAML. + /// Smaller values produce more compact output, while larger values can improve + /// readability for deeply nested structures. + /// + /// + /// + /// With Indentation = 2: + /// + /// parent: + /// child: + /// value: example + /// + /// + /// With Indentation = 4: + /// + /// parent: + /// child: + /// value: example + /// + /// + public int Indentation { get; init; } = 2; + + /// + /// Gets the list of custom type converters used during serialization. + /// + /// + /// A list of instances that handle serialization of specific types. + /// Default includes for basic types. + /// + /// + /// + /// Custom converters allow you to control how specific .NET types are serialized to YAML. + /// You can add converters for custom types or override the default serialization behavior + /// for built-in types. + /// + /// + /// Converters are evaluated in order, and the first converter that can handle a type is used. + /// + /// + /// + /// Adding a custom converter for a DateTime format: + /// + /// var settings = new YamlSerializerSettings(); + /// settings.Converters.Add(new CustomDateTimeConverter("yyyy-MM-dd")); + /// + /// string yaml = YamlSerializer.Serialize(obj, settings); + /// + /// + /// Adding a converter for a custom type: + /// + /// public class ColorConverter : IYamlTypeConverter + /// { + /// public bool CanConvert(Type type) => type == typeof(Color); + /// + /// public void WriteYaml(YamlWriter writer, object value) + /// { + /// var color = (Color)value; + /// writer.WriteValue($"#{color.R:X2}{color.G:X2}{color.B:X2}"); + /// } + /// } + /// + /// + public List Converters { get; } = new() { new PrimitiveConverter() }; + } +} \ No newline at end of file diff --git a/src/Cortex.Serialization.Yaml/YamlDeserializer.cs b/src/Cortex.Serialization.Yaml/YamlDeserializer.cs new file mode 100644 index 0000000..1bab558 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/YamlDeserializer.cs @@ -0,0 +1,414 @@ +using Cortex.Serialization.Yaml.Converters; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Cortex.Serialization.Yaml +{ + /// + /// Provides methods for deserializing YAML content into .NET objects. + /// + /// + /// + /// The class converts YAML documents into .NET objects, supporting + /// a wide range of types including primitives, collections, dictionaries, and custom classes. + /// It offers both instance-based and static convenience methods for deserialization. + /// + /// + /// Key features include: + /// + /// Bidirectional naming convention support + /// Custom type converters for specialized serialization + /// Flexible property matching (case-sensitive or insensitive) + /// Configurable handling of unmatched properties + /// Support for arrays, lists, dictionaries, and complex object graphs + /// + /// + /// + /// + /// + /// Basic usage with static methods: + /// + /// + /// // Deserialize from string + /// var person = YamlDeserializer.Deserialize<Person>(yamlString); + /// + /// // Deserialize with custom settings + /// var settings = new YamlDeserializerSettings + /// { + /// CaseInsensitive = false, + /// IgnoreUnmatchedProperties = false + /// }; + /// var config = YamlDeserializer.Deserialize<Config>(yamlString, settings); + /// + /// // Deserialize from file + /// using var reader = new StreamReader("config.yaml"); + /// var data = YamlDeserializer.Deserialize<DataModel>(reader); + /// + /// + /// + /// Advanced usage with instance methods and custom converters: + /// + /// + /// // Create deserializer with custom converters + /// var converters = new List<IYamlTypeConverter> { new CustomDateConverter() }; + /// var deserializer = new YamlDeserializer(settings, converters); + /// + /// // Reuse the same instance for multiple deserializations + /// var obj1 = deserializer.Deserialize<Type1>(yaml1); + /// var obj2 = deserializer.Deserialize<Type2>(yaml2); + /// + /// + /// + /// + /// + public sealed class YamlDeserializer + { + private readonly YamlDeserializerSettings _settings; + private readonly List _converters = new() + { + new PrimitiveConverter() + }; + + + // --- Static convenience API --- + + /// + /// Deserializes YAML content from a string into an instance of the specified type. + /// + /// The type of object to deserialize. + /// The YAML string to deserialize. + /// Optional settings to control deserialization behavior. + /// Optional additional type converters to use during deserialization. + /// An instance of populated with data from the YAML string. + /// + /// Thrown when the YAML is invalid, cannot be parsed, or cannot be converted to the target type. + /// + /// + /// + /// This is a convenience method that creates a temporary instance + /// for a single deserialization operation. For multiple deserializations, consider creating + /// a instance and reusing it. + /// + /// + /// + /// + /// string yaml = @"name: John Smith + /// age: 30 + /// email: john@example.com"; + /// + /// var person = YamlDeserializer.Deserialize<Person>(yaml); + /// Console.WriteLine(person.Name); // "John Smith" + /// + /// + public static T Deserialize(string input, + YamlDeserializerSettings? settings = null, + IEnumerable? extra = null) + => new YamlDeserializer(settings, extra).Deserialize(input); + + /// + /// Deserializes YAML content from a string into an instance of the specified type. + /// + /// The YAML string to deserialize. + /// The type of object to deserialize. + /// Optional settings to control deserialization behavior. + /// Optional additional type converters to use during deserialization. + /// An instance of the specified type populated with data from the YAML string. + /// + /// Thrown when the YAML is invalid, cannot be parsed, or cannot be converted to the target type. + /// + /// + /// + /// Use this overload when the target type is not known at compile time. + /// + /// + /// + /// + /// string yaml = @"name: John Smith + /// age: 30"; + /// + /// Type targetType = typeof(Person); + /// var person = YamlDeserializer.Deserialize(yaml, targetType); + /// + /// + public static object? Deserialize(string input, Type t, + YamlDeserializerSettings? settings = null, + IEnumerable? extra = null) + => new YamlDeserializer(settings, extra).Deserialize(input, t); + + /// + /// Deserializes YAML content from a into an instance of the specified type. + /// + /// The type of object to deserialize. + /// The containing YAML content. + /// Optional settings to control deserialization behavior. + /// Optional additional type converters to use during deserialization. + /// An instance of populated with data from the YAML content. + /// + /// Thrown when the YAML is invalid, cannot be parsed, or cannot be converted to the target type. + /// + /// + /// + /// This method is suitable for reading YAML from files, network streams, or any other + /// source that can be wrapped in a . + /// + /// + /// + /// + /// using var reader = new StreamReader("data.yaml"); + /// var data = YamlDeserializer.Deserialize<DataModel>(reader); + /// + /// + public static T Deserialize(TextReader reader, + YamlDeserializerSettings? settings = null, + IEnumerable? extra = null) + => new YamlDeserializer(settings, extra).Deserialize(reader); + + /// + /// Deserializes YAML content from a into an instance of the specified type. + /// + /// The containing YAML content. + /// The type of object to deserialize. + /// Optional settings to control deserialization behavior. + /// Optional additional type converters to use during deserialization. + /// An instance of the specified type populated with data from the YAML content. + /// + /// Thrown when the YAML is invalid, cannot be parsed, or cannot be converted to the target type. + /// + /// + /// + /// using var reader = new StringReader(yamlString); + /// Type targetType = typeof(Person); + /// var person = YamlDeserializer.Deserialize(reader, targetType); + /// + /// + public static object? Deserialize(TextReader reader, Type t, + YamlDeserializerSettings? settings = null, + IEnumerable? extra = null) + => new YamlDeserializer(settings, extra).Deserialize(reader, t); + // ------------------------------------ + + /// + /// Initializes a new instance of the class with the specified settings and converters. + /// + /// The settings to use for deserialization. If null, default settings are used. + /// Additional type converters to use during deserialization. + /// + /// + /// Creating an instance of is recommended when you need to perform + /// multiple deserialization operations with the same configuration, as it avoids the overhead + /// of recreating internal structures for each operation. + /// + /// + /// The deserializer is initialized with a by default, which handles + /// basic types like strings, numbers, and booleans. Additional converters can be provided to handle + /// custom types or override default conversion behavior. + /// + /// + /// + /// + /// // With default settings + /// var deserializer1 = new YamlDeserializer(); + /// + /// // With custom settings + /// var settings = new YamlDeserializerSettings { CaseInsensitive = false }; + /// var deserializer2 = new YamlDeserializer(settings); + /// + /// // With custom settings and converters + /// var converters = new List<IYamlTypeConverter> { new CustomConverter() }; + /// var deserializer3 = new YamlDeserializer(settings, converters); + /// + /// + public YamlDeserializer(YamlDeserializerSettings? s = null, IEnumerable? extra = null) + { + _settings = s ?? new(); + if (extra is not null) + _converters.AddRange(extra); + } + + /// + /// Deserializes YAML content from a into an instance of the specified type. + /// + /// The type of object to deserialize. + /// The containing YAML content. + /// An instance of populated with data from the YAML content. + /// Thrown when is null. + /// + /// Thrown when the YAML is invalid, cannot be parsed, or cannot be converted to the target type. + /// + /// + /// + /// This method reads the entire content from the and parses it as YAML. + /// The reader is not closed or disposed by this method - the caller remains responsible for resource management. + /// + /// + public T Deserialize(TextReader reader) + { + var doc = Parse(reader.ReadToEnd()); + return (T)ConvertNode(doc, typeof(T))!; + } + + /// + /// Deserializes YAML content from a into an instance of the specified type. + /// + /// The containing YAML content. + /// The type of object to deserialize. + /// An instance of the specified type populated with data from the YAML content. + /// + /// Thrown when or is null. + /// + /// + /// Thrown when the YAML is invalid, cannot be parsed, or cannot be converted to the target type. + /// + public object? Deserialize(TextReader reader, Type t) + { + var doc = Parse(reader.ReadToEnd()); + return ConvertNode(doc, t); + } + + /// + /// Deserializes a YAML string into an instance of the specified type. + /// + /// The type of object to deserialize. + /// The YAML string to deserialize. + /// An instance of populated with data from the YAML string. + /// Thrown when is null. + /// + /// Thrown when the YAML is invalid, cannot be parsed, or cannot be converted to the target type. + /// + /// + /// + /// This is the primary method for deserializing YAML strings. It parses the YAML content + /// and converts it to the specified .NET type using the configured settings and converters. + /// + /// + public T Deserialize(string input) + { + var doc = Parse(input); + return (T)ConvertNode(doc, typeof(T))!; + } + + /// + /// Deserializes a YAML string into an instance of the specified type. + /// + /// The YAML string to deserialize. + /// The type of object to deserialize. + /// An instance of the specified type populated with data from the YAML string. + /// + /// Thrown when or is null. + /// + /// + /// Thrown when the YAML is invalid, cannot be parsed, or cannot be converted to the target type. + /// + /// + /// + /// Use this overload when the target type is determined at runtime rather than compile time. + /// + /// + public object? Deserialize(string input, Type t) + { + var doc = Parse(input); + return ConvertNode(doc, t); + } + + private Parser.YamlNode Parse(string input) + { + var scanner = new Parser.Scanner(input); + var tokens = scanner.Scan(); + var parser = new Parser.Parser(tokens); + return parser.ParseDocument(); + } + + private object? ConvertNode(Parser.YamlNode node, Type target) + { + foreach (var c in _converters) + if (c.CanConvert(target)) + return c.Read((node as Parser.YamlScalar)?.Value, target); + + if (node is Parser.YamlScalar s) + { + if (target == typeof(object)) + return s.Value; + foreach (var c in _converters) + if (c.CanConvert(target)) + return c.Read(s.Value, target); + return s.Value; + } + + if (typeof(System.Collections.IDictionary).IsAssignableFrom(target)) + { + var (kt, vt) = GetDictTypes(target); + var dict = (System.Collections.IDictionary)Activator.CreateInstance(target)!; + if (node is not Parser.YamlMapping map) + throw new Common.YamlException("Expected mapping for dictionary"); + foreach (var (k, v) in map.Entries) + { + var keyObj = Convert.ChangeType(k, kt); + var valObj = ConvertNode(v, vt); + dict.Add(keyObj!, valObj); + } + return dict; + } + + if (typeof(System.Collections.IEnumerable).IsAssignableFrom(target) && target != typeof(string)) + { + var et = target.IsArray ? target.GetElementType()! : target.GenericTypeArguments.FirstOrDefault() ?? typeof(object); + var list = (System.Collections.IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(et))!; + if (node is not Parser.YamlSequence seq) + throw new Common.YamlException("Expected sequence for collection"); + + foreach (var it in seq.Items) + list.Add(ConvertNode(it, et)); + if (target.IsArray) + { + var arr = Array.CreateInstance(et, list.Count); + list.CopyTo(arr, 0); return arr; + } + if (target.IsAssignableFrom(list.GetType())) + return list; + var coll = Activator.CreateInstance(target); + var add = target.GetMethod("Add"); + foreach (var it in list) + add!.Invoke(coll, new[] { it }); + return coll; + } + + if (node is Parser.YamlMapping mapping) + { + var obj = Activator.CreateInstance(target)!; + var lookup = BuildMemberLookup(target); + foreach (var (key, val) in mapping.Entries) + { + if (!lookup.TryGetValue(key, out var pm)) + { + if (!_settings.IgnoreUnmatchedProperties) + throw new Common.YamlException($"Unknown property '{key}' for type {target.Name}"); + continue; + } + var converted = ConvertNode(val, pm.MemberType); + pm.SetValue(obj, converted); + } + return obj; + } + throw new Common.YamlException($"Cannot convert node {node.GetType().Name} to {target.Name}"); + } + private Dictionary BuildMemberLookup(Type t) + { + var insp = new Reflection.TypeInspector(_settings.NamingConvention); + var dict = new Dictionary(_settings.CaseInsensitive ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + foreach (var m in insp.GetDeserializableMembers(t)) + dict[_settings.NamingConvention.Convert(m.Name)] = m; + return dict; + } + private static (Type key, Type val) GetDictTypes(Type dict) + { + if (dict.IsGenericType) + { + var a = dict.GetGenericArguments(); + return (a[0], a[1]); + } + return (typeof(string), typeof(object)); + } + } +} \ No newline at end of file diff --git a/src/Cortex.Serialization.Yaml/YamlSerializer.cs b/src/Cortex.Serialization.Yaml/YamlSerializer.cs new file mode 100644 index 0000000..b8a4957 --- /dev/null +++ b/src/Cortex.Serialization.Yaml/YamlSerializer.cs @@ -0,0 +1,250 @@ +using Cortex.Serialization.Yaml.Reflection; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Cortex.Serialization.Yaml +{ + /// + /// Provides methods for serializing .NET objects into YAML format. + /// + /// + /// + /// The class converts .NET objects into YAML documents, supporting + /// a wide range of types including primitives, collections, dictionaries, and custom classes. + /// It offers both instance-based and static convenience methods for serialization. + /// + /// + /// Key features include: + /// + /// Configurable naming conventions for property names + /// Control over null and default value emission + /// Property ordering (declaration order or alphabetical) + /// Customizable indentation + /// Automatic handling of collections and dictionaries + /// + /// + /// + /// The serializer automatically handles common .NET types including: + /// + /// Primitives: string, bool, int, long, double, decimal, Guid, DateTime, DateOnly, TimeOnly + /// Collections: arrays, lists, and any type implementing + /// Dictionaries: any type implementing + /// Custom objects: public properties are serialized as YAML mappings + /// + /// + /// + /// + /// + /// Basic usage with static method: + /// + /// + /// var person = new Person { Name = "John", Age = 30 }; + /// string yaml = YamlSerializer.Serialize(person); + /// + /// // Output: + /// // name: John + /// // age: 30 + /// + /// + /// + /// Serializing collections and complex objects: + /// + /// + /// var users = new List<Person> + /// { + /// new Person { Name = "John", Age = 30 }, + /// new Person { Name = "Jane", Age = 25 } + /// }; + /// + /// string yaml = YamlSerializer.Serialize(users); + /// + /// // Output: + /// // - name: John + /// // age: 30 + /// // - name: Jane + /// // age: 25 + /// + /// + /// + /// Using instance methods for multiple serializations: + /// + /// + /// var settings = new YamlSerializerSettings + /// { + /// EmitNulls = false, + /// SortProperties = true + /// }; + /// + /// var serializer = new YamlSerializer(settings); + /// string yaml1 = serializer.Serialize(obj1); + /// string yaml2 = serializer.Serialize(obj2); + /// + /// + /// + /// + public sealed class YamlSerializer + { + private readonly YamlSerializerSettings _settings; + + // --- Static convenience API --- + + /// + /// Serializes the specified object to a YAML string using the provided settings. + /// + /// The object to serialize. + /// The settings to use for serialization, or null to use default settings. + /// A YAML string representing the serialized object. + /// + /// + /// This is a convenience method that creates a temporary instance + /// for a single serialization operation. For multiple serializations, consider creating + /// a instance and reusing it. + /// + /// + /// If is null, the method returns a YAML null value. + /// + /// + /// + /// + /// var person = new Person { Name = "John", Age = 30 }; + /// + /// // With default settings + /// string yaml1 = YamlSerializer.Serialize(person); + /// + /// // With custom settings + /// var settings = new YamlSerializerSettings { EmitNulls = false }; + /// string yaml2 = YamlSerializer.Serialize(person, settings); + /// + /// + public static string Serialize(object? obj, YamlSerializerSettings? settings = null) + => new YamlSerializer(settings).Serialize(obj); + + // --- Instance API --- + + /// + /// Initializes a new instance of the class with the specified settings. + /// + /// The settings to use for serialization. If null, default settings are used. + /// + /// + /// Creating an instance of is recommended when you need to perform + /// multiple serialization operations with the same configuration, as it avoids the overhead + /// of recreating internal structures for each operation. + /// + /// + /// + /// + /// // With default settings + /// var serializer1 = new YamlSerializer(); + /// + /// // With custom settings + /// var settings = new YamlSerializerSettings + /// { + /// EmitNulls = false, + /// SortProperties = true + /// }; + /// var serializer2 = new YamlSerializer(settings); + /// + /// + public YamlSerializer(YamlSerializerSettings? s = null) => _settings = s ?? new(); + + /// + /// Serializes the specified object to a YAML string. + /// + /// The object to serialize. + /// A YAML string representing the serialized object. + /// + /// + /// The serialization process follows these steps: + /// + /// Convert the object to a YAML node tree using + /// Emit the YAML node tree to a string using the configured indentation + /// + /// + /// + /// The method handles null values by returning a YAML null scalar. For objects, + /// only public readable properties are serialized (read-only properties are excluded). + /// + /// + /// + /// + /// var serializer = new YamlSerializer(); + /// + /// // Serialize a simple object + /// var person = new Person { Name = "John", Age = 30 }; + /// string yaml = serializer.Serialize(person); + /// + /// // Serialize a collection + /// var numbers = new List<int> { 1, 2, 3 }; + /// string yamlNumbers = serializer.Serialize(numbers); + /// + /// // Serialize a dictionary + /// var dict = new Dictionary<string, object> + /// { + /// ["key1"] = "value1", + /// ["key2"] = 42 + /// }; + /// string yamlDict = serializer.Serialize(dict); + /// + /// + public string Serialize(object? obj) + { + var node = ToNode(obj); + var emitter = new Emitter.Emitter(_settings.Indentation); + + return emitter.Emit(node); + } + + private Parser.YamlNode ToNode(object? obj) + { + if (obj is null) + return new Parser.YamlScalar(null); + + // Handle primitive types as scalars + if (obj is string or bool or int or long or double or decimal or Guid or DateTime or DateOnly or TimeOnly) + return new Parser.YamlScalar(obj); + + // Handle dictionaries as mappings + if (obj is System.Collections.IDictionary dict) + { + var m = new Dictionary(); + foreach (System.Collections.DictionaryEntry de in dict) + m[de.Key!.ToString()!] = ToNode(de.Value); + return new Parser.YamlMapping(m); + } + + // Handle collections as sequences (but not strings, which are handled above) + if (obj is System.Collections.IEnumerable en && obj is not string) + { + var items = new List(); + foreach (var it in en) items.Add(ToNode(it)); + return new Parser.YamlSequence(items); + } + + // Handle complex objects as mappings with properties + var insp = new TypeInspector(_settings.NamingConvention); + var map = new Dictionary(); + var props = insp.GetSerializableMembers(obj.GetType(), includeReadOnly: false); + var seq = _settings.SortProperties ? props.OrderBy(p => p.Name) : props; + + foreach (var p in seq) + { + var val = p.GetValue(obj); + if (val is null && !_settings.EmitNulls) + continue; + if (val is not null && IsDefault(val) && !_settings.EmitDefaults) + continue; + map[p.Name] = ToNode(val); + } + + return new Parser.YamlMapping(map); + } + + private static bool IsDefault(object value) + { + var t = value.GetType(); + return Equals(value, t.IsValueType ? Activator.CreateInstance(t) : null); + } + } +} \ No newline at end of file diff --git a/src/Cortex.States/Abstractions/IAsyncDataStore.cs b/src/Cortex.States/Abstractions/IAsyncDataStore.cs new file mode 100644 index 0000000..621f9aa --- /dev/null +++ b/src/Cortex.States/Abstractions/IAsyncDataStore.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Cortex.States.Abstractions +{ + public interface IAsyncDataStore : IDataStore + { + Task GetAsync( + TKey key, + CancellationToken cancellationToken = default); + + Task PutAsync( + TKey key, + TValue value, + CancellationToken cancellationToken = default); + + Task ContainsKeyAsync( + TKey key, + CancellationToken cancellationToken = default); + + Task RemoveAsync( + TKey key, + CancellationToken cancellationToken = default); + +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_0_OR_GREATER + IAsyncEnumerable> GetAllAsync( + CancellationToken cancellationToken = default); + + IAsyncEnumerable GetKeysAsync( + CancellationToken cancellationToken = default); +#endif + + Task> GetManyAsync( + IEnumerable keys, + CancellationToken cancellationToken = default); + + Task PutManyAsync( + IEnumerable> items, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Cortex.States/Abstractions/IStateStore.cs b/src/Cortex.States/Abstractions/IDataStore.cs similarity index 100% rename from src/Cortex.States/Abstractions/IStateStore.cs rename to src/Cortex.States/Abstractions/IDataStore.cs diff --git a/src/Cortex.States/InMemoryStateStore.cs b/src/Cortex.States/InMemoryStateStore.cs index e898aed..173114b 100644 --- a/src/Cortex.States/InMemoryStateStore.cs +++ b/src/Cortex.States/InMemoryStateStore.cs @@ -1,10 +1,14 @@ -using System; +using Cortex.States.Abstractions; +using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; namespace Cortex.States { - public class InMemoryStateStore : IDataStore + public class InMemoryStateStore : IAsyncDataStore { private readonly ConcurrentDictionary _store = new ConcurrentDictionary(); @@ -12,7 +16,7 @@ public class InMemoryStateStore : IDataStore public InMemoryStateStore(string name) { - Name = name; + Name = name ?? throw new ArgumentNullException(nameof(name)); } public TValue Get(TKey key) @@ -47,5 +51,99 @@ public IEnumerable GetKeys() { return _store.Keys; } + + public Task GetAsync(TKey key, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + _store.TryGetValue(key, out var value); + return Task.FromResult(value); + } + + public Task PutAsync(TKey key, TValue value, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + _store[key] = value; + return Task.CompletedTask; + } + + public Task ContainsKeyAsync(TKey key, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var contains = _store.ContainsKey(key); + return Task.FromResult(contains); + } + + public Task RemoveAsync(TKey key, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + _store.TryRemove(key, out _); + return Task.CompletedTask; + } + +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_0_OR_GREATER + public async IAsyncEnumerable> GetAllAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var item in _store) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return item; + + // Optional: yield control for very large stores + await Task.Yield(); + } + } + + public async IAsyncEnumerable GetKeysAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var key in _store.Keys) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return key; + + // Optional: yield control for very large keysets + await Task.Yield(); + } + } +#endif + + public Task> GetManyAsync(IEnumerable keys, CancellationToken cancellationToken = default) + { + if (keys == null) throw new ArgumentNullException(nameof(keys)); + cancellationToken.ThrowIfCancellationRequested(); + + var result = new Dictionary(); + + foreach (var key in keys) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_store.TryGetValue(key, out var value)) + { + result[key] = value; + } + } + + return Task.FromResult((IDictionary)result); + } + + public Task PutManyAsync(IEnumerable> items, CancellationToken cancellationToken = default) + { + if (items == null) throw new ArgumentNullException(nameof(items)); + cancellationToken.ThrowIfCancellationRequested(); + + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + _store[item.Key] = item.Value; + } + + return Task.CompletedTask; + } } } diff --git a/src/Cortex.Tests/Cortex.Tests.csproj b/src/Cortex.Tests/Cortex.Tests.csproj index 6208286..5de5105 100644 --- a/src/Cortex.Tests/Cortex.Tests.csproj +++ b/src/Cortex.Tests/Cortex.Tests.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -9,12 +9,6 @@ true - - - - - - all @@ -30,6 +24,7 @@ + diff --git a/src/Cortex.Tests/Mediator/FluentValidation/Tests/ValidationCommandBehaviorTests.cs b/src/Cortex.Tests/Mediator/FluentValidation/Tests/ValidationCommandBehaviorTests.cs new file mode 100644 index 0000000..b77ec8e --- /dev/null +++ b/src/Cortex.Tests/Mediator/FluentValidation/Tests/ValidationCommandBehaviorTests.cs @@ -0,0 +1,70 @@ +using Cortex.Mediator.Behaviors; +using Cortex.Mediator.Commands; +using FluentValidation; +using FluentValidation.Results; +using Moq; + +namespace Cortex.Tests.Mediator.FluentValidation.Tests +{ + public class ValidationCommandBehaviorTests + { + public class FakeCommand : ICommand + { + } + + [Fact] + public async Task Handle_ShouldNotThrowExceptionsWhenThereAreNoValidationFailures() + { + // Arrange + var expectedResult = "completed"; + var validator = new Mock>(); + validator.Setup(v => v.ValidateAsync(It.IsAny(), default)).Returns(() => Task.FromResult(new ValidationResult { Errors = new List()})); + var validators = new IValidator[] + { + validator.Object, + }; + + var next = new Mock>(); + next.Setup(n => n.Invoke()).Returns(Task.FromResult(expectedResult)); + var systemUnderTest = new ValidationCommandBehavior(validators); + + // Act + + var result = await systemUnderTest.Handle(new FakeCommand(), next.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedResult, result); + } + + [Fact] + public async Task Handle_ShouldThrowAnExceptionsWhenThereIsAValidationFailure() + { + // Arrange + var expectedResult = "completed"; + var validator = new Mock>(); + validator.Setup(v => v.ValidateAsync(It.IsAny(), default)) + .Returns(() => Task.FromResult(new ValidationResult + { + Errors = new List + { + new ValidationFailure("some-property", "was invalid") + } + } + )); + + var validators = new IValidator[] + { + validator.Object, + }; + + var next = new Mock>(); + next.Setup(n => n.Invoke()).Returns(Task.FromResult(expectedResult)); + var systemUnderTest = new ValidationCommandBehavior(validators); + + // Act + // Assert + await Assert.ThrowsAsync(async () => await systemUnderTest.Handle(new FakeCommand(), next.Object, CancellationToken.None)); + } + } +} diff --git a/src/Cortex.Tests/Mediator/FluentValidation/Tests/ValidationQueryBehaviorTests.cs b/src/Cortex.Tests/Mediator/FluentValidation/Tests/ValidationQueryBehaviorTests.cs new file mode 100644 index 0000000..882e70c --- /dev/null +++ b/src/Cortex.Tests/Mediator/FluentValidation/Tests/ValidationQueryBehaviorTests.cs @@ -0,0 +1,69 @@ +using Cortex.Mediator.Behaviors.FluentValidation; +using Cortex.Mediator.Queries; +using FluentValidation; +using FluentValidation.Results; +using Moq; + +namespace Cortex.Tests.Mediator.FluentValidation.Tests +{ + public class FakeQuery : IQuery + { + } + + public class ValidationQueryBehaviorTests + { + [Fact] + public async Task Handle_ShouldNotThrowExceptionsWhenThereAreNoValidationFailures() + { + // Arrange + var expectedResult = "completed"; + var validator = new Mock>(); + validator.Setup(v => v.ValidateAsync(It.IsAny(), default)).Returns(() => Task.FromResult(new ValidationResult { Errors = new List()})); + var validators = new IValidator[] + { + validator.Object, + }; + + var next = new Mock>(); + next.Setup(n => n.Invoke()).Returns(Task.FromResult(expectedResult)); + var systemUnderTest = new ValidationQueryBehavior(validators); + + // Act + var result = await systemUnderTest.Handle(new FakeQuery(), next.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedResult, result); + } + + [Fact] + public async Task Handle_ShouldThrowAnExceptionsWhenThereIsAValidationFailure() + { + // Arrange + var expectedResult = "completed"; + var validator = new Mock>(); + validator.Setup(v => v.ValidateAsync(It.IsAny(), default)) + .Returns(() => Task.FromResult(new ValidationResult + { + Errors = + [ + new("some-property", "was invalid") + ] + } + )); + + var validators = new IValidator[] + { + validator.Object, + }; + + var next = new Mock>(); + next.Setup(n => n.Invoke()).Returns(Task.FromResult(expectedResult)); + var systemUnderTest = new ValidationQueryBehavior(validators); + + // Act + // Assert + await Assert.ThrowsAsync(async () => await systemUnderTest.Handle(new FakeQuery(), next.Object, CancellationToken.None)); + } + } +} diff --git a/src/Cortex.Tests/Mediator/FluentValidation/Tests/VoidValidationCommandBehaviorTests.cs b/src/Cortex.Tests/Mediator/FluentValidation/Tests/VoidValidationCommandBehaviorTests.cs new file mode 100644 index 0000000..9632715 --- /dev/null +++ b/src/Cortex.Tests/Mediator/FluentValidation/Tests/VoidValidationCommandBehaviorTests.cs @@ -0,0 +1,66 @@ +using Cortex.Mediator.Behaviors; +using Cortex.Mediator.Commands; +using FluentValidation; +using FluentValidation.Results; +using Moq; + +namespace Cortex.Tests.Mediator.FluentValidation.Tests +{ + public class FakeVoidCommand : ICommand + { + } + + public class VoidValidationCommandBehaviorTests + { + [Fact] + public async Task Handle_ShouldNotThrowExceptionsWhenThereAreNoValidationFailures() + { + // Arrange + var expectedResult = "completed"; + var validator = new Mock>(); + validator.Setup(v => v.ValidateAsync(It.IsAny(), default)).Returns(() => Task.FromResult(new ValidationResult { Errors = new List()})); + var validators = new IValidator[] + { + validator.Object, + }; + + var next = new Mock(); + next.Setup(n => n.Invoke()).Returns(Task.FromResult(expectedResult)); + var systemUnderTest = new ValidationCommandBehavior(validators); + + // Act + // Assert + await systemUnderTest.Handle(new FakeVoidCommand(), next.Object, CancellationToken.None); + } + + [Fact] + public async Task Handle_ShouldThrowAnExceptionsWhenThereIsAValidationFailure() + { + // Arrange + var expectedResult = "completed"; + var validator = new Mock>(); + validator.Setup(v => v.ValidateAsync(It.IsAny(), default)) + .Returns(() => Task.FromResult(new ValidationResult + { + Errors = new List + { + new ValidationFailure("some-property", "was invalid") + } + } + )); + + var validators = new IValidator[] + { + validator.Object, + }; + + var next = new Mock(); + next.Setup(n => n.Invoke()).Returns(Task.FromResult(expectedResult)); + var systemUnderTest = new ValidationCommandBehavior(validators); + + // Act + // Assert + await Assert.ThrowsAsync(async () => await systemUnderTest.Handle(new FakeVoidCommand(), next.Object, CancellationToken.None)); + } + } +}