From 34e0e671156262e1c62667fff3f4103e6346a508 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 25 Sep 2025 14:54:55 +0200 Subject: [PATCH 01/73] Initial version of EmailAddress --- .editorconfig | 16 + Directory.Build.props | 49 +++ Directory.Packages.props | 16 + PosInformatique.Foundations.sln | 57 +++ README.md | 42 +- src/Directory.Build.props | 26 ++ src/EmailAddresses/EmailAddress.cs | 326 +++++++++++++++ src/EmailAddresses/EmailAddresses.csproj | 17 + src/EmailAddresses/README.md | 74 ++++ stylecop.json | 9 + tests/.editorconfig | 6 + tests/Directory.Build.props | 35 ++ .../EmailAddresses.Tests/EmailAddressTest.cs | 380 ++++++++++++++++++ .../EmailAddresses.Tests.csproj | 11 + 14 files changed, 1062 insertions(+), 2 deletions(-) create mode 100644 .editorconfig create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 PosInformatique.Foundations.sln create mode 100644 src/Directory.Build.props create mode 100644 src/EmailAddresses/EmailAddress.cs create mode 100644 src/EmailAddresses/EmailAddresses.csproj create mode 100644 src/EmailAddresses/README.md create mode 100644 stylecop.json create mode 100644 tests/.editorconfig create mode 100644 tests/Directory.Build.props create mode 100644 tests/EmailAddresses.Tests/EmailAddressTest.cs create mode 100644 tests/EmailAddresses.Tests/EmailAddresses.Tests.csproj diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d260ef3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8-bom +insert_final_newline = true +trim_trailing_whitespace = true + +# x.proj specific settings +[*.{csproj,props}] +indent_style = space +indent_size = 2 + +# C# specific settings +[*.cs] +indent_style = space +indent_size = 4 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..1f0aa1a --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,49 @@ + + + + + Gilles TOURREAU + P.O.S Informatique + P.O.S Informatique + Copyright (c) P.O.S Informatique. All rights reserved. + https://github.com/PosInformatique/PosInformatique.Foundations + git + + + latest + + + enable + + + $(NoWarn);SA0001;NU1903 + + + true + + + PosInformatique.Foundations.$(MSBuildProjectName) + PosInformatique.Foundations.$(MSBuildProjectName.Replace(" ", "_")) + + + + + stylecop.json + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..2293ccc --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,16 @@ + + + true + + + + + + + + + + + + + \ No newline at end of file diff --git a/PosInformatique.Foundations.sln b/PosInformatique.Foundations.sln new file mode 100644 index 0000000..98020ea --- /dev/null +++ b/PosInformatique.Foundations.sln @@ -0,0 +1,57 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36511.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses", "src\EmailAddresses\EmailAddresses.csproj", "{6B43B51B-A93C-4B4F-AD4D-4B74A2E7DB3F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.Tests", "tests\EmailAddresses.Tests\EmailAddresses.Tests.csproj", "{BAE006E4-4A1E-4E52-8CA1-D341BD0B26EE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .gitignore = .gitignore + Directory.Build.props = Directory.Build.props + Directory.Packages.props = Directory.Packages.props + LICENSE = LICENSE + README.md = README.md + stylecop.json = stylecop.json + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{DFE1E9A1-3CB7-4FA4-B304-711772346C43}" + ProjectSection(SolutionItems) = preProject + tests\.editorconfig = tests\.editorconfig + tests\Directory.Build.props = tests\Directory.Build.props + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{FAA7960F-95C1-45E2-9A42-EED477DF97F1}" + ProjectSection(SolutionItems) = preProject + src\Directory.Build.props = src\Directory.Build.props + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6B43B51B-A93C-4B4F-AD4D-4B74A2E7DB3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B43B51B-A93C-4B4F-AD4D-4B74A2E7DB3F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B43B51B-A93C-4B4F-AD4D-4B74A2E7DB3F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B43B51B-A93C-4B4F-AD4D-4B74A2E7DB3F}.Release|Any CPU.Build.0 = Release|Any CPU + {BAE006E4-4A1E-4E52-8CA1-D341BD0B26EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BAE006E4-4A1E-4E52-8CA1-D341BD0B26EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BAE006E4-4A1E-4E52-8CA1-D341BD0B26EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BAE006E4-4A1E-4E52-8CA1-D341BD0B26EE}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {DFE1E9A1-3CB7-4FA4-B304-711772346C43} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {FAA7960F-95C1-45E2-9A42-EED477DF97F1} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {344068EF-5958-4241-BD83-86403ADA68F1} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 908ec8f..5dbae39 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,40 @@ -# PosInformatique.Foundations -A lightweight collection of foundational .NET libraries for standardizing technical and functional development with reusable components. +# PosInformatique.Foundation + +PosInformatique.Foundation is a collection of small, focused .NET libraries that provide **simple, reusable building blocks** for your applications. + +The goal is to avoid shipping a monolithic framework by creating **modular NuGet packages**, each addressing a single responsibility. + +## ✨ Philosophy + +- **Granular**: each library is independent, lightweight, and minimal. +- **Composable**: you bring exactly the pieces you need, nothing more. +- **Practical**: packages can be foundational (value objects, abstractions, contracts) or technical utilities (helpers, validation rules, extensions). +- **Consistent**: all packages follow the same naming convention and version alignment. +- **Standards-based**: whenever possible, implementations follow well-known standards (e.g. RFC 5322 for email addresses, E.164 for phone numbers,...). + +➡️ Each package has **no strong dependency** on another. You are free to pick only what you need. +➡️ These libraries are **not structuring frameworks**; they are small utilities meant to fill missing gaps in your applications. + +## 📦 Installation + +You can install any package using the .NET CLI or NuGet Package Manager. + +## 📦 Packages Overview + +| Package | Description | NuGet | +|---------|-------------|-------| +| [**PosInformatique.Foundation.EmailAddresses**](./EmailAddresses/README.md) | Strongly-typed value object representing an email address with validation and normalization. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundation.EmailAddress)](https://www.nuget.org/packages/PosInformatique.Foundation.EmailAddress) | + +> Note: Each package is completely independent. You install only what you need. + +## 🚀 Why use PosInformatique.Foundation? + +- Avoid reinventing common value objects and utilities. +- Apply standards-based implementations (RFC, E.164, ...). +- Improve consistency across your projects. +- Get lightweight, modular libraries tailored to single responsibilities. +- Add missing building blocks to your projects without introducing a heavyweight framework. + +## 📄 License + +Licensed under the [MIT License](./LICENSE). \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..ccc084a --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,26 @@ + + + + + + + enable + + true + + Icon.png + https://github.com/PosInformatique/PosInformatique.Foundations + README.md + MIT + + + + + + <_Parameter1>$(AssemblyName).Tests + + + <_Parameter1>DynamicProxyGenAssembly2 + + + \ No newline at end of file diff --git a/src/EmailAddresses/EmailAddress.cs b/src/EmailAddresses/EmailAddress.cs new file mode 100644 index 0000000..3408c0f --- /dev/null +++ b/src/EmailAddresses/EmailAddress.cs @@ -0,0 +1,326 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations +{ + using System.Diagnostics.CodeAnalysis; + using MimeKit; + + /// + /// Represents a valid e-mail address. Any attempt to create an invalid e-mail is rejected. + /// + /// + /// This class provides several features: + /// + /// + /// Implements and so that instances + /// can be compared and used seamlessly in generic scenarios such as collections, sorting, + /// or equality checks. + /// + /// + /// Implements and to enable generic + /// conversion to and from string representations, making it easy to integrate with a wide + /// range of components that rely on string formatting and parsing. + /// + /// + /// + public sealed class EmailAddress : IEquatable, IComparable, IFormattable, IParsable + { + private static readonly ParserOptions Options = new() + { + AllowAddressesWithoutDomain = false, + AddressParserComplianceMode = RfcComplianceMode.Strict, + Rfc2047ComplianceMode = RfcComplianceMode.Strict, + AllowUnquotedCommasInAddresses = false, + }; + + private readonly string value; + + private EmailAddress(MailboxAddress address) + { + this.value = address.Address; + + var parts = this.value.Split('@'); + this.UserName = parts[0]; + this.Domain = parts[1]; + } + + /// + /// Gets the user name part of the email address (The part before the @ separator). + /// + public string UserName { get; } + + /// + /// Gets the domain part of the email address (The part after the @ separator). + /// + public string Domain { get; } + + /// + /// Implicitly converts an to a . + /// + /// The email address to convert. + /// The string representation of the email address. + /// Thrown when the argument is . + public static implicit operator string(EmailAddress emailAddress) + { + ArgumentNullException.ThrowIfNull(emailAddress, nameof(emailAddress)); + + return emailAddress.value; + } + + /// + /// Implicitly converts a to an . + /// + /// The string to convert to an email address. + /// An instance. + /// Thrown when the string is not a valid email address. + /// Thrown when the argument is . + public static implicit operator EmailAddress(string emailAddress) + { + ArgumentNullException.ThrowIfNull(emailAddress, nameof(emailAddress)); + + return Parse(emailAddress, null); + } + + /// + /// Determines whether two instances are equal. + /// + /// The first email address to compare. + /// The second email address to compare. + /// if the email addresses are equal; otherwise, . + public static bool operator ==(EmailAddress? left, EmailAddress? right) + { + return Equals(left, right); + } + + /// + /// Determines whether two instances are not equal. + /// + /// The first email address to compare. + /// The second email address to compare. + /// if the email addresses are not equal; otherwise, . + public static bool operator !=(EmailAddress? left, EmailAddress? right) + { + return !(left == right); + } + + /// + /// Determines whether one is less than another. + /// + /// The first email address to compare. + /// The second email address to compare. + /// if is less than ; otherwise, . + public static bool operator <(EmailAddress? left, EmailAddress? right) + { + return Comparer.Default.Compare(left, right) < 0; + } + + /// + /// Determines whether one is less than or equal to another. + /// + /// The first email address to compare. + /// The second email address to compare. + /// if is less than or equal to ; otherwise, . + public static bool operator <=(EmailAddress? left, EmailAddress? right) + { + return Comparer.Default.Compare(left, right) <= 0; + } + + /// + /// Determines whether one is greater than another. + /// + /// The first email address to compare. + /// The second email address to compare. + /// if is greater than ; otherwise, . + public static bool operator >(EmailAddress? left, EmailAddress? right) + { + return Comparer.Default.Compare(left, right) > 0; + } + + /// + /// Determines whether one is greater than or equal to another. + /// + /// The first email address to compare. + /// The second email address to compare. + /// if is greater than or equal to ; otherwise, . + public static bool operator >=(EmailAddress? left, EmailAddress? right) + { + return Comparer.Default.Compare(left, right) >= 0; + } + + /// + /// Parses a string representation of an email address. + /// + /// The string to parse. + /// An instance. + /// Thrown when the argument is . + /// Thrown when the string is not a valid email address. + public static EmailAddress Parse(string s) + { + ArgumentNullException.ThrowIfNull(s, nameof(s)); + + return Parse(s, null); + } + + /// + /// Parses a string representation of an email address using the specified format provider. + /// + /// The string to parse. + /// The format provider (not used in this implementation). + /// An instance. + /// Thrown when the argument is . + /// Thrown when the string is not a valid email address. + public static EmailAddress Parse(string s, IFormatProvider? provider) + { + ArgumentNullException.ThrowIfNull(s, nameof(s)); + + if (!TryParse(s, out var result)) + { + throw new FormatException($"'{s}' is not a valid email address."); + } + + return result; + } + + /// + /// Tries to parse a string representation of an email address. + /// + /// The string to parse. + /// When this method returns, contains the parsed if the parsing succeeded, or if it failed. + /// if the parsing succeeded; otherwise, . + public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)][NotNullWhen(true)] out EmailAddress? result) + { + return TryParse(s, null, out result); + } + + /// + /// Tries to parse a string representation of an email address using the specified format provider. + /// + /// The string to parse. + /// The format provider (not used in this implementation). + /// When this method returns, contains the parsed if the parsing succeeded, or if it failed. + /// if the parsing succeeded; otherwise, . + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)][NotNullWhen(true)] out EmailAddress? result) + { + var emailAddress = TryParse(s); + + if (emailAddress is null) + { + result = null; + return false; + } + + result = new EmailAddress(emailAddress); + return true; + } + + /// + /// Determines if the specified is a valid e-mail address. + /// + /// E-mail address value to test. + /// if the is valid e-mail address, otherwise. + public static bool IsValid(string value) + { + return TryParse(value, out var _); + } + + /// + public override bool Equals(object? obj) + { + if (obj is not EmailAddress emailAddress) + { + return false; + } + + return this.Equals(emailAddress); + } + + /// + /// Determines whether the current is equal to another . + /// + /// The other email address to compare with. + /// if the email addresses are equal; otherwise, . + public bool Equals(EmailAddress? other) + { + if (other is null) + { + return false; + } + + if (!this.value.Equals(other.value, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return true; + } + + /// + public override int GetHashCode() + { + return this.value.GetHashCode(StringComparison.OrdinalIgnoreCase); + } + + /// + /// Returns the string representation of the . + /// + /// The string representation of the . + public override string ToString() + { + return this.value; + } + + /// + string IFormattable.ToString(string? format, IFormatProvider? formatProvider) + { + return this.ToString(); + } + + /// + /// Compare the current instance with the one. + /// + /// Other e-mail address to compare. + /// + /// A value that indicates the relative order of the objects being compared: + /// + /// 0 if the current is equal to the . + /// A value less than 0 if the current is before the in alphabetic order. + /// A value greater than 0 if the current is after the in alphabetic order. + /// + /// + public int CompareTo(EmailAddress? other) + { + if (other is null) + { + return string.Compare(this.value, null, StringComparison.OrdinalIgnoreCase); + } + + return string.Compare(this.value, other.value, StringComparison.OrdinalIgnoreCase); + } + + private static MailboxAddress? TryParse(string? input) + { + if (input is null) + { + return null; + } + + input = input.ToLowerInvariant(); + + if (!MailboxAddress.TryParse(Options, input, out var address)) + { + return null; + } + + if (address.IsInternational) + { + return null; + } + + return address; + } + } +} diff --git a/src/EmailAddresses/EmailAddresses.csproj b/src/EmailAddresses/EmailAddresses.csproj new file mode 100644 index 0000000..f2bb163 --- /dev/null +++ b/src/EmailAddresses/EmailAddresses.csproj @@ -0,0 +1,17 @@ + + + + net9.0 + + + + + 1.0.0 + - Initial version + + + + + + + diff --git a/src/EmailAddresses/README.md b/src/EmailAddresses/README.md new file mode 100644 index 0000000..bb4eeae --- /dev/null +++ b/src/EmailAddresses/README.md @@ -0,0 +1,74 @@ +# PosInformatique.Foundations.EmailAddresses + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.svg)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.EmailAddresses.svg)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) + +## Introduction +This package provides a strongly-typed **EmailAddress** value object that ensures only valid email addresses (RFC 5322 compliant) can be instantiated. + +It simplifies validation, parsing, comparison, and string formatting of email addresses. + +## Install +You can install the package from [NuGet](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/): + +```powershell +dotnet add package PosInformatique.Foundations.EmailAddresses +``` + +## Features +- Strongly-typed email address validation and parsing +- Ensures email addresses are formatted according to **RFC 5322** +- Email values are always stored and compared in **lowercase** to avoid case-sensitive inconsistencies +- Implements `IEquatable`, `IComparable`, and operator overloads for equality and ordering +- Provides `IFormattable` and `IParsable` for seamless integration with .NET APIs +- Implicit conversion between `string` and `EmailAddress` + +## Use cases +- **Validation**: prevent invalid emails from being stored in your domain entities. +- **Type safety**: avoid dealing with raw strings when working with email addresses. +- **Conversion**: use implicit/explicit parsing and formatting seamlessly when working with APIs. +- **Consistency**: ensures a single, robust email parsing logic across all projects. + +## Examples + +### Create and validate email addresses +```csharp +// Implicit conversion from string +EmailAddress email = "john.doe@example.com"; + +// Access parts +Console.WriteLine(email.UserName); // "john.doe" +Console.WriteLine(email.Domain); // "example.com" + +// Validation +bool valid = EmailAddress.IsValid("test@domain.com"); // true +bool invalid = EmailAddress.IsValid("not-an-email"); // false +``` + +### Parsing +```csharp +var email = EmailAddress.Parse("alice@company.org"); +Console.WriteLine(email); // "alice@company.org" + +if (EmailAddress.TryParse("bob@company.org", out var parsed)) +{ + Console.WriteLine(parsed.Domain); // "company.org" +} +``` + +### Comparisons +```csharp +var a = (EmailAddress)"alice@company.com"; +var b = (EmailAddress)"bob@company.com"; + +Console.WriteLine(a == b); // false +Console.WriteLine(a != b); // true +Console.WriteLine(a < b); // true, alphabetic order + +var list = new List { b, a }; +list.Sort(); // Sorted alphabetically +``` + +## Links +- [NuGet package](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) diff --git a/stylecop.json b/stylecop.json new file mode 100644 index 0000000..b747ad1 --- /dev/null +++ b/stylecop.json @@ -0,0 +1,9 @@ +{ + "settings": { + "documentationRules": { + "companyName": "P.O.S Informatique", + "copyrightText": "Copyright (c) {companyName}. All rights reserved.", + "documentInternalElements": false + } + } +} \ No newline at end of file diff --git a/tests/.editorconfig b/tests/.editorconfig new file mode 100644 index 0000000..f4f1f48 --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1,6 @@ +[*.cs] + +#### StyleCop #### + +# SA1600: Elements should be documented +dotnet_diagnostic.SA1600.severity = none diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..3c52f1c --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,35 @@ + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + $(NoWarn);SA0001 + + + + + + + + + + \ No newline at end of file diff --git a/tests/EmailAddresses.Tests/EmailAddressTest.cs b/tests/EmailAddresses.Tests/EmailAddressTest.cs new file mode 100644 index 0000000..b43e0b1 --- /dev/null +++ b/tests/EmailAddresses.Tests/EmailAddressTest.cs @@ -0,0 +1,380 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Tests +{ + public class EmailAddressTest + { + public static TheoryData InvalidEmailAddresses { get; } = new() + { + "Test", + "test1â@test.com", + "test1@", + "@test.com", + "test1,()@test.com", + }; + + public static TheoryData ValidEmailAddresses { get; } = new() + { + @"""Test"" ", + "test1@test.com", + "TEST1@TEST.COM", + }; + + [Theory] + [InlineData(@"""Test"" ", "test1@test.com", "test1", "test.com")] + [InlineData("test1@test.com", "test1@test.com", "test1", "test.com")] + [InlineData("TEST1@TEST.COM", "test1@test.com", "test1", "test.com")] + public void Parse(string emailAddress, string expectedEmailAddress, string userName, string domain) + { + var address = EmailAddress.Parse(emailAddress); + + address.ToString().Should().Be(expectedEmailAddress); + address.As().ToString(null, null).Should().Be(expectedEmailAddress); + address.UserName.Should().Be(userName); + address.Domain.Should().Be(domain); + } + + [Fact] + public void Parse_WithNullArgument() + { + var act = () => + { + EmailAddress.Parse(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("s"); + } + + [Theory] + [MemberData(nameof(InvalidEmailAddresses))] + public void Parse_InvalidEmailAddress(string invalidEmailAdddress) + { + var act = () => EmailAddress.Parse(invalidEmailAdddress); + + act.Should().ThrowExactly() + .WithMessage($"'{invalidEmailAdddress}' is not a valid email address."); + } + + [Theory] + [InlineData(@"""Test"" ", "test1@test.com", "test1", "test.com")] + [InlineData("test1@test.com", "test1@test.com", "test1", "test.com")] + [InlineData("TEST1@TEST.COM", "test1@test.com", "test1", "test.com")] + public void Parse_WithFormatProvider(string emailAddress, string expectedEmailAddress, string userName, string domain) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var address = EmailAddress.Parse(emailAddress, formatProvider); + + address.ToString().Should().Be(expectedEmailAddress); + address.As().ToString(null, null).Should().Be(expectedEmailAddress); + address.UserName.Should().Be(userName); + address.Domain.Should().Be(domain); + } + + [Fact] + public void Parse_WithFormatProvider_WithNullArgument() + { + var act = () => + { + EmailAddress.Parse(null, default); + }; + + act.Should().ThrowExactly() + .WithParameterName("s"); + } + + [Theory] + [MemberData(nameof(InvalidEmailAddresses))] + public void Parse_WithFormatProvider_InvalidEmailAddress(string invalidEmailAdddress) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var act = () => EmailAddress.Parse(invalidEmailAdddress, formatProvider); + + act.Should().ThrowExactly() + .WithMessage($"'{invalidEmailAdddress}' is not a valid email address."); + } + + [Theory] + [InlineData(@"""Test"" ", "test1@test.com", "test1", "test.com")] + [InlineData("test1@test.com", "test1@test.com", "test1", "test.com")] + [InlineData("TEST1@TEST.COM", "test1@test.com", "test1", "test.com")] + public void TryParse(string emailAddress, string expectedEmailAddress, string userName, string domain) + { + var result = EmailAddress.TryParse(emailAddress, out var address); + + result.Should().BeTrue(); + + address.ToString().Should().Be(expectedEmailAddress); + address.As().ToString(null, null).Should().Be(expectedEmailAddress); + address.UserName.Should().Be(userName); + address.Domain.Should().Be(domain); + } + + [Theory] + [MemberData(nameof(InvalidEmailAddresses))] + public void TryParse_InvalidEmailAddress(string invalidEmailAdddress) + { + var result = EmailAddress.TryParse(invalidEmailAdddress, out var address); + + result.Should().BeFalse(); + address.Should().BeNull(); + } + + [Theory] + [InlineData(@"""Test"" ", "test1@test.com", "test1", "test.com")] + [InlineData("test1@test.com", "test1@test.com", "test1", "test.com")] + [InlineData("TEST1@TEST.COM", "test1@test.com", "test1", "test.com")] + public void TryParse_WithFormatProvider(string emailAddress, string expectedEmailAddress, string userName, string domain) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var result = EmailAddress.TryParse(emailAddress, formatProvider, out var address); + + result.Should().BeTrue(); + + address.ToString().Should().Be(expectedEmailAddress); + address.As().ToString(null, null).Should().Be(expectedEmailAddress); + address.UserName.Should().Be(userName); + address.Domain.Should().Be(domain); + } + + [Theory] + [MemberData(nameof(InvalidEmailAddresses))] + public void TryParse_WithFormatProvider_InvalidEmailAddress(string invalidEmailAdddress) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var result = EmailAddress.TryParse(invalidEmailAdddress, formatProvider, out var address); + + result.Should().BeFalse(); + address.Should().BeNull(); + } + + [Theory] + [MemberData(nameof(ValidEmailAddresses))] + public void IsValid_Valid(string emailAddress) + { + EmailAddress.IsValid(emailAddress).Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(InvalidEmailAddresses))] + public void IsValid_Invalid(string invalidEmailAdddress) + { + EmailAddress.IsValid(invalidEmailAdddress).Should().BeFalse(); + } + + [Theory] + [InlineData(@"""Test"" ", @"""Test"" ", true)] + [InlineData(@"""Test"" ", @"""Test"" ", false)] + [InlineData(@"""Test"" ", @"""Test"" ", true)] + [InlineData(@"""Test"" ", @"""Test"" ", true)] + [InlineData(@"""Test"" ", @"""Test2"" ", true)] + [InlineData(@"""Test"" ", null, false)] + public void Equals_WithEmailAddress(string emailAddress1String, string emailAddress2String, bool expectedResult) + { + var emailAddress1 = EmailAddress.Parse(emailAddress1String); + var emailAddress2 = emailAddress2String is not null ? EmailAddress.Parse(emailAddress2String) : null; + + emailAddress1.Equals(emailAddress2).Should().Be(expectedResult); + } + + [Theory] + [InlineData(@"""Test"" ", @"""Test"" ", true)] + [InlineData(@"""Test"" ", @"""Test"" ", false)] + [InlineData(@"""Test"" ", @"""Test"" ", true)] + [InlineData(@"""Test"" ", @"""Test"" ", true)] + [InlineData(@"""Test"" ", @"""Test2"" ", true)] + [InlineData(@"""Test"" ", null, false)] + public void Equals_WithObject(string emailAddress1String, string emailAddress2String, bool expectedResult) + { + var emailAddress1 = EmailAddress.Parse(emailAddress1String); + var emailAddress2 = emailAddress2String is not null ? EmailAddress.Parse(emailAddress2String) : null; + + emailAddress1.Equals((object)emailAddress2).Should().Be(expectedResult); + } + + [Fact] + public void GetHashCode_Test() + { + var address = EmailAddress.Parse(@"""Test"" "); + + address.GetHashCode().Should().Be("test1@test.com".GetHashCode(StringComparison.OrdinalIgnoreCase)); + } + + [Theory] + [InlineData(@"""Test"" ", @"""Test"" ", true)] + [InlineData(@"""Test"" ", @"""Test"" ", false)] + [InlineData(@"""Test"" ", @"""Test"" ", true)] + [InlineData(@"""Test"" ", @"""Test"" ", true)] + [InlineData(@"""Test"" ", @"""Test2"" ", true)] + [InlineData(@"""Test"" ", null, false)] + public void Operator_Equals(string emailAddress1String, string emailAddress2String, bool expectedResult) + { + var emailAddress1 = EmailAddress.Parse(emailAddress1String); + var emailAddress2 = emailAddress2String is not null ? EmailAddress.Parse(emailAddress2String) : null; + + (emailAddress1 == emailAddress2).Should().Be(expectedResult); + } + + [Theory] + [InlineData(@"""Test"" ", @"""Test"" ", false)] + [InlineData(@"""Test"" ", @"""Test"" ", true)] + [InlineData(@"""Test"" ", @"""Test"" ", false)] + [InlineData(@"""Test"" ", @"""Test"" ", false)] + [InlineData(@"""Test"" ", @"""Test2"" ", false)] + [InlineData(@"""Test"" ", null, true)] + public void Operator_NotEquals(string emailAddress1String, string emailAddress2String, bool expectedResult) + { + var emailAddress1 = EmailAddress.Parse(emailAddress1String); + var emailAddress2 = emailAddress2String is not null ? EmailAddress.Parse(emailAddress2String) : null; + + (emailAddress1 != emailAddress2).Should().Be(expectedResult); + } + + [Theory] + [InlineData(@"""Test""", @"test@test.com")] + [InlineData(@"test@test.com", "test@test.com")] + public void ToString_ShouldReturnValue(string emailAddress, string expectedValue) + { + var address = EmailAddress.Parse(emailAddress); + + address.ToString().Should().Be(expectedValue); + address.As().ToString(null, null).Should().Be(expectedValue); + } + + [Theory] + [InlineData(@"""Test""")] + [InlineData(@"test@test.com")] + public void Domain(string emailAddress) + { + var address = EmailAddress.Parse(emailAddress); + + address.Domain.Should().Be("test.com"); + } + + [Theory] + [InlineData(@"""Test""")] + [InlineData(@"test@test.com")] + public void Username(string emailAddress) + { + var address = EmailAddress.Parse(emailAddress); + + address.UserName.Should().Be("test"); + } + + [Theory] + [InlineData(@"""Test""", "test1@test.com")] + [InlineData(@"test1@test.com", "test1@test.com")] + public void Operator_EmailAddressToString(string emailAddressString, string expectedEmailAddress) + { + var emailAddress = EmailAddress.Parse(emailAddressString); + + string toStringValue = emailAddress; + + toStringValue.Should().Be(expectedEmailAddress); + } + + [Fact] + public void Operator_EmailAddressToString_WithNullArgument() + { + var act = () => + { + string toStringValue = (EmailAddress)null; + }; + + act.Should().ThrowExactly() + .WithParameterName("emailAddress"); + } + + [Theory] + [InlineData(@"""Test""", "test1@test.com", "test1", "test.com")] + [InlineData(@"test1@test.com", "test1@test.com", "test1", "test.com")] + public void Operator_StringToEmailAddress(string emailAddressString, string expectedEmailAddress, string expectedUserName, string expectedDomain) + { + EmailAddress emailAddress = emailAddressString; + + emailAddress.ToString().Should().Be(expectedEmailAddress); + emailAddress.As().ToString(null, null).Should().Be(expectedEmailAddress); + emailAddress.Domain.Should().Be(expectedDomain); + emailAddress.UserName.Should().Be(expectedUserName); + } + + [Fact] + public void Operator_StringToEmailAddress_WithNullArgument() + { + var act = () => + { + EmailAddress toStringValue = (string)null; + }; + + act.Should().ThrowExactly() + .WithParameterName("emailAddress"); + } + + [Fact] + public void CompareTo() + { + EmailAddress.Parse("test1@test.com").CompareTo(EmailAddress.Parse("test2@test.com")).Should().BeLessThan(0); + EmailAddress.Parse("test2@test.com").CompareTo(EmailAddress.Parse("test1@test.com")).Should().BeGreaterThan(0); + + EmailAddress.Parse("test1@test.com").CompareTo(EmailAddress.Parse("test1@test.com")).Should().Be(0); + + EmailAddress.Parse("test1@test.com").CompareTo(null).Should().BeGreaterThan(0); + } + + [Theory] + [InlineData("test1@test.com", "test2@test.com", true)] + [InlineData("test2@test.com", "test1@test.com", false)] + [InlineData("test1@test.com", "test1@test.com", false)] + [InlineData(null, "test1@test.com", true)] + [InlineData("test1@test.com", null, false)] + [InlineData(null, null, false)] + public void Operator_LessThan(string emailAddress1, string emailAddress2, bool expectedResult) + { + ((emailAddress1 is not null ? EmailAddress.Parse(emailAddress1) : null) < (emailAddress2 is not null ? EmailAddress.Parse(emailAddress2) : null)).Should().Be(expectedResult); + } + + [Theory] + [InlineData("test1@test.com", "test2@test.com", true)] + [InlineData("test2@test.com", "test1@test.com", false)] + [InlineData("test1@test.com", "test1@test.com", true)] + [InlineData(null, "test1@test.com", true)] + [InlineData("test1@test.com", null, false)] + [InlineData(null, null, true)] + public void Operator_LessThanOrEqual(string emailAddress1, string emailAddress2, bool expectedResult) + { + ((emailAddress1 is not null ? EmailAddress.Parse(emailAddress1) : null) <= (emailAddress2 is not null ? EmailAddress.Parse(emailAddress2) : null)).Should().Be(expectedResult); + } + + [Theory] + [InlineData("test1@test.com", "test2@test.com", false)] + [InlineData("test2@test.com", "test1@test.com", true)] + [InlineData("test1@test.com", "test1@test.com", false)] + [InlineData(null, "test1@test.com", false)] + [InlineData("test1@test.com", null, true)] + [InlineData(null, null, false)] + public void Operator_GreaterThan(string emailAddress1, string emailAddress2, bool expectedResult) + { + ((emailAddress1 is not null ? EmailAddress.Parse(emailAddress1) : null) > (emailAddress2 is not null ? EmailAddress.Parse(emailAddress2) : null)).Should().Be(expectedResult); + } + + [Theory] + [InlineData("test1@test.com", "test2@test.com", false)] + [InlineData("test2@test.com", "test1@test.com", true)] + [InlineData("test1@test.com", "test1@test.com", true)] + [InlineData(null, "test1@test.com", false)] + [InlineData("test1@test.com", null, true)] + [InlineData(null, null, true)] + public void Operator_GreaterThanOrEqual(string emailAddress1, string emailAddress2, bool expectedResult) + { + ((emailAddress1 is not null ? EmailAddress.Parse(emailAddress1) : null) >= (emailAddress2 is not null ? EmailAddress.Parse(emailAddress2) : null)).Should().Be(expectedResult); + } + } +} diff --git a/tests/EmailAddresses.Tests/EmailAddresses.Tests.csproj b/tests/EmailAddresses.Tests/EmailAddresses.Tests.csproj new file mode 100644 index 0000000..fc4f2e6 --- /dev/null +++ b/tests/EmailAddresses.Tests/EmailAddresses.Tests.csproj @@ -0,0 +1,11 @@ + + + + net9.0 + + + + + + + From b2aa7704c54dc69f60bc833d7b42c4ffd9d59a84 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 25 Sep 2025 16:54:41 +0200 Subject: [PATCH 02/73] Add the CI support. --- .editorconfig | 5 +++ .github/workflows/github-actions-ci.yaml | 42 +++++++++++++++++++ .github/workflows/github-actions-release.yml | 36 ++++++++++++++++ Icon.png | Bin 0 -> 39330 bytes PosInformatique.Foundations.sln | 11 +++++ README.md | 10 +++-- src/EmailAddresses/CHANGELOG.md | 3 ++ src/EmailAddresses/EmailAddresses.csproj | 20 +++++++-- src/EmailAddresses/Icon.png | Bin 0 -> 42605 bytes src/EmailAddresses/README.md | 2 + tests/Directory.Build.props | 7 +++- 11 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/github-actions-ci.yaml create mode 100644 .github/workflows/github-actions-release.yml create mode 100644 Icon.png create mode 100644 src/EmailAddresses/CHANGELOG.md create mode 100644 src/EmailAddresses/Icon.png diff --git a/.editorconfig b/.editorconfig index d260ef3..8d52d35 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,6 +5,11 @@ charset = utf-8-bom insert_final_newline = true trim_trailing_whitespace = true +# Markdown specific settings +[*.md] +indent_style = space +indent_size = 2 + # x.proj specific settings [*.{csproj,props}] indent_style = space diff --git a/.github/workflows/github-actions-ci.yaml b/.github/workflows/github-actions-ci.yaml new file mode 100644 index 0000000..53ea05e --- /dev/null +++ b/.github/workflows/github-actions-ci.yaml @@ -0,0 +1,42 @@ +name: Continuous Integration + +on: + pull_request: + branches: [ "main" ] + push: + branches: [ "releases/**" ] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Restore dependencies + run: dotnet restore PosInformatique.Foundations.sln + + - name: Build solution + run: dotnet build PosInformatique.Foundations.sln --configuration Release --no-restore + + - name: Run tests + run: | + dotnet test PosInformatique.Foundations.sln \ + --configuration Release \ + --no-build \ + --logger "trx;LogFileName=test_results.trx" \ + --results-directory ./TestResults + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: (!cancelled()) + with: + files: | + TestResults/**/*.trx diff --git a/.github/workflows/github-actions-release.yml b/.github/workflows/github-actions-release.yml new file mode 100644 index 0000000..238d5a8 --- /dev/null +++ b/.github/workflows/github-actions-release.yml @@ -0,0 +1,36 @@ +name: Release + +on: + workflow_dispatch: + inputs: + VersionPrefix: + type: string + description: The version of the library + required: true + default: 1.0.0 + VersionSuffix: + type: string + description: The version suffix of the library (for example rc.1) + +run-name: ${{ inputs.VersionSuffix && format('{0}-{1}', inputs.VersionPrefix, inputs.VersionSuffix) || inputs.VersionPrefix }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.x' + + - name: Build all the NuGet packages + run: dotnet pack PosInformatique.Foundations.sln \ + --configuration Release \ + --property:VersionPrefix=${{ github.event.inputs.VersionPrefix }} \ + --property:VersionSuffix=${{ github.event.inputs.VersionSuffix }} \ + --output ./artifacts + + - name: Publish the package to nuget.org + run: dotnet nuget push "./artifacts/*.nupkg" --api-key "${{ secrets.NUGET_APIKEY }}" --source https://api.nuget.org/v3/index.json diff --git a/Icon.png b/Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1b2cc54b3894a9df755ebfa3512954d0362c3b95 GIT binary patch literal 39330 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 literal 0 HcmV?d00001 diff --git a/PosInformatique.Foundations.sln b/PosInformatique.Foundations.sln index 98020ea..7c919cf 100644 --- a/PosInformatique.Foundations.sln +++ b/PosInformatique.Foundations.sln @@ -13,6 +13,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .gitignore = .gitignore Directory.Build.props = Directory.Build.props Directory.Packages.props = Directory.Packages.props + Icon.png = Icon.png LICENSE = LICENSE README.md = README.md stylecop.json = stylecop.json @@ -29,6 +30,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{FAA7960F-95C src\Directory.Build.props = src\Directory.Build.props EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{99066C81-F6AB-4A66-9E52-9D324A840307}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{CE76142F-BAA5-4D79-9CBB-9C298378FFF9}" + ProjectSection(SolutionItems) = preProject + .github\workflows\github-actions-ci.yaml = .github\workflows\github-actions-ci.yaml + .github\workflows\github-actions-release.yml = .github\workflows\github-actions-release.yml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -50,6 +59,8 @@ Global GlobalSection(NestedProjects) = preSolution {DFE1E9A1-3CB7-4FA4-B304-711772346C43} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {FAA7960F-95C1-45E2-9A42-EED477DF97F1} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {99066C81-F6AB-4A66-9E52-9D324A840307} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {CE76142F-BAA5-4D79-9CBB-9C298378FFF9} = {99066C81-F6AB-4A66-9E52-9D324A840307} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {344068EF-5958-4241-BD83-86403ADA68F1} diff --git a/README.md b/README.md index 5dbae39..ec4fb4a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # PosInformatique.Foundation +PosInformatique.Foundations icon + PosInformatique.Foundation is a collection of small, focused .NET libraries that provide **simple, reusable building blocks** for your applications. The goal is to avoid shipping a monolithic framework by creating **modular NuGet packages**, each addressing a single responsibility. @@ -21,9 +23,9 @@ You can install any package using the .NET CLI or NuGet Package Manager. ## 📦 Packages Overview -| Package | Description | NuGet | -|---------|-------------|-------| -| [**PosInformatique.Foundation.EmailAddresses**](./EmailAddresses/README.md) | Strongly-typed value object representing an email address with validation and normalization. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundation.EmailAddress)](https://www.nuget.org/packages/PosInformatique.Foundation.EmailAddress) | +| |Package | Description | NuGet | +|--|---------|-------------|-------| +|PosInformatique.Foundations.EmailAddresses icon|[**PosInformatique.Foundation.EmailAddresses**](./src/EmailAddresses/README.md) | Strongly-typed value object representing an email address with validation and normalization. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundation.EmailAddress)](https://www.nuget.org/packages/PosInformatique.Foundation.EmailAddress) | > Note: Each package is completely independent. You install only what you need. @@ -37,4 +39,4 @@ You can install any package using the .NET CLI or NuGet Package Manager. ## 📄 License -Licensed under the [MIT License](./LICENSE). \ No newline at end of file +Licensed under the [MIT License](./LICENSE). diff --git a/src/EmailAddresses/CHANGELOG.md b/src/EmailAddresses/CHANGELOG.md new file mode 100644 index 0000000..75b45b6 --- /dev/null +++ b/src/EmailAddresses/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 +- Initial release of **PosInformatique.Foundations.EmailAddresses** + - Strongly-typed EmailAddress value object diff --git a/src/EmailAddresses/EmailAddresses.csproj b/src/EmailAddresses/EmailAddresses.csproj index f2bb163..295a6ba 100644 --- a/src/EmailAddresses/EmailAddresses.csproj +++ b/src/EmailAddresses/EmailAddresses.csproj @@ -2,15 +2,27 @@ net9.0 - + true - + + Provides a strongly-typed EmailAddress value object that ensures only valid RFC 5322 compliant email addresses can be instantiated. + Includes parsing, validation, comparison, and formatting features with case-insensitive handling for consistent equality checks. + + email;emailaddress;valueobject;ddd;rfc5322;parsing;validation;dotnet;posinformatique - 1.0.0 - - Initial version + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + diff --git a/src/EmailAddresses/Icon.png b/src/EmailAddresses/Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ed1fab276667c80e38d00d1cada20259fd8cb141 GIT binary patch literal 42605 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 LvUI7Wf#3fCAx8Mr literal 0 HcmV?d00001 diff --git a/src/EmailAddresses/README.md b/src/EmailAddresses/README.md index bb4eeae..e6258a5 100644 --- a/src/EmailAddresses/README.md +++ b/src/EmailAddresses/README.md @@ -1,5 +1,7 @@ # PosInformatique.Foundations.EmailAddresses +PosInformatique.Foundations.EmailAddresses icon + [![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.svg)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) [![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.EmailAddresses.svg)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 3c52f1c..4acd5ed 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -1,4 +1,4 @@ - + @@ -21,6 +21,9 @@ + + false + $(NoWarn);SA0001 @@ -32,4 +35,4 @@ - \ No newline at end of file + From a9326bae0a7f595e6a6b0bef3dfbc29ba3c50d16 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 25 Sep 2025 17:02:02 +0200 Subject: [PATCH 03/73] Fix README. --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ec4fb4a..7898f42 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PosInformatique.Foundation -PosInformatique.Foundations icon +PosInformatique.Foundations icon PosInformatique.Foundation is a collection of small, focused .NET libraries that provide **simple, reusable building blocks** for your applications. @@ -17,12 +17,10 @@ The goal is to avoid shipping a monolithic framework by creating **modular NuGet ➡️ Each package has **no strong dependency** on another. You are free to pick only what you need. ➡️ These libraries are **not structuring frameworks**; they are small utilities meant to fill missing gaps in your applications. -## 📦 Installation +## 📦 Packages Overview You can install any package using the .NET CLI or NuGet Package Manager. -## 📦 Packages Overview - | |Package | Description | NuGet | |--|---------|-------------|-------| |PosInformatique.Foundations.EmailAddresses icon|[**PosInformatique.Foundation.EmailAddresses**](./src/EmailAddresses/README.md) | Strongly-typed value object representing an email address with validation and normalization. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundation.EmailAddress)](https://www.nuget.org/packages/PosInformatique.Foundation.EmailAddress) | From 1c6a77a93fbd8b488405af838fcb6291c1e7414a Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 25 Sep 2025 17:04:59 +0200 Subject: [PATCH 04/73] Update github-actions-release.yml --- .github/workflows/github-actions-release.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/github-actions-release.yml b/.github/workflows/github-actions-release.yml index 238d5a8..ef82da1 100644 --- a/.github/workflows/github-actions-release.yml +++ b/.github/workflows/github-actions-release.yml @@ -26,11 +26,12 @@ jobs: dotnet-version: '9.x' - name: Build all the NuGet packages - run: dotnet pack PosInformatique.Foundations.sln \ - --configuration Release \ - --property:VersionPrefix=${{ github.event.inputs.VersionPrefix }} \ - --property:VersionSuffix=${{ github.event.inputs.VersionSuffix }} \ - --output ./artifacts + run: | + dotnet pack PosInformatique.Foundations.sln \ + --configuration Release \ + --property:VersionPrefix=${{ github.event.inputs.VersionPrefix }} \ + --property:VersionSuffix=${{ github.event.inputs.VersionSuffix }} \ + --output ./artifacts - name: Publish the package to nuget.org run: dotnet nuget push "./artifacts/*.nupkg" --api-key "${{ secrets.NUGET_APIKEY }}" --source https://api.nuget.org/v3/index.json From 1e265752ab72af19d2b03df4974b4e3257c07acc Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 25 Sep 2025 18:21:02 +0200 Subject: [PATCH 05/73] Fix README. --- README.md | 8 ++++---- src/EmailAddresses/CHANGELOG.md | 5 ++--- src/EmailAddresses/EmailAddresses.csproj | 6 ++++-- src/EmailAddresses/README.md | 6 ++---- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7898f42..4a61b6e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# PosInformatique.Foundation +# PosInformatique.Foundations PosInformatique.Foundations icon -PosInformatique.Foundation is a collection of small, focused .NET libraries that provide **simple, reusable building blocks** for your applications. +PosInformatique.Foundations is a collection of small, focused .NET libraries that provide **simple, reusable building blocks** for your applications. The goal is to avoid shipping a monolithic framework by creating **modular NuGet packages**, each addressing a single responsibility. @@ -23,11 +23,11 @@ You can install any package using the .NET CLI or NuGet Package Manager. | |Package | Description | NuGet | |--|---------|-------------|-------| -|PosInformatique.Foundations.EmailAddresses icon|[**PosInformatique.Foundation.EmailAddresses**](./src/EmailAddresses/README.md) | Strongly-typed value object representing an email address with validation and normalization. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundation.EmailAddress)](https://www.nuget.org/packages/PosInformatique.Foundation.EmailAddress) | +|PosInformatique.Foundations.EmailAddresses icon|[**PosInformatique.Foundations.EmailAddresses**](./src/EmailAddresses/README.md) | Strongly-typed value object representing an email address with validation and normalization. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses) | > Note: Each package is completely independent. You install only what you need. -## 🚀 Why use PosInformatique.Foundation? +## 🚀 Why use PosInformatique.Foundations? - Avoid reinventing common value objects and utilities. - Apply standards-based implementations (RFC, E.164, ...). diff --git a/src/EmailAddresses/CHANGELOG.md b/src/EmailAddresses/CHANGELOG.md index 75b45b6..9b36899 100644 --- a/src/EmailAddresses/CHANGELOG.md +++ b/src/EmailAddresses/CHANGELOG.md @@ -1,3 +1,2 @@ -## 1.0.0 -- Initial release of **PosInformatique.Foundations.EmailAddresses** - - Strongly-typed EmailAddress value object +1.0.0 + - EmailAddresses: Initial release with strongly-typed EmailAddress value object diff --git a/src/EmailAddresses/EmailAddresses.csproj b/src/EmailAddresses/EmailAddresses.csproj index 295a6ba..d95cbc5 100644 --- a/src/EmailAddresses/EmailAddresses.csproj +++ b/src/EmailAddresses/EmailAddresses.csproj @@ -16,11 +16,13 @@ - + - + + + diff --git a/src/EmailAddresses/README.md b/src/EmailAddresses/README.md index e6258a5..255afc4 100644 --- a/src/EmailAddresses/README.md +++ b/src/EmailAddresses/README.md @@ -1,9 +1,7 @@ # PosInformatique.Foundations.EmailAddresses -PosInformatique.Foundations.EmailAddresses icon - -[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.svg)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) -[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.EmailAddresses.svg)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.EmailAddresses)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) ## Introduction This package provides a strongly-typed **EmailAddress** value object that ensures only valid email addresses (RFC 5322 compliant) can be instantiated. From 3a17141f92d110cd47b30186eb73edbb79a992bd Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 26 Sep 2025 12:31:33 +0200 Subject: [PATCH 06/73] Add the PosInformatique.Foundations.EmailAddresses.Json package implementation. --- .editorconfig | 63 +++++++++- Directory.Packages.props | 1 + PosInformatique.Foundations.sln | 12 ++ README.md | 3 +- src/EmailAddresses.Json/CHANGELOG.md | 2 + .../EmailAddressJsonConverter.cs | 45 +++++++ ...lAddressJsonSerializerOptionsExtensions.cs | 35 ++++++ .../EmailAddresses.Json.csproj | 33 +++++ src/EmailAddresses.Json/README.md | 79 ++++++++++++ src/EmailAddresses/CHANGELOG.md | 2 +- src/EmailAddresses/EmailAddress.cs | 2 +- .../EmailAddressJsonConverterTest.cs | 118 ++++++++++++++++++ ...ressJsonSerializerOptionsExtensionsTest.cs | 42 +++++++ .../EmailAddresses.Json.Tests.csproj | 15 +++ .../EmailAddresses.Tests/EmailAddressTest.cs | 2 +- 15 files changed, 447 insertions(+), 7 deletions(-) create mode 100644 src/EmailAddresses.Json/CHANGELOG.md create mode 100644 src/EmailAddresses.Json/EmailAddressJsonConverter.cs create mode 100644 src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs create mode 100644 src/EmailAddresses.Json/EmailAddresses.Json.csproj create mode 100644 src/EmailAddresses.Json/README.md create mode 100644 tests/EmailAddresses.Json.Tests/EmailAddressJsonConverterTest.cs create mode 100644 tests/EmailAddresses.Json.Tests/EmailAddressJsonSerializerOptionsExtensionsTest.cs create mode 100644 tests/EmailAddresses.Json.Tests/EmailAddresses.Json.Tests.csproj diff --git a/.editorconfig b/.editorconfig index 8d52d35..786397e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,6 +4,8 @@ charset = utf-8-bom insert_final_newline = true trim_trailing_whitespace = true +csharp_using_directive_placement = inside_namespace:warning +csharp_style_prefer_primary_constructors = false:suggestion # Markdown specific settings [*.md] @@ -15,7 +17,62 @@ indent_size = 2 indent_style = space indent_size = 2 -# C# specific settings -[*.cs] -indent_style = space +[*.{cs,vb}] + +#### Naming styles #### +tab_width = 4 indent_size = 4 +end_of_line = crlf + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion diff --git a/Directory.Packages.props b/Directory.Packages.props index 2293ccc..608e428 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + diff --git a/PosInformatique.Foundations.sln b/PosInformatique.Foundations.sln index 7c919cf..120b384 100644 --- a/PosInformatique.Foundations.sln +++ b/PosInformatique.Foundations.sln @@ -38,6 +38,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ .github\workflows\github-actions-release.yml = .github\workflows\github-actions-release.yml EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.Json", "src\EmailAddresses.Json\EmailAddresses.Json.csproj", "{C203E40E-37C1-49F0-B74C-E3559EB74DA7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.Json.Tests", "tests\EmailAddresses.Json.Tests\EmailAddresses.Json.Tests.csproj", "{3CEDE123-579A-4E3F-B32C-7FC53950075A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -52,6 +56,14 @@ Global {BAE006E4-4A1E-4E52-8CA1-D341BD0B26EE}.Debug|Any CPU.Build.0 = Debug|Any CPU {BAE006E4-4A1E-4E52-8CA1-D341BD0B26EE}.Release|Any CPU.ActiveCfg = Release|Any CPU {BAE006E4-4A1E-4E52-8CA1-D341BD0B26EE}.Release|Any CPU.Build.0 = Release|Any CPU + {C203E40E-37C1-49F0-B74C-E3559EB74DA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C203E40E-37C1-49F0-B74C-E3559EB74DA7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C203E40E-37C1-49F0-B74C-E3559EB74DA7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C203E40E-37C1-49F0-B74C-E3559EB74DA7}.Release|Any CPU.Build.0 = Release|Any CPU + {3CEDE123-579A-4E3F-B32C-7FC53950075A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3CEDE123-579A-4E3F-B32C-7FC53950075A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3CEDE123-579A-4E3F-B32C-7FC53950075A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3CEDE123-579A-4E3F-B32C-7FC53950075A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 4a61b6e..9183f1b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ You can install any package using the .NET CLI or NuGet Package Manager. | |Package | Description | NuGet | |--|---------|-------------|-------| -|PosInformatique.Foundations.EmailAddresses icon|[**PosInformatique.Foundations.EmailAddresses**](./src/EmailAddresses/README.md) | Strongly-typed value object representing an email address with validation and normalization. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses) | +|PosInformatique.Foundations.EmailAddresses icon|[**PosInformatique.Foundations.EmailAddresses**](./src/EmailAddresses/README.md) | Strongly-typed value object representing an email address with validation and normalization as RFC 5322 compliant. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses) | +|PosInformatique.Foundations.EmailAddresses.Json icon|[**PosInformatique.Foundations.EmailAddresses.Json**](./src/EmailAddresses.Json/README.md) | System.Text.Json converter for the EmailAddress value object, enabling seamless serialization and deserialization of RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json) | > Note: Each package is completely independent. You install only what you need. diff --git a/src/EmailAddresses.Json/CHANGELOG.md b/src/EmailAddresses.Json/CHANGELOG.md new file mode 100644 index 0000000..69013f7 --- /dev/null +++ b/src/EmailAddresses.Json/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with the support JSON serialization (with System.Text.Json) for EmailAddress value object. diff --git a/src/EmailAddresses.Json/EmailAddressJsonConverter.cs b/src/EmailAddresses.Json/EmailAddressJsonConverter.cs new file mode 100644 index 0000000..2b2f58a --- /dev/null +++ b/src/EmailAddresses.Json/EmailAddressJsonConverter.cs @@ -0,0 +1,45 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.EmailAddresses.Json +{ + using System.Text.Json; + using System.Text.Json.Serialization; + + /// + /// which allows to serialize and deserialize an + /// as a JSON string. + /// + public sealed class EmailAddressJsonConverter : JsonConverter + { + /// + public override bool HandleNull => true; + + /// + public override EmailAddress? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var input = reader.GetString(); + + if (input is null) + { + return null; + } + + if (!EmailAddress.TryParse(input, out var emailAddress)) + { + throw new JsonException($"'{input}' is not a valid email address."); + } + + return emailAddress; + } + + /// + public override void Write(Utf8JsonWriter writer, EmailAddress value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } + } +} diff --git a/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs b/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs new file mode 100644 index 0000000..92202d6 --- /dev/null +++ b/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs @@ -0,0 +1,35 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.EmailAddresses.Json +{ + using System.Text.Json; + + /// + /// Contains extension methods to configure . + /// + public static class EmailAddressJsonSerializerOptionsExtensions + { + /// + /// Registers the to the . + /// + /// which the + /// converter will be added in the collection. + /// The instance to continue the configuration. + /// If the specified argument is . + public static JsonSerializerOptions AddEmailAddressesConverters(this JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(options, nameof(options)); + + if (!options.Converters.Any(c => c.GetType() == typeof(EmailAddressJsonConverter))) + { + options.Converters.Add(new EmailAddressJsonConverter()); + } + + return options; + } + } +} diff --git a/src/EmailAddresses.Json/EmailAddresses.Json.csproj b/src/EmailAddresses.Json/EmailAddresses.Json.csproj new file mode 100644 index 0000000..ffac096 --- /dev/null +++ b/src/EmailAddresses.Json/EmailAddresses.Json.csproj @@ -0,0 +1,33 @@ + + + + net9.0 + true + + + Provides a System.Text.Json converter for the EmailAddress value object. + Enables seamless serialization and deserialization of RFC 5322 compliant email addresses within JSON documents. + Designed to integrate smoothly with System.Text.Json options, ensuring consistent validation and parsing during JSON processing. + + email;emailaddress;valueobject;ddd;json;rfc5322;parsing;validation;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + diff --git a/src/EmailAddresses.Json/README.md b/src/EmailAddresses.Json/README.md new file mode 100644 index 0000000..e6ba7c6 --- /dev/null +++ b/src/EmailAddresses.Json/README.md @@ -0,0 +1,79 @@ +# PosInformatique.Foundations.EmailAddresses.Json + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.EmailAddresses.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json/) + +## Introduction +Provides a **System.Text.Json** converter for the `EmailAddress` value object from +[PosInformatique.Foundations.EmailAddresses](../EmailAddresses/README.md). Enables seamless serialization and deserialization of **RFC 5322 compliant** email addresses within JSON documents. + +## Install +You can install the package from NuGet: + +```powershell +dotnet add package PosInformatique.Foundations.EmailAddresses.Json +``` + +## Features +- Provides a `JsonConverter` for serialization and deserialization. +- Ensures validation of **RFC 5322 compliant** email addresses when deserializing. +- Can be used via attributes (`[JsonConverter]`) or through `JsonSerializerOptions` extension method. +- Ensures consistency with the base `EmailAddress` value object. + +## Use cases +- **Serialization**: Convert value objects into JSON strings without losing semantics +- **Validation**: Guarantee that only valid RFC 5322 email addresses are accepted in JSON payloads +- **Integration**: Plug directly into `System.Text.Json` configuration + +## Examples + +### Example 1: DTO with `[JsonConverter]` attribute +```csharp +using System.Text.Json; +using System.Text.Json.Serialization; +using PosInformatique.Foundations; + +public class UserDto +{ + [JsonConverter(typeof(EmailAddressJsonConverter))] + public EmailAddress Email { get; set; } = default!; +} + +// Serialization +var dto = new UserDto { Email = "john.doe@example.com" }; +var json = JsonSerializer.Serialize(dto); +// Result: {"Email":"john.doe@example.com"} + +// Deserialization +var input = "{ "Email": "alice@company.org" }"; +var deserialized = JsonSerializer.Deserialize(input); +Console.WriteLine(deserialized!.Email); // "alice@company.org" +``` + +### Example 2: Use extension method without attributes +```csharp +using System.Text.Json; +using PosInformatique.Foundations; + +public class CustomerDto +{ + public EmailAddress Email { get; set; } = default!; +} + +var options = new JsonSerializerOptions().AddEmailAddressesConverters(); + +// Serialization +var dto = new CustomerDto { Email = "bob@myapp.com" }; +var json = JsonSerializer.Serialize(dto, options); +// Result: {"Email":"bob@myapp.com"} + +// Deserialization +var input = "{ "Email": "carol@myapp.com" }"; +var deserialized = JsonSerializer.Deserialize(input, options); +Console.WriteLine(deserialized!.Email); // "carol@myapp.com" +``` + +## Links +- [PosInformatique.Foundations.EmailAddresses NuGet package](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json/) +- [PosInformatique.Foundations.EmailAddresses.Json NuGet package](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) diff --git a/src/EmailAddresses/CHANGELOG.md b/src/EmailAddresses/CHANGELOG.md index 9b36899..206c6d3 100644 --- a/src/EmailAddresses/CHANGELOG.md +++ b/src/EmailAddresses/CHANGELOG.md @@ -1,2 +1,2 @@ 1.0.0 - - EmailAddresses: Initial release with strongly-typed EmailAddress value object + - Initial release with strongly-typed EmailAddress value object. diff --git a/src/EmailAddresses/EmailAddress.cs b/src/EmailAddresses/EmailAddress.cs index 3408c0f..2e56b53 100644 --- a/src/EmailAddresses/EmailAddress.cs +++ b/src/EmailAddresses/EmailAddress.cs @@ -4,7 +4,7 @@ // //----------------------------------------------------------------------- -namespace PosInformatique.Foundations +namespace PosInformatique.Foundations.EmailAddresses { using System.Diagnostics.CodeAnalysis; using MimeKit; diff --git a/tests/EmailAddresses.Json.Tests/EmailAddressJsonConverterTest.cs b/tests/EmailAddresses.Json.Tests/EmailAddressJsonConverterTest.cs new file mode 100644 index 0000000..112fe7c --- /dev/null +++ b/tests/EmailAddresses.Json.Tests/EmailAddressJsonConverterTest.cs @@ -0,0 +1,118 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.EmailAddresses.Json.Tests +{ + using System.Text.Json; + + public class EmailAddressJsonConverterTest + { + [Fact] + public void Serialization() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new EmailAddressJsonConverter(), + }, + }; + + var @object = new JsonClass + { + StringValue = "The string value", + EmailAddress = EmailAddress.Parse(@"""Test"" "), + }; + + @object.Should().BeJsonSerializableInto( + new + { + StringValue = "The string value", + EmailAddress = "test@test.com", + }, + options); + } + + [Fact] + public void Deserialization() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new EmailAddressJsonConverter(), + }, + }; + + var json = new + { + StringValue = "The string value", + EmailAddress = "\"Test\" ", + }; + + json.Should().BeJsonDeserializableInto( + new JsonClass + { + StringValue = "The string value", + EmailAddress = EmailAddress.Parse("test@test.com"), + }, + options); + } + + [Fact] + public void Deserialization_WithNullValue() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new EmailAddressJsonConverter(), + }, + }; + + var json = new + { + StringValue = "The string value", + EmailAddress = (string)null, + }; + + json.Should().BeJsonDeserializableInto( + new JsonClass + { + StringValue = "The string value", + EmailAddress = null, + }, + options); + } + + [Fact] + public void Deserialization_WithInvalidEmail() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new EmailAddressJsonConverter(), + }, + }; + + var act = () => + { + JsonSerializer.Deserialize("{\"StringValue\":\"\",\"EmailAddress\":\"@invalidEmail.com\"}", options); + }; + + act.Should().ThrowExactly() + .WithMessage("'@invalidEmail.com' is not a valid email address."); + } + + private class JsonClass + { + public string StringValue { get; set; } + + public EmailAddress EmailAddress { get; set; } + } + } +} diff --git a/tests/EmailAddresses.Json.Tests/EmailAddressJsonSerializerOptionsExtensionsTest.cs b/tests/EmailAddresses.Json.Tests/EmailAddressJsonSerializerOptionsExtensionsTest.cs new file mode 100644 index 0000000..8f775c6 --- /dev/null +++ b/tests/EmailAddresses.Json.Tests/EmailAddressJsonSerializerOptionsExtensionsTest.cs @@ -0,0 +1,42 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.EmailAddresses.Json.Tests +{ + using System.Text.Json; + + public class EmailAddressJsonSerializerOptionsExtensionsTest + { + [Fact] + public void AddEmailAddressesConverters() + { + var options = new JsonSerializerOptions(); + + options.AddEmailAddressesConverters(); + + options.Converters.Should().HaveCount(1); + options.Converters[0].Should().BeOfType(); + + // Call again to check nothing has been changed. + options.AddEmailAddressesConverters(); + + options.Converters.Should().HaveCount(1); + options.Converters[0].Should().BeOfType(); + } + + [Fact] + public void AddEmailAddressesConverters_WithNullArgument() + { + var act = () => + { + EmailAddressJsonSerializerOptionsExtensions.AddEmailAddressesConverters(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("options"); + } + } +} diff --git a/tests/EmailAddresses.Json.Tests/EmailAddresses.Json.Tests.csproj b/tests/EmailAddresses.Json.Tests/EmailAddresses.Json.Tests.csproj new file mode 100644 index 0000000..793afda --- /dev/null +++ b/tests/EmailAddresses.Json.Tests/EmailAddresses.Json.Tests.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + + + + + + + + + + + diff --git a/tests/EmailAddresses.Tests/EmailAddressTest.cs b/tests/EmailAddresses.Tests/EmailAddressTest.cs index b43e0b1..45f2cba 100644 --- a/tests/EmailAddresses.Tests/EmailAddressTest.cs +++ b/tests/EmailAddresses.Tests/EmailAddressTest.cs @@ -4,7 +4,7 @@ // //----------------------------------------------------------------------- -namespace PosInformatique.Foundations.Tests +namespace PosInformatique.Foundations.EmailAddresses.Tests { public class EmailAddressTest { From 290f72e6b002152e2a0f4906ba573de9267449ed Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 26 Sep 2025 12:43:04 +0200 Subject: [PATCH 07/73] Put .NET version property in common and fix namespace. --- src/Directory.Build.props | 1 + .../EmailAddressJsonSerializerOptionsExtensions.cs | 4 ++-- src/EmailAddresses.Json/EmailAddresses.Json.csproj | 1 - src/EmailAddresses/EmailAddresses.csproj | 1 - tests/Directory.Build.props | 2 ++ .../EmailAddressJsonSerializerOptionsExtensionsTest.cs | 4 ++-- .../EmailAddresses.Json.Tests.csproj | 4 ---- tests/EmailAddresses.Tests/EmailAddresses.Tests.csproj | 4 ---- 8 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ccc084a..0bbb420 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -4,6 +4,7 @@ + net9.0 enable true diff --git a/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs b/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs index 92202d6..ce54379 100644 --- a/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs +++ b/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs @@ -4,9 +4,9 @@ // //----------------------------------------------------------------------- -namespace PosInformatique.Foundations.EmailAddresses.Json +namespace System.Text.Json { - using System.Text.Json; + using PosInformatique.Foundations.EmailAddresses.Json; /// /// Contains extension methods to configure . diff --git a/src/EmailAddresses.Json/EmailAddresses.Json.csproj b/src/EmailAddresses.Json/EmailAddresses.Json.csproj index ffac096..9986d12 100644 --- a/src/EmailAddresses.Json/EmailAddresses.Json.csproj +++ b/src/EmailAddresses.Json/EmailAddresses.Json.csproj @@ -1,7 +1,6 @@  - net9.0 true diff --git a/src/EmailAddresses/EmailAddresses.csproj b/src/EmailAddresses/EmailAddresses.csproj index d95cbc5..a2e9fbc 100644 --- a/src/EmailAddresses/EmailAddresses.csproj +++ b/src/EmailAddresses/EmailAddresses.csproj @@ -1,7 +1,6 @@  - net9.0 true diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 4acd5ed..cbb07ee 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -21,6 +21,8 @@ + net9.0 + false diff --git a/tests/EmailAddresses.Json.Tests/EmailAddressJsonSerializerOptionsExtensionsTest.cs b/tests/EmailAddresses.Json.Tests/EmailAddressJsonSerializerOptionsExtensionsTest.cs index 8f775c6..967ef8d 100644 --- a/tests/EmailAddresses.Json.Tests/EmailAddressJsonSerializerOptionsExtensionsTest.cs +++ b/tests/EmailAddresses.Json.Tests/EmailAddressJsonSerializerOptionsExtensionsTest.cs @@ -4,9 +4,9 @@ // //----------------------------------------------------------------------- -namespace PosInformatique.Foundations.EmailAddresses.Json.Tests +namespace System.Text.Json.Tests { - using System.Text.Json; + using PosInformatique.Foundations.EmailAddresses.Json; public class EmailAddressJsonSerializerOptionsExtensionsTest { diff --git a/tests/EmailAddresses.Json.Tests/EmailAddresses.Json.Tests.csproj b/tests/EmailAddresses.Json.Tests/EmailAddresses.Json.Tests.csproj index 793afda..7e3f218 100644 --- a/tests/EmailAddresses.Json.Tests/EmailAddresses.Json.Tests.csproj +++ b/tests/EmailAddresses.Json.Tests/EmailAddresses.Json.Tests.csproj @@ -1,9 +1,5 @@  - - net9.0 - - diff --git a/tests/EmailAddresses.Tests/EmailAddresses.Tests.csproj b/tests/EmailAddresses.Tests/EmailAddresses.Tests.csproj index fc4f2e6..2d74a14 100644 --- a/tests/EmailAddresses.Tests/EmailAddresses.Tests.csproj +++ b/tests/EmailAddresses.Tests/EmailAddresses.Tests.csproj @@ -1,9 +1,5 @@  - - net9.0 - - From 6c8b2c3fcd11cf90580f59e1b3335c9d681fe1f1 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 26 Sep 2025 14:43:01 +0200 Subject: [PATCH 08/73] Add EmailAddresses.EntityFramework package. --- .editorconfig | 5 + Directory.Packages.props | 6 +- PosInformatique.Foundations.sln | 28 +++- README.md | 1 + .../CHANGELOG.md | 2 + .../EmailAddressPropertyExtensions.cs | 45 +++++++ .../EmailAddresses.EntityFramework.csproj | 31 +++++ src/EmailAddresses.EntityFramework/README.md | 73 +++++++++++ .../EmailAddressPropertyExtensionsTest.cs | 121 ++++++++++++++++++ ...mailAddresses.EntityFramework.Tests.csproj | 11 ++ 10 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 src/EmailAddresses.EntityFramework/CHANGELOG.md create mode 100644 src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs create mode 100644 src/EmailAddresses.EntityFramework/EmailAddresses.EntityFramework.csproj create mode 100644 src/EmailAddresses.EntityFramework/README.md create mode 100644 tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs create mode 100644 tests/EmailAddresses.EntityFramework.Tests/EmailAddresses.EntityFramework.Tests.csproj diff --git a/.editorconfig b/.editorconfig index 786397e..1c5dfef 100644 --- a/.editorconfig +++ b/.editorconfig @@ -76,3 +76,8 @@ dotnet_style_prefer_auto_properties = true:silent dotnet_style_object_initializer = true:suggestion dotnet_style_collection_initializer = true:suggestion dotnet_style_prefer_simplified_boolean_expressions = true:suggestion + +### Visual Studio ### + +# IDE0130: Namespace does not match folder structure +dotnet_diagnostic.IDE0130.severity = none diff --git a/Directory.Packages.props b/Directory.Packages.props index 608e428..79a0fc5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,10 +1,12 @@ - + true + + @@ -14,4 +16,4 @@ - \ No newline at end of file + diff --git a/PosInformatique.Foundations.sln b/PosInformatique.Foundations.sln index 120b384..ec0886f 100644 --- a/PosInformatique.Foundations.sln +++ b/PosInformatique.Foundations.sln @@ -7,7 +7,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses", "src\Email EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.Tests", "tests\EmailAddresses.Tests\EmailAddresses.Tests.csproj", "{BAE006E4-4A1E-4E52-8CA1-D341BD0B26EE}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "00 - Solution Items", "00 - Solution Items", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig .gitignore = .gitignore @@ -42,6 +42,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.Json", "src\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.Json.Tests", "tests\EmailAddresses.Json.Tests\EmailAddresses.Json.Tests.csproj", "{3CEDE123-579A-4E3F-B32C-7FC53950075A}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EmailAddresses", "EmailAddresses", "{B461FB79-24F2-4091-BB91-F56392A0560D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Json", "Json", "{EAC60D27-67F3-4A5E-8772-A2C6AE0631CB}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EntityFramework", "EntityFramework", "{BA63670E-1038-491A-85B2-76DC954A59A5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.EntityFramework", "src\EmailAddresses.EntityFramework\EmailAddresses.EntityFramework.csproj", "{0EA33463-8B0B-057B-E76E-2D441A86832A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.EntityFramework.Tests", "tests\EmailAddresses.EntityFramework.Tests\EmailAddresses.EntityFramework.Tests.csproj", "{1BA02A1F-D02D-4FB1-99F9-A6740B593389}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -64,15 +74,31 @@ Global {3CEDE123-579A-4E3F-B32C-7FC53950075A}.Debug|Any CPU.Build.0 = Debug|Any CPU {3CEDE123-579A-4E3F-B32C-7FC53950075A}.Release|Any CPU.ActiveCfg = Release|Any CPU {3CEDE123-579A-4E3F-B32C-7FC53950075A}.Release|Any CPU.Build.0 = Release|Any CPU + {0EA33463-8B0B-057B-E76E-2D441A86832A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0EA33463-8B0B-057B-E76E-2D441A86832A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0EA33463-8B0B-057B-E76E-2D441A86832A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0EA33463-8B0B-057B-E76E-2D441A86832A}.Release|Any CPU.Build.0 = Release|Any CPU + {1BA02A1F-D02D-4FB1-99F9-A6740B593389}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1BA02A1F-D02D-4FB1-99F9-A6740B593389}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1BA02A1F-D02D-4FB1-99F9-A6740B593389}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1BA02A1F-D02D-4FB1-99F9-A6740B593389}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {6B43B51B-A93C-4B4F-AD4D-4B74A2E7DB3F} = {B461FB79-24F2-4091-BB91-F56392A0560D} + {BAE006E4-4A1E-4E52-8CA1-D341BD0B26EE} = {B461FB79-24F2-4091-BB91-F56392A0560D} {DFE1E9A1-3CB7-4FA4-B304-711772346C43} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {FAA7960F-95C1-45E2-9A42-EED477DF97F1} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {99066C81-F6AB-4A66-9E52-9D324A840307} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {CE76142F-BAA5-4D79-9CBB-9C298378FFF9} = {99066C81-F6AB-4A66-9E52-9D324A840307} + {C203E40E-37C1-49F0-B74C-E3559EB74DA7} = {EAC60D27-67F3-4A5E-8772-A2C6AE0631CB} + {3CEDE123-579A-4E3F-B32C-7FC53950075A} = {EAC60D27-67F3-4A5E-8772-A2C6AE0631CB} + {EAC60D27-67F3-4A5E-8772-A2C6AE0631CB} = {B461FB79-24F2-4091-BB91-F56392A0560D} + {BA63670E-1038-491A-85B2-76DC954A59A5} = {B461FB79-24F2-4091-BB91-F56392A0560D} + {0EA33463-8B0B-057B-E76E-2D441A86832A} = {BA63670E-1038-491A-85B2-76DC954A59A5} + {1BA02A1F-D02D-4FB1-99F9-A6740B593389} = {BA63670E-1038-491A-85B2-76DC954A59A5} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {344068EF-5958-4241-BD83-86403ADA68F1} diff --git a/README.md b/README.md index 9183f1b..8553032 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ You can install any package using the .NET CLI or NuGet Package Manager. | |Package | Description | NuGet | |--|---------|-------------|-------| |PosInformatique.Foundations.EmailAddresses icon|[**PosInformatique.Foundations.EmailAddresses**](./src/EmailAddresses/README.md) | Strongly-typed value object representing an email address with validation and normalization as RFC 5322 compliant. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses) | +|PosInformatique.Foundations.EmailAddresses.EntityFramework icon|[**PosInformatique.Foundations.EmailAddresses.EntityFramework**](./src/EmailAddresses.EntityFramework/README.md) | Entity Framework Core integration for the EmailAddress value object, including property configuration and value converter for seamless database persistence. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework) | |PosInformatique.Foundations.EmailAddresses.Json icon|[**PosInformatique.Foundations.EmailAddresses.Json**](./src/EmailAddresses.Json/README.md) | System.Text.Json converter for the EmailAddress value object, enabling seamless serialization and deserialization of RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json) | > Note: Each package is completely independent. You install only what you need. diff --git a/src/EmailAddresses.EntityFramework/CHANGELOG.md b/src/EmailAddresses.EntityFramework/CHANGELOG.md new file mode 100644 index 0000000..771d9aa --- /dev/null +++ b/src/EmailAddresses.EntityFramework/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with the support Entity Framework persitance for EmailAddress value object. diff --git a/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs b/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs new file mode 100644 index 0000000..0c9773a --- /dev/null +++ b/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs @@ -0,0 +1,45 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.EntityFrameworkCore +{ + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + using PosInformatique.Foundations.EmailAddresses; + + /// + /// Contains extension method to map a to a string column. + /// + public static class EmailAddressPropertyExtensions + { + /// + /// Configures the specified to be mapped on a column with a SQL EmailAddress type. + /// The EmailAddress type must be mapped to a VARCHAR(320). + /// + /// Entity property to map in the . + /// The instance to configure the configuration of the property. + public static PropertyBuilder IsEmailAddress(this PropertyBuilder property) + { + ArgumentNullException.ThrowIfNull(property, nameof(property)); + + return property + .IsUnicode(false) + .HasMaxLength(320) + .HasColumnType("EmailAddress") + .HasConversion(EmailAddressConverter.Instance); + } + + private sealed class EmailAddressConverter : ValueConverter + { + private EmailAddressConverter() + : base(v => v.ToString(), v => EmailAddress.Parse(v)) + { + } + + public static EmailAddressConverter Instance { get; } = new EmailAddressConverter(); + } + } +} diff --git a/src/EmailAddresses.EntityFramework/EmailAddresses.EntityFramework.csproj b/src/EmailAddresses.EntityFramework/EmailAddresses.EntityFramework.csproj new file mode 100644 index 0000000..84f8d0c --- /dev/null +++ b/src/EmailAddresses.EntityFramework/EmailAddresses.EntityFramework.csproj @@ -0,0 +1,31 @@ + + + + true + + + Provides Entity Framework Core integration for the EmailAddress value object. + Enables seamless mapping of RFC 5322 compliant email addresses as strongly-typed properties in Entity Framework Core entities, with safe conversion to string. + + email;emailaddress;entityframework;efcore;valueobject;validation;rfc5322;mapping;conversion;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + diff --git a/src/EmailAddresses.EntityFramework/README.md b/src/EmailAddresses.EntityFramework/README.md new file mode 100644 index 0000000..3931cca --- /dev/null +++ b/src/EmailAddresses.EntityFramework/README.md @@ -0,0 +1,73 @@ +# PosInformatique.Foundations.EmailAddresses.EntityFramework + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) + +## Introduction +Provides **Entity Framework Core** integration for the `EmailAddress` value object from +[PosInformatique.Foundations.EmailAddresses](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/). +This package enables seamless mapping of RFC 5322 compliant email addresses as strongly-typed properties in Entity Framework Core entities. + +It ensures proper SQL type mapping, validation, and conversion to `VARCHAR` when persisted to the database. + +## Install +You can install the package from NuGet: + +```powershell +dotnet add package PosInformatique.Foundations.EmailAddresses.EntityFramework +``` + +## Features +- Provides an extension method `IsEmailAddress()` to configure EF Core properties for `EmailAddress`. +- Maps to `VARCHAR(320)` database columns using the SQL type `EmailAddress` (you must define the SQL type `EmailAddress` mapped to `VARCHAR(320)` in your database). +- Ensures validation, normalization, and safe conversion to/from database fields. +- Built on top of the core `EmailAddress` value object. + +## Use cases +- **Entity mapping**: enforce strong typing for email addresses at the persistence layer. +- **Consistency**: ensure the same validation rules are applied in your entities and database. +- **Safety**: prevent invalid strings being stored in your database + +## Examples + +> ⚠️ To use `IsEmailAddress()`, you must first define the SQL type `EmailAddress` mapped to `VARCHAR(320)` in your database. +For SQL Server, you can create it with: + +```sql +CREATE TYPE EmailAddress FROM VARCHAR(320) NOT NULL; +``` + +### Example: Configure an entity +```csharp +using Microsoft.EntityFrameworkCore; +using PosInformatique.Foundations; + +public class User +{ + public int Id { get; set; } + public EmailAddress Email { get; set; } +} + +public class ApplicationDbContext : DbContext +{ + public DbSet Users => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .Property(u => u.Email) + .IsEmailAddress(); + } +} +``` + +This will configure the `Email` property of the `User` entity with: +- `VARCHAR(320)` column length +- Non-Unicode +- SQL column type `EmailAddress` +- Bi-directional conversion between `EmailAddress` and `string` + +## Links +- [PosInformatique.Foundations.EmailAddresses NuGet package](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) +- [PosInformatique.Foundations.EmailAddresses.EntityFramework NuGet package](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs b/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs new file mode 100644 index 0000000..f0a2913 --- /dev/null +++ b/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs @@ -0,0 +1,121 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.EntityFrameworkCore.Tests +{ + using PosInformatique.Foundations.EmailAddresses; + + public class EmailAddressPropertyExtensionsTest + { + [Fact] + public void IsEmailAddress() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("EmailAddress"); + + property.GetColumnType().Should().Be("EmailAddress"); + property.IsUnicode().Should().BeFalse(); + property.GetMaxLength().Should().Be(320); + } + + [Fact] + public void IsEmailAddress_NullArgument() + { + var act = () => + { + EmailAddressPropertyExtensions.IsEmailAddress(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("property"); + } + + [Theory] + [InlineData("user@domain.com")] + [InlineData("\"The user\" ")] + public void ConvertFromProvider(string emailAddress) + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("EmailAddress"); + + var converter = property.GetValueConverter(); + + converter.ConvertFromProvider(emailAddress).Should().Be(EmailAddress.Parse(emailAddress)); + } + + [Fact] + public void ConvertFromProvider_Null() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("EmailAddress"); + + var converter = property.GetValueConverter(); + + converter.ConvertFromProvider(null).Should().BeNull(); + } + + [Theory] + [InlineData("user@domain.com", "user@domain.com")] + [InlineData("\"The user\" ", "user@domain.com")] + public void ConvertToProvider(string modelEmailAddress, string providerEmailAddress) + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("EmailAddress"); + + var converter = property.GetValueConverter(); + + converter.ConvertToProvider(EmailAddress.Parse(modelEmailAddress)).Should().Be(providerEmailAddress); + } + + [Fact] + public void ConvertToProvider_WithNull() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("EmailAddress"); + + var converter = property.GetValueConverter(); + + converter.ConvertToProvider(null).Should().BeNull(); + } + + private class DbContextMock : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + optionsBuilder.UseSqlServer(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + var property = modelBuilder.Entity() + .Property(e => e.EmailAddress); + + property.IsEmailAddress().Should().BeSameAs(property); + } + } + + private class EntityMock + { + public int Id { get; set; } + + public EmailAddress EmailAddress { get; set; } + } + } +} diff --git a/tests/EmailAddresses.EntityFramework.Tests/EmailAddresses.EntityFramework.Tests.csproj b/tests/EmailAddresses.EntityFramework.Tests/EmailAddresses.EntityFramework.Tests.csproj new file mode 100644 index 0000000..57a13b5 --- /dev/null +++ b/tests/EmailAddresses.EntityFramework.Tests/EmailAddresses.EntityFramework.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + From 52bee764ccba00e9d596da259ab93f195e8b8825 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 26 Sep 2025 15:29:16 +0200 Subject: [PATCH 09/73] Add FluentValidations extensions. --- Directory.Packages.props | 3 +- PosInformatique.Foundations.sln | 17 ++++ src/EmailAddresses.EntityFramework/README.md | 6 +- .../CHANGELOG.md | 2 + .../EmailAddressValidator.cs | 34 ++++++++ .../EmailAddressValidatorExtensions.cs | 35 ++++++++ .../EmailAddresses.FluentValidation.csproj | 30 +++++++ src/EmailAddresses.FluentValidation/README.md | 84 +++++++++++++++++++ src/EmailAddresses.Json/README.md | 6 +- src/EmailAddresses/README.md | 5 +- .../EmailAddressValidatorExtensionsTest.cs | 25 ++++++ .../EmailAddressValidatorTest.cs | 56 +++++++++++++ ...ailAddresses.FluentValidation.Tests.csproj | 17 ++++ .../EmailAddresses.Tests/EmailAddressTest.cs | 28 ++----- .../EmailAddressTestData.cs | 27 ++++++ 15 files changed, 347 insertions(+), 28 deletions(-) create mode 100644 src/EmailAddresses.FluentValidation/CHANGELOG.md create mode 100644 src/EmailAddresses.FluentValidation/EmailAddressValidator.cs create mode 100644 src/EmailAddresses.FluentValidation/EmailAddressValidatorExtensions.cs create mode 100644 src/EmailAddresses.FluentValidation/EmailAddresses.FluentValidation.csproj create mode 100644 src/EmailAddresses.FluentValidation/README.md create mode 100644 tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorExtensionsTest.cs create mode 100644 tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorTest.cs create mode 100644 tests/EmailAddresses.FluentValidation.Tests/EmailAddresses.FluentValidation.Tests.csproj create mode 100644 tests/EmailAddresses.Tests/EmailAddressTestData.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 79a0fc5..b5ba072 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + @@ -16,4 +17,4 @@ - + \ No newline at end of file diff --git a/PosInformatique.Foundations.sln b/PosInformatique.Foundations.sln index ec0886f..863be91 100644 --- a/PosInformatique.Foundations.sln +++ b/PosInformatique.Foundations.sln @@ -52,6 +52,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.EntityFramew EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.EntityFramework.Tests", "tests\EmailAddresses.EntityFramework.Tests\EmailAddresses.EntityFramework.Tests.csproj", "{1BA02A1F-D02D-4FB1-99F9-A6740B593389}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "FluentValidation", "FluentValidation", "{80CB9DF4-D9EB-4E13-A78F-B716093A1B6C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.FluentValidation", "src\EmailAddresses.FluentValidation\EmailAddresses.FluentValidation.csproj", "{9B9B9120-25EE-4DAA-9D09-63B4BBBAEEAA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.FluentValidation.Tests", "tests\EmailAddresses.FluentValidation.Tests\EmailAddresses.FluentValidation.Tests.csproj", "{0903F791-7EE4-4644-BA90-494798B1F4B3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -82,6 +88,14 @@ Global {1BA02A1F-D02D-4FB1-99F9-A6740B593389}.Debug|Any CPU.Build.0 = Debug|Any CPU {1BA02A1F-D02D-4FB1-99F9-A6740B593389}.Release|Any CPU.ActiveCfg = Release|Any CPU {1BA02A1F-D02D-4FB1-99F9-A6740B593389}.Release|Any CPU.Build.0 = Release|Any CPU + {9B9B9120-25EE-4DAA-9D09-63B4BBBAEEAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B9B9120-25EE-4DAA-9D09-63B4BBBAEEAA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B9B9120-25EE-4DAA-9D09-63B4BBBAEEAA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B9B9120-25EE-4DAA-9D09-63B4BBBAEEAA}.Release|Any CPU.Build.0 = Release|Any CPU + {0903F791-7EE4-4644-BA90-494798B1F4B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0903F791-7EE4-4644-BA90-494798B1F4B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0903F791-7EE4-4644-BA90-494798B1F4B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0903F791-7EE4-4644-BA90-494798B1F4B3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -99,6 +113,9 @@ Global {BA63670E-1038-491A-85B2-76DC954A59A5} = {B461FB79-24F2-4091-BB91-F56392A0560D} {0EA33463-8B0B-057B-E76E-2D441A86832A} = {BA63670E-1038-491A-85B2-76DC954A59A5} {1BA02A1F-D02D-4FB1-99F9-A6740B593389} = {BA63670E-1038-491A-85B2-76DC954A59A5} + {80CB9DF4-D9EB-4E13-A78F-B716093A1B6C} = {B461FB79-24F2-4091-BB91-F56392A0560D} + {9B9B9120-25EE-4DAA-9D09-63B4BBBAEEAA} = {80CB9DF4-D9EB-4E13-A78F-B716093A1B6C} + {0903F791-7EE4-4644-BA90-494798B1F4B3} = {80CB9DF4-D9EB-4E13-A78F-B716093A1B6C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {344068EF-5958-4241-BD83-86403ADA68F1} diff --git a/src/EmailAddresses.EntityFramework/README.md b/src/EmailAddresses.EntityFramework/README.md index 3931cca..352b2a3 100644 --- a/src/EmailAddresses.EntityFramework/README.md +++ b/src/EmailAddresses.EntityFramework/README.md @@ -17,6 +17,8 @@ You can install the package from NuGet: dotnet add package PosInformatique.Foundations.EmailAddresses.EntityFramework ``` +This package depends on the base package [PosInformatique.Foundations.EmailAddresses](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/). + ## Features - Provides an extension method `IsEmailAddress()` to configure EF Core properties for `EmailAddress`. - Maps to `VARCHAR(320)` database columns using the SQL type `EmailAddress` (you must define the SQL type `EmailAddress` mapped to `VARCHAR(320)` in your database). @@ -68,6 +70,6 @@ This will configure the `Email` property of the `User` entity with: - Bi-directional conversion between `EmailAddress` and `string` ## Links -- [PosInformatique.Foundations.EmailAddresses NuGet package](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) -- [PosInformatique.Foundations.EmailAddresses.EntityFramework NuGet package](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) +- [NuGet package: EmailAddresses.EntityFramework](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) +- [NuGet package: EmailAddresses (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) - [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/src/EmailAddresses.FluentValidation/CHANGELOG.md b/src/EmailAddresses.FluentValidation/CHANGELOG.md new file mode 100644 index 0000000..5a5116c --- /dev/null +++ b/src/EmailAddresses.FluentValidation/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with the support FluentValidation for the validation of EmailAddress value object. diff --git a/src/EmailAddresses.FluentValidation/EmailAddressValidator.cs b/src/EmailAddresses.FluentValidation/EmailAddressValidator.cs new file mode 100644 index 0000000..98de8d3 --- /dev/null +++ b/src/EmailAddresses.FluentValidation/EmailAddressValidator.cs @@ -0,0 +1,34 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace FluentValidation +{ + using FluentValidation.Validators; + using PosInformatique.Foundations.EmailAddresses; + + internal sealed class EmailAddressValidator : PropertyValidator + { + public override string Name + { + get => "EmailAddressValidator"; + } + + public override bool IsValid(ValidationContext context, string value) + { + if (value is not null) + { + return EmailAddress.IsValid(value); + } + + return true; + } + + protected override string GetDefaultMessageTemplate(string errorCode) + { + return $"'{{PropertyName}}' must be a valid email address."; + } + } +} diff --git a/src/EmailAddresses.FluentValidation/EmailAddressValidatorExtensions.cs b/src/EmailAddresses.FluentValidation/EmailAddressValidatorExtensions.cs new file mode 100644 index 0000000..6124ff7 --- /dev/null +++ b/src/EmailAddresses.FluentValidation/EmailAddressValidatorExtensions.cs @@ -0,0 +1,35 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace FluentValidation +{ + using PosInformatique.Foundations.EmailAddresses; + + /// + /// Contains extension methods for FluentValidation to validate e-mail addresses. + /// + public static class EmailAddressValidatorExtensions + { + /// + /// Defines a validator that checks if a property is a valid e-mail address + /// (parsable by the class). + /// Validation fails if the value is not a valid e-mail address. + /// If the value is , validation succeeds. + /// Use the validator + /// to disallow values. + /// + /// The type of the object being validated. + /// The rule builder on which the validator is defined. + /// The instance to continue configuring the property validator. + /// If the specified argument is . + public static IRuleBuilderOptions MustBeEmailAddress(this IRuleBuilder ruleBuilder) + { + ArgumentNullException.ThrowIfNull(ruleBuilder, nameof(ruleBuilder)); + + return ruleBuilder.SetValidator(new EmailAddressValidator()); + } + } +} diff --git a/src/EmailAddresses.FluentValidation/EmailAddresses.FluentValidation.csproj b/src/EmailAddresses.FluentValidation/EmailAddresses.FluentValidation.csproj new file mode 100644 index 0000000..4bd2726 --- /dev/null +++ b/src/EmailAddresses.FluentValidation/EmailAddresses.FluentValidation.csproj @@ -0,0 +1,30 @@ + + + + true + + + Provides a FluentValidation extension to validate email addresses + using the strongly-typed EmailAddress value object (RFC 5322 compliant). + + email;emailaddress;fluentvalidation;validation;dotnet;rfc5322;ddd;valueobject;parsing;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + diff --git a/src/EmailAddresses.FluentValidation/README.md b/src/EmailAddresses.FluentValidation/README.md new file mode 100644 index 0000000..9b4f6fe --- /dev/null +++ b/src/EmailAddresses.FluentValidation/README.md @@ -0,0 +1,84 @@ +# PosInformatique.Foundations.EmailAddresses.FluentValidation + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.FluentValidation/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.EmailAddresses.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.FluentValidation/) + +## Introduction +This package provides a [FluentValidation](https://fluentvalidation.net/) extension for validating email addresses using the +[EmailAddress](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) value object. + +It ensures that only **valid RFC 5322 compliant email addresses** are accepted when validating string properties. + +- `null` string values are **ignored** (considered valid). +- To require non-null values, combine with the `NotNull()` or/and `NotEmpty()`. + +## Install +You can install the package from [NuGet](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.FluentValidation/): + +```powershell +dotnet add package PosInformatique.Foundations.EmailAddresses.FluentValidation +``` + +This package depends on the base package [PosInformatique.Foundations.EmailAddresses](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/). + +## Features +- FluentValidation extension for email address validation +- Uses the same parsing and validation rules as the [EmailAddress](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) value object +- Clear and consistent error messages +> `null` values are accepted (combine with `NotNull()` validator to forbid nulls) + +## Usage + +### Basic validation +```csharp +using FluentValidation; + +public class User +{ + public string Email { get; set; } +} + +public class UserValidator : AbstractValidator +{ + public UserValidator() + { + RuleFor(u => u.Email).MustBeEmailAddress(); + } +} +``` + +### Null values are ignored +```csharp +var validator = new UserValidator(); + +// Valid, because null is ignored +var result1 = validator.Validate(new User { Email = null }); +Console.WriteLine(result1.IsValid); // True + +// Valid, because it's a valid email +var result2 = validator.Validate(new User { Email = "alice@company.com" }); +Console.WriteLine(result2.IsValid); // True + +// Invalid, because it's not a valid email +var result3 = validator.Validate(new User { Email = "not-an-email" }); +Console.WriteLine(result3.IsValid); // False +``` + +### Require non-null values +```csharp +public class UserValidator : AbstractValidator +{ + public UserValidator() + { + RuleFor(u => u.Email) + .NotEmpty() // Disallow null and empty + .MustBeEmailAddress(); + } +} +``` + +## Links +- [NuGet package: EmailAddresses.FluentValidation](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.FluentValidation/) +- [NuGet package: EmailAddresses (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) +- [FluentValidation](https://fluentvalidation.net/) diff --git a/src/EmailAddresses.Json/README.md b/src/EmailAddresses.Json/README.md index e6ba7c6..53e6031 100644 --- a/src/EmailAddresses.Json/README.md +++ b/src/EmailAddresses.Json/README.md @@ -14,6 +14,8 @@ You can install the package from NuGet: dotnet add package PosInformatique.Foundations.EmailAddresses.Json ``` +This package depends on the base package [PosInformatique.Foundations.EmailAddresses](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/). + ## Features - Provides a `JsonConverter` for serialization and deserialization. - Ensures validation of **RFC 5322 compliant** email addresses when deserializing. @@ -74,6 +76,6 @@ Console.WriteLine(deserialized!.Email); // "carol@myapp.com" ``` ## Links -- [PosInformatique.Foundations.EmailAddresses NuGet package](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json/) -- [PosInformatique.Foundations.EmailAddresses.Json NuGet package](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json/) +- [NuGet package: EmailAddresses.Json](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json/) +- [NuGet package: EmailAddresses (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) - [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) diff --git a/src/EmailAddresses/README.md b/src/EmailAddresses/README.md index 255afc4..5949e05 100644 --- a/src/EmailAddresses/README.md +++ b/src/EmailAddresses/README.md @@ -70,5 +70,8 @@ list.Sort(); // Sorted alphabetically ``` ## Links -- [NuGet package](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) +- [NuGet package: EmailAddresses (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) +- [NuGet package: EmailAddresses.EntityFramework](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) +- [NuGet package: EmailAddresses.FluentValidations](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.FluentValidations/) +- [NuGet package: EmailAddresses.Json](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json/) - [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) diff --git a/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorExtensionsTest.cs b/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorExtensionsTest.cs new file mode 100644 index 0000000..a04d947 --- /dev/null +++ b/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorExtensionsTest.cs @@ -0,0 +1,25 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace FluentValidation.Tests +{ + public class EmailAddressValidatorExtensionsTest + { + [Fact] + public void MustBeEmailAddress() + { + var options = Mock.Of>(MockBehavior.Strict); + + var ruleBuilder = new Mock>(MockBehavior.Strict); + ruleBuilder.Setup(rb => rb.SetValidator(It.IsNotNull>())) + .Returns(options); + + ruleBuilder.Object.MustBeEmailAddress().Should().BeSameAs(options); + + ruleBuilder.VerifyAll(); + } + } +} diff --git a/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorTest.cs b/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorTest.cs new file mode 100644 index 0000000..ea054f9 --- /dev/null +++ b/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorTest.cs @@ -0,0 +1,56 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace FluentValidation.Tests +{ + using FluentValidation.Validators; + using PosInformatique.Foundations; + + public class EmailAddressValidatorTest + { + [Fact] + public void Constructor() + { + var validator = new EmailAddressValidator(); + + validator.Name.Should().Be("EmailAddressValidator"); + } + + [Fact] + public void GetDefaultMessageTemplate() + { + var validator = new EmailAddressValidator(); + + validator.As().GetDefaultMessageTemplate(default).Should().Be("'{PropertyName}' must be a valid email address."); + } + + [Theory] + [MemberData(nameof(EmailAddressTestData.ValidEmailAddresses), MemberType = typeof(EmailAddressTestData))] + public void IsValid_True(string emailAddress) + { + var validator = new EmailAddressValidator(); + + validator.IsValid(default!, emailAddress).Should().BeTrue(); + } + + [Fact] + public void IsValid_WithNull() + { + var validator = new EmailAddressValidator(); + + validator.IsValid(default!, null!).Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(EmailAddressTestData.InvalidEmailAddresses), MemberType = typeof(EmailAddressTestData))] + public void IsValid_False(string emailAddress) + { + var validator = new EmailAddressValidator(); + + validator.IsValid(default!, emailAddress).Should().BeFalse(); + } + } +} diff --git a/tests/EmailAddresses.FluentValidation.Tests/EmailAddresses.FluentValidation.Tests.csproj b/tests/EmailAddresses.FluentValidation.Tests/EmailAddresses.FluentValidation.Tests.csproj new file mode 100644 index 0000000..0e2fb6a --- /dev/null +++ b/tests/EmailAddresses.FluentValidation.Tests/EmailAddresses.FluentValidation.Tests.csproj @@ -0,0 +1,17 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + diff --git a/tests/EmailAddresses.Tests/EmailAddressTest.cs b/tests/EmailAddresses.Tests/EmailAddressTest.cs index 45f2cba..4de8cbe 100644 --- a/tests/EmailAddresses.Tests/EmailAddressTest.cs +++ b/tests/EmailAddresses.Tests/EmailAddressTest.cs @@ -8,22 +8,6 @@ namespace PosInformatique.Foundations.EmailAddresses.Tests { public class EmailAddressTest { - public static TheoryData InvalidEmailAddresses { get; } = new() - { - "Test", - "test1â@test.com", - "test1@", - "@test.com", - "test1,()@test.com", - }; - - public static TheoryData ValidEmailAddresses { get; } = new() - { - @"""Test"" ", - "test1@test.com", - "TEST1@TEST.COM", - }; - [Theory] [InlineData(@"""Test"" ", "test1@test.com", "test1", "test.com")] [InlineData("test1@test.com", "test1@test.com", "test1", "test.com")] @@ -51,7 +35,7 @@ public void Parse_WithNullArgument() } [Theory] - [MemberData(nameof(InvalidEmailAddresses))] + [MemberData(nameof(EmailAddressTestData.InvalidEmailAddresses), MemberType = typeof(EmailAddressTestData))] public void Parse_InvalidEmailAddress(string invalidEmailAdddress) { var act = () => EmailAddress.Parse(invalidEmailAdddress); @@ -89,7 +73,7 @@ public void Parse_WithFormatProvider_WithNullArgument() } [Theory] - [MemberData(nameof(InvalidEmailAddresses))] + [MemberData(nameof(EmailAddressTestData.InvalidEmailAddresses), MemberType = typeof(EmailAddressTestData))] public void Parse_WithFormatProvider_InvalidEmailAddress(string invalidEmailAdddress) { var formatProvider = Mock.Of(MockBehavior.Strict); @@ -117,7 +101,7 @@ public void TryParse(string emailAddress, string expectedEmailAddress, string us } [Theory] - [MemberData(nameof(InvalidEmailAddresses))] + [MemberData(nameof(EmailAddressTestData.InvalidEmailAddresses), MemberType = typeof(EmailAddressTestData))] public void TryParse_InvalidEmailAddress(string invalidEmailAdddress) { var result = EmailAddress.TryParse(invalidEmailAdddress, out var address); @@ -145,7 +129,7 @@ public void TryParse_WithFormatProvider(string emailAddress, string expectedEmai } [Theory] - [MemberData(nameof(InvalidEmailAddresses))] + [MemberData(nameof(EmailAddressTestData.InvalidEmailAddresses), MemberType = typeof(EmailAddressTestData))] public void TryParse_WithFormatProvider_InvalidEmailAddress(string invalidEmailAdddress) { var formatProvider = Mock.Of(MockBehavior.Strict); @@ -157,14 +141,14 @@ public void TryParse_WithFormatProvider_InvalidEmailAddress(string invalidEmailA } [Theory] - [MemberData(nameof(ValidEmailAddresses))] + [MemberData(nameof(EmailAddressTestData.ValidEmailAddresses), MemberType = typeof(EmailAddressTestData))] public void IsValid_Valid(string emailAddress) { EmailAddress.IsValid(emailAddress).Should().BeTrue(); } [Theory] - [MemberData(nameof(InvalidEmailAddresses))] + [MemberData(nameof(EmailAddressTestData.InvalidEmailAddresses), MemberType = typeof(EmailAddressTestData))] public void IsValid_Invalid(string invalidEmailAdddress) { EmailAddress.IsValid(invalidEmailAdddress).Should().BeFalse(); diff --git a/tests/EmailAddresses.Tests/EmailAddressTestData.cs b/tests/EmailAddresses.Tests/EmailAddressTestData.cs new file mode 100644 index 0000000..991542c --- /dev/null +++ b/tests/EmailAddresses.Tests/EmailAddressTestData.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations +{ + public static class EmailAddressTestData + { + public static TheoryData InvalidEmailAddresses { get; } = + [ + "Test", + "test1â@test.com", + "test1@", + "@test.com", + "test1,()@test.com", + ]; + + public static TheoryData ValidEmailAddresses { get; } = + [ + @"""Test"" ", + "test1@test.com", + "TEST1@TEST.COM", + ]; + } +} From 4adcc8fb5c90236144432f52697156039dab69a5 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 26 Sep 2025 15:32:26 +0200 Subject: [PATCH 10/73] Fix unit tests. --- tests/EmailAddresses.Tests/EmailAddressTest.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/EmailAddresses.Tests/EmailAddressTest.cs b/tests/EmailAddresses.Tests/EmailAddressTest.cs index 4de8cbe..5c188bd 100644 --- a/tests/EmailAddresses.Tests/EmailAddressTest.cs +++ b/tests/EmailAddresses.Tests/EmailAddressTest.cs @@ -102,6 +102,7 @@ public void TryParse(string emailAddress, string expectedEmailAddress, string us [Theory] [MemberData(nameof(EmailAddressTestData.InvalidEmailAddresses), MemberType = typeof(EmailAddressTestData))] + [InlineData(null)] public void TryParse_InvalidEmailAddress(string invalidEmailAdddress) { var result = EmailAddress.TryParse(invalidEmailAdddress, out var address); @@ -130,6 +131,7 @@ public void TryParse_WithFormatProvider(string emailAddress, string expectedEmai [Theory] [MemberData(nameof(EmailAddressTestData.InvalidEmailAddresses), MemberType = typeof(EmailAddressTestData))] + [InlineData(null)] public void TryParse_WithFormatProvider_InvalidEmailAddress(string invalidEmailAdddress) { var formatProvider = Mock.Of(MockBehavior.Strict); @@ -149,6 +151,7 @@ public void IsValid_Valid(string emailAddress) [Theory] [MemberData(nameof(EmailAddressTestData.InvalidEmailAddresses), MemberType = typeof(EmailAddressTestData))] + [InlineData(null)] public void IsValid_Invalid(string invalidEmailAdddress) { EmailAddress.IsValid(invalidEmailAdddress).Should().BeFalse(); From 4e131f700bce8a782cac7e8d5105944f5cbe44f0 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sat, 27 Sep 2025 10:25:46 +0200 Subject: [PATCH 11/73] Fix README for FluentValidation package. --- src/EmailAddresses/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/EmailAddresses/README.md b/src/EmailAddresses/README.md index 5949e05..848c717 100644 --- a/src/EmailAddresses/README.md +++ b/src/EmailAddresses/README.md @@ -72,6 +72,6 @@ list.Sort(); // Sorted alphabetically ## Links - [NuGet package: EmailAddresses (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) - [NuGet package: EmailAddresses.EntityFramework](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) -- [NuGet package: EmailAddresses.FluentValidations](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.FluentValidations/) +- [NuGet package: EmailAddresses.FluentValidations](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.FluentValidation/) - [NuGet package: EmailAddresses.Json](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json/) - [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) From 38c0e97a2b94ccd0769c2eed9fe760117efb44ce Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sat, 27 Sep 2025 10:26:03 +0200 Subject: [PATCH 12/73] Fix IsEmailAddress() for nullable property. --- .../EmailAddressPropertyExtensions.cs | 10 +++++- .../EmailAddressPropertyExtensionsTest.cs | 34 ++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs b/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs index 0c9773a..9be8b2c 100644 --- a/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs +++ b/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs @@ -19,12 +19,20 @@ public static class EmailAddressPropertyExtensions /// Configures the specified to be mapped on a column with a SQL EmailAddress type. /// The EmailAddress type must be mapped to a VARCHAR(320). /// + /// Type of the property which must be . /// Entity property to map in the . /// The instance to configure the configuration of the property. - public static PropertyBuilder IsEmailAddress(this PropertyBuilder property) + /// If the specified argument is . + /// If the specified generic type is not a . + public static PropertyBuilder IsEmailAddress(this PropertyBuilder property) { ArgumentNullException.ThrowIfNull(property, nameof(property)); + if (typeof(T) != typeof(EmailAddress)) + { + throw new ArgumentException($"The '{nameof(IsEmailAddress)}()' method must be called on '{nameof(EmailAddress)} class.", nameof(T)); + } + return property .IsUnicode(false) .HasMaxLength(320) diff --git a/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs b/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs index f0a2913..e9c189c 100644 --- a/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs +++ b/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs @@ -21,6 +21,12 @@ public void IsEmailAddress() property.GetColumnType().Should().Be("EmailAddress"); property.IsUnicode().Should().BeFalse(); property.GetMaxLength().Should().Be(320); + + property = entity.GetProperty("NullableEmailAddress"); + + property.GetColumnType().Should().Be("EmailAddress"); + property.IsUnicode().Should().BeFalse(); + property.GetMaxLength().Should().Be(320); } [Fact] @@ -28,13 +34,30 @@ public void IsEmailAddress_NullArgument() { var act = () => { - EmailAddressPropertyExtensions.IsEmailAddress(null); + EmailAddressPropertyExtensions.IsEmailAddress(null); }; act.Should().ThrowExactly() .WithParameterName("property"); } + [Fact] + public void IsEmailAddress_NotEmailAddressProperty() + { + var builder = new ModelBuilder(); + var property = builder.Entity() + .Property(e => e.Id); + + var act = () => + { + property.IsEmailAddress(); + }; + + act.Should().ThrowExactly() + .WithMessage("The 'IsEmailAddress()' method must be called on 'EmailAddress class. (Parameter 'T')") + .WithParameterName("T"); + } + [Theory] [InlineData("user@domain.com")] [InlineData("\"The user\" ")] @@ -108,6 +131,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .Property(e => e.EmailAddress); property.IsEmailAddress().Should().BeSameAs(property); + + var nullableProperty = modelBuilder.Entity() + .Property(e => e.NullableEmailAddress); + + nullableProperty.IsEmailAddress().Should().BeSameAs(nullableProperty); } } @@ -116,6 +144,10 @@ private class EntityMock public int Id { get; set; } public EmailAddress EmailAddress { get; set; } + +#nullable enable + public EmailAddress? NullableEmailAddress { get; set; } +#nullable restore } } } From 6ab0732067966e7c97a4d8996298072756086fbc Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sat, 27 Sep 2025 10:26:21 +0200 Subject: [PATCH 13/73] Fix CI to raise error if there is some warnings. --- .editorconfig | 14 ++++++++++++-- .github/workflows/github-actions-ci.yaml | 5 ++++- ...ons-release.yml => github-actions-release.yaml} | 0 PosInformatique.Foundations.sln | 2 +- 4 files changed, 17 insertions(+), 4 deletions(-) rename .github/workflows/{github-actions-release.yml => github-actions-release.yaml} (100%) diff --git a/.editorconfig b/.editorconfig index 1c5dfef..bff7b65 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,14 +4,17 @@ charset = utf-8-bom insert_final_newline = true trim_trailing_whitespace = true -csharp_using_directive_placement = inside_namespace:warning -csharp_style_prefer_primary_constructors = false:suggestion # Markdown specific settings [*.md] indent_style = space indent_size = 2 +# YAML specific settings +[*.yaml] +indent_style = space +indent_size = 2 + # x.proj specific settings [*.{csproj,props}] indent_style = space @@ -24,6 +27,9 @@ tab_width = 4 indent_size = 4 end_of_line = crlf +csharp_style_prefer_primary_constructors = false:suggestion +csharp_using_directive_placement = inside_namespace:warning + # Naming rules dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion @@ -70,6 +76,7 @@ dotnet_naming_style.pascal_case.word_separator = dotnet_naming_style.pascal_case.capitalization = pascal_case dotnet_style_operator_placement_when_wrapping = beginning_of_line dotnet_style_coalesce_expression = true:suggestion +dotnet_style_namespace_match_folder = false:suggestion dotnet_style_null_propagation = true:suggestion dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion dotnet_style_prefer_auto_properties = true:silent @@ -79,5 +86,8 @@ dotnet_style_prefer_simplified_boolean_expressions = true:suggestion ### Visual Studio ### +# IDE0005: Using directive is unnecessary. +dotnet_diagnostic.IDE0005.severity = warning + # IDE0130: Namespace does not match folder structure dotnet_diagnostic.IDE0130.severity = none diff --git a/.github/workflows/github-actions-ci.yaml b/.github/workflows/github-actions-ci.yaml index 53ea05e..cef0a59 100644 --- a/.github/workflows/github-actions-ci.yaml +++ b/.github/workflows/github-actions-ci.yaml @@ -24,7 +24,10 @@ jobs: run: dotnet restore PosInformatique.Foundations.sln - name: Build solution - run: dotnet build PosInformatique.Foundations.sln --configuration Release --no-restore + run: dotnet build PosInformatique.Foundations.sln \ + --configuration Release \ + --no-restore \ + -warnaserror - name: Run tests run: | diff --git a/.github/workflows/github-actions-release.yml b/.github/workflows/github-actions-release.yaml similarity index 100% rename from .github/workflows/github-actions-release.yml rename to .github/workflows/github-actions-release.yaml diff --git a/PosInformatique.Foundations.sln b/PosInformatique.Foundations.sln index 863be91..bd2cf8b 100644 --- a/PosInformatique.Foundations.sln +++ b/PosInformatique.Foundations.sln @@ -35,7 +35,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{CE76142F-BAA5-4D79-9CBB-9C298378FFF9}" ProjectSection(SolutionItems) = preProject .github\workflows\github-actions-ci.yaml = .github\workflows\github-actions-ci.yaml - .github\workflows\github-actions-release.yml = .github\workflows\github-actions-release.yml + .github\workflows\github-actions-release.yaml = .github\workflows\github-actions-release.yaml EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.Json", "src\EmailAddresses.Json\EmailAddresses.Json.csproj", "{C203E40E-37C1-49F0-B74C-E3559EB74DA7}" From 5be1fc79d1309ed90ad2f99fdd98a9421628ae70 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sat, 27 Sep 2025 10:33:07 +0200 Subject: [PATCH 14/73] Fix CI. --- .github/workflows/github-actions-ci.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/github-actions-ci.yaml b/.github/workflows/github-actions-ci.yaml index cef0a59..fdd6884 100644 --- a/.github/workflows/github-actions-ci.yaml +++ b/.github/workflows/github-actions-ci.yaml @@ -24,7 +24,8 @@ jobs: run: dotnet restore PosInformatique.Foundations.sln - name: Build solution - run: dotnet build PosInformatique.Foundations.sln \ + run: | + dotnet build PosInformatique.Foundations.sln \ --configuration Release \ --no-restore \ -warnaserror From 14f9ae97923ada44631f83c75b10f79e3d0ff22f Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sat, 27 Sep 2025 10:46:21 +0200 Subject: [PATCH 15/73] Disable warnings to error in the CI. --- .github/workflows/github-actions-ci.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/github-actions-ci.yaml b/.github/workflows/github-actions-ci.yaml index fdd6884..ead18b0 100644 --- a/.github/workflows/github-actions-ci.yaml +++ b/.github/workflows/github-actions-ci.yaml @@ -27,8 +27,7 @@ jobs: run: | dotnet build PosInformatique.Foundations.sln \ --configuration Release \ - --no-restore \ - -warnaserror + --no-restore - name: Run tests run: | From 1b26b6ea213d9abcf256fe8dd2304d7fb5a2809a Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 9 Oct 2025 14:55:57 +0200 Subject: [PATCH 16/73] Add the People core library. --- PosInformatique.Foundations.sln | 16 + src/People/CHANGELOG.md | 4 + src/People/FirstName.cs | 445 +++++++++++++++++++ src/People/IPerson.cs | 26 ++ src/People/Icon.png | Bin 0 -> 41943 bytes src/People/LastName.cs | 438 ++++++++++++++++++ src/People/NameNormalizer.cs | 48 ++ src/People/People.csproj | 28 ++ src/People/PersonExtensions.cs | 58 +++ src/People/README.md | 251 +++++++++++ tests/People.Tests/FirstNameTest.cs | 484 ++++++++++++++++++++ tests/People.Tests/LastNameTest.cs | 493 +++++++++++++++++++++ tests/People.Tests/NameNormalizerTest.cs | 71 +++ tests/People.Tests/NameTestData.cs | 56 +++ tests/People.Tests/People.Tests.csproj | 7 + tests/People.Tests/PersonExtensionsTest.cs | 89 ++++ 16 files changed, 2514 insertions(+) create mode 100644 src/People/CHANGELOG.md create mode 100644 src/People/FirstName.cs create mode 100644 src/People/IPerson.cs create mode 100644 src/People/Icon.png create mode 100644 src/People/LastName.cs create mode 100644 src/People/NameNormalizer.cs create mode 100644 src/People/People.csproj create mode 100644 src/People/PersonExtensions.cs create mode 100644 src/People/README.md create mode 100644 tests/People.Tests/FirstNameTest.cs create mode 100644 tests/People.Tests/LastNameTest.cs create mode 100644 tests/People.Tests/NameNormalizerTest.cs create mode 100644 tests/People.Tests/NameTestData.cs create mode 100644 tests/People.Tests/People.Tests.csproj create mode 100644 tests/People.Tests/PersonExtensionsTest.cs diff --git a/PosInformatique.Foundations.sln b/PosInformatique.Foundations.sln index bd2cf8b..2190b6b 100644 --- a/PosInformatique.Foundations.sln +++ b/PosInformatique.Foundations.sln @@ -58,6 +58,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.FluentValida EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.FluentValidation.Tests", "tests\EmailAddresses.FluentValidation.Tests\EmailAddresses.FluentValidation.Tests.csproj", "{0903F791-7EE4-4644-BA90-494798B1F4B3}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "People", "People", "{F2473697-B13F-422F-A267-DA263F56025F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People", "src\People\People.csproj", "{5CF087B0-0B63-40F5-8E7B-6DCE1FAA1EEE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.Tests", "tests\People.Tests\People.Tests.csproj", "{E9727893-5089-49AD-B829-9A0C7478DE8D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -96,6 +102,14 @@ Global {0903F791-7EE4-4644-BA90-494798B1F4B3}.Debug|Any CPU.Build.0 = Debug|Any CPU {0903F791-7EE4-4644-BA90-494798B1F4B3}.Release|Any CPU.ActiveCfg = Release|Any CPU {0903F791-7EE4-4644-BA90-494798B1F4B3}.Release|Any CPU.Build.0 = Release|Any CPU + {5CF087B0-0B63-40F5-8E7B-6DCE1FAA1EEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5CF087B0-0B63-40F5-8E7B-6DCE1FAA1EEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5CF087B0-0B63-40F5-8E7B-6DCE1FAA1EEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5CF087B0-0B63-40F5-8E7B-6DCE1FAA1EEE}.Release|Any CPU.Build.0 = Release|Any CPU + {E9727893-5089-49AD-B829-9A0C7478DE8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E9727893-5089-49AD-B829-9A0C7478DE8D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E9727893-5089-49AD-B829-9A0C7478DE8D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E9727893-5089-49AD-B829-9A0C7478DE8D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -116,6 +130,8 @@ Global {80CB9DF4-D9EB-4E13-A78F-B716093A1B6C} = {B461FB79-24F2-4091-BB91-F56392A0560D} {9B9B9120-25EE-4DAA-9D09-63B4BBBAEEAA} = {80CB9DF4-D9EB-4E13-A78F-B716093A1B6C} {0903F791-7EE4-4644-BA90-494798B1F4B3} = {80CB9DF4-D9EB-4E13-A78F-B716093A1B6C} + {5CF087B0-0B63-40F5-8E7B-6DCE1FAA1EEE} = {F2473697-B13F-422F-A267-DA263F56025F} + {E9727893-5089-49AD-B829-9A0C7478DE8D} = {F2473697-B13F-422F-A267-DA263F56025F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {344068EF-5958-4241-BD83-86403ADA68F1} diff --git a/src/People/CHANGELOG.md b/src/People/CHANGELOG.md new file mode 100644 index 0000000..d561013 --- /dev/null +++ b/src/People/CHANGELOG.md @@ -0,0 +1,4 @@ +1.0.0 + - Initial release with strongly-typed FirstName, LastName value objects. + - Add IPerson interface + extension methods + - NameNormalizer to normalize the composed names first name / last name diff --git a/src/People/FirstName.cs b/src/People/FirstName.cs new file mode 100644 index 0000000..602d43c --- /dev/null +++ b/src/People/FirstName.cs @@ -0,0 +1,445 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People +{ + using System.Collections; + using System.Diagnostics.CodeAnalysis; + using System.Globalization; + using System.Text; + + /// + /// Represents a normalized first name with the following rules: + /// + /// The maximum length is 50 characters (see ). + /// Only letters are allowed, with separators limited to space and - (list of allowed characters are accessible from the property). + /// Each word starts with an uppercase letter (e.g., John, John Henri-Smith). + /// No consecutive or trailing separators are allowed. + /// Implicit conversions from/to are provided. + /// Implements and for standard .NET conversions. + /// Acts like a read-only array of via . + /// + /// Using this type standardizes first name representation across your domain (users, people, customers, etc.). + /// + public sealed class FirstName : IReadOnlyList, IEquatable, IComparable, IFormattable, IParsable + { + /// + /// Maximum allowed length of a (50). + /// + public const int MaxLength = 50; + + private static readonly CultureInfo DefaultCulture = new CultureInfo("fr-FR"); + + private readonly string value; + + private FirstName(string value) + { + this.value = value; + } + + private enum InvalidReason + { + None, + Null, + InvalidCharacter, + TooLong, + Empty, + } + + /// + /// Gets the separators allowed in a first name. + /// + public static IReadOnlyList AllowedSeparators { get; } = [' ', '-']; + + /// + /// Gets the number of characters in the first name. + /// + int IReadOnlyCollection.Count => this.value.Length; + + /// + /// Gets the number of characters in the first name. + /// + public int Length => this.value.Length; + + /// + /// Gets the character at the specified zero-based index. + /// + /// The zero-based position of the character. + /// The character at the specified . + /// Thrown when is less than 0 or greater than or equal to . + public char this[int index] => this.value[index]; + + /// + /// Implicitly converts a to its representation. + /// + /// The instance to convert. + /// The normalized string value. + /// If is . + public static implicit operator string(FirstName firstName) + { + ArgumentNullException.ThrowIfNull(firstName, nameof(firstName)); + + return firstName.ToString(); + } + + /// + /// Implicitly converts a to a . + /// + /// The string value to convert. + /// The created . + /// If is . + /// If the value is empty, exceeds , or contains invalid characters. + public static implicit operator FirstName(string firstName) + { + return Create(firstName); + } + + /// + /// Determines whether two values have the same content. + /// + /// The first to compare. + /// The second to compare. + /// if the values are equal; otherwise, . + public static bool operator ==(FirstName? left, FirstName? right) + { + return Equals(left, right); + } + + /// + /// Determines whether two values have different content. + /// + /// The first to compare. + /// The second to compare. + /// if the values are not equal; otherwise, . + public static bool operator !=(FirstName? left, FirstName? right) + { + return !(left == right); + } + + /// + /// Determines whether the value is lexicographically less than the value. + /// + /// The first to compare. + /// The second to compare. + /// if is less than ; otherwise, . + public static bool operator <(FirstName? left, FirstName? right) + { + return Comparer.Default.Compare(left, right) < 0; + } + + /// + /// Determines whether the value is lexicographically less than or equal to the value. + /// + /// The first to compare. + /// The second to compare. + /// if is less than or equal to ; otherwise, . + public static bool operator <=(FirstName? left, FirstName? right) + { + return Comparer.Default.Compare(left, right) <= 0; + } + + /// + /// Determines whether the value is lexicographically greater than the value. + /// + /// The first to compare. + /// The second to compare. + /// if is greater than ; otherwise, . + public static bool operator >(FirstName? left, FirstName? right) + { + return Comparer.Default.Compare(left, right) > 0; + } + + /// + /// Determines whether the value is lexicographically greater than or equal to the value. + /// + /// The first to compare. + /// The second to compare. + /// if is greater than or equal to ; otherwise, . + public static bool operator >=(FirstName? left, FirstName? right) + { + return Comparer.Default.Compare(left, right) >= 0; + } + + /// + /// Creates a from the provided string, enforcing normalization and validation rules. + /// + /// The input value. + /// A valid . + /// If is . + /// If the value is empty, exceeds , or contains invalid characters. + public static FirstName Create(string firstName) + { + var result = TryCreateCore(firstName); + + if (result.FirstName is null) + { + if (result.InvalidReason == InvalidReason.Null) + { + throw new ArgumentNullException(nameof(firstName)); + } + + if (result.InvalidReason == InvalidReason.TooLong) + { + throw new ArgumentException($"The first name cannot exceed more than {MaxLength} characters.", nameof(firstName)); + } + + if (result.InvalidReason == InvalidReason.Empty) + { + throw new ArgumentException($"The first name cannot be empty.", nameof(firstName)); + } + + throw new ArgumentException($"'{firstName}' is not a valid first name.", nameof(firstName)); + } + + return result.FirstName; + } + + /// + /// Tries to create a from the provided value. + /// + /// The input value. + /// When this method returns, contains the created if successful; otherwise . + /// if creation succeeded; otherwise, . + public static bool TryCreate([NotNullWhen(true)] string? value, [MaybeNullWhen(false)][NotNullWhen(true)] out FirstName? firstName) + { + var result = TryCreateCore(value); + + if (result.FirstName is null) + { + firstName = null; + return false; + } + + firstName = result.FirstName; + return true; + } + + /// + /// Determines whether the specified value is a valid first name according to the rules. + /// + /// The value to validate. + /// if valid; otherwise, . + public static bool IsValid(string firstName) + { + return TryCreate(firstName, out var _); + } + + /// + /// Parses a into a . + /// + /// The to parse. + /// A format provider (ignored). + /// The parsed . + /// If is . + /// + /// If the value is empty, exceeds , or contains invalid characters. + /// + static FirstName IParsable.Parse(string s, IFormatProvider? provider) + { + return Create(s); + } + + /// + /// Tries to parse a into a . + /// + /// The to parse. + /// A format provider (ignored). + /// When this method returns, contains the parsed if successful; otherwise . + /// if parsing succeeded; otherwise, . + static bool IParsable.TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)][NotNullWhen(true)] out FirstName? result) + { + return TryCreate(s, out result); + } + + /// + /// Determines whether the specified object is equal to the current . + /// + /// The object to compare with the current instance. + /// if equal; otherwise, . + public override bool Equals(object? obj) + { + if (obj is not FirstName firstName) + { + return false; + } + + return this.Equals(firstName); + } + + /// + /// Indicates whether the current object is equal to another . + /// + /// A to compare with this instance. + /// if equal; otherwise, . + public bool Equals(FirstName? other) + { + if (other is null) + { + return false; + } + + return this.value.Equals(other.value, StringComparison.Ordinal); + } + + /// + /// Returns a hash code for this instance. + /// + /// A hash code for the current object. + public override int GetHashCode() + { + return this.value.GetHashCode(StringComparison.Ordinal); + } + + /// + /// Returns the normalized string representation of the first name. + /// + /// The normalized first name. + public override string ToString() + { + return this.value; + } + + /// + /// Formats the value of the current instance using the specified format. + /// + /// A format string (ignored). + /// A format provider (ignored). + /// The normalized first name. + string IFormattable.ToString(string? format, IFormatProvider? formatProvider) + { + return this.ToString(); + } + + /// + /// Returns an that iterates through the characters of the first name. + /// + /// An over the characters. + public IEnumerator GetEnumerator() + { + return this.value.GetEnumerator(); + } + + /// + /// Compares the current instance with another and returns an integer + /// that indicates whether the current instance precedes, follows, or occurs in the same position + /// in the sort order as the other object. + /// + /// The other to compare. + /// + /// A value less than zero if this instance precedes ; zero if they are equal; + /// greater than zero if this instance follows . + /// + public int CompareTo(FirstName? other) + { + if (other is null) + { + return string.Compare(this.value, null, StringComparison.Ordinal); + } + + return string.Compare(this.value, other.value, StringComparison.Ordinal); + } + + /// + /// Returns an that iterates through the characters of the first name. + /// + /// An over the characters. + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + private static ParseResult TryCreateCore(string? value) + { + if (value is null) + { + return ParseResult.Invalid(InvalidReason.Null); + } + + var firstNameBuilder = new StringBuilder(value.Length); + + var upperCase = true; + + for (var i = 0; i < value.Length; i++) + { + var letter = value[i]; + + if (char.IsLetter(letter)) + { + // It is a letter, make it in upper or lower case depending of the current context. + if (upperCase) + { + firstNameBuilder.Append(char.ToUpper(letter, DefaultCulture)); + upperCase = false; + } + else + { + firstNameBuilder.Append(char.ToLower(letter, DefaultCulture)); + } + } + else if (AllowedSeparators.Contains(letter)) + { + // Allowed character + if (!upperCase) + { + // Add the separator and define the next letter to uppercase. + firstNameBuilder.Append(letter); + upperCase = true; + } + else + { + // Ignore the separator (already have more than one). + } + } + else + { + // Invalid character + return ParseResult.Invalid(InvalidReason.InvalidCharacter); + } + } + + // If at the end we have a separator, remove it. + if (firstNameBuilder.Length > 0 && AllowedSeparators.Contains(firstNameBuilder[^1])) + { + firstNameBuilder.Remove(firstNameBuilder.Length - 1, 1); + } + + if (firstNameBuilder.Length == 0) + { + return ParseResult.Invalid(InvalidReason.Empty); + } + + if (firstNameBuilder.Length > MaxLength) + { + return ParseResult.Invalid(InvalidReason.TooLong); + } + + return ParseResult.Valid(new FirstName(firstNameBuilder.ToString())); + } + + private readonly struct ParseResult + { + private ParseResult(FirstName? firstName, InvalidReason? invalidReason) + { + this.FirstName = firstName; + this.InvalidReason = invalidReason; + } + + public FirstName? FirstName { get; } + + public InvalidReason? InvalidReason { get; } + + public static ParseResult Invalid(InvalidReason invalidReason) + { + return new ParseResult(null, invalidReason); + } + + public static ParseResult Valid(FirstName firstName) + { + return new ParseResult(firstName, null); + } + } + } +} diff --git a/src/People/IPerson.cs b/src/People/IPerson.cs new file mode 100644 index 0000000..dcd0e2b --- /dev/null +++ b/src/People/IPerson.cs @@ -0,0 +1,26 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People +{ + /// + /// Represents a nominative person entity identified by a and a . + /// Use it for domain concepts like user, customer, employee, or contact. Can be implemented + /// by any business entity to generalize persons. + /// + public interface IPerson + { + /// + /// Gets the normalized first name of the person. + /// + FirstName FirstName { get; } + + /// + /// Gets the normalized last name of the person. + /// + LastName LastName { get; } + } +} diff --git a/src/People/Icon.png b/src/People/Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..49e0da9955cbe8c45661c73abf79158bc06d93af GIT binary patch literal 41943 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μC4rMMEicvKT5uy@X6X^2Pax+SaZFxKe~ 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 ToQH&hfZuH$!<%JVb}#=AGlWPd literal 0 HcmV?d00001 diff --git a/src/People/LastName.cs b/src/People/LastName.cs new file mode 100644 index 0000000..83d803f --- /dev/null +++ b/src/People/LastName.cs @@ -0,0 +1,438 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People +{ + using System.Collections; + using System.Diagnostics.CodeAnalysis; + using System.Globalization; + using System.Text; + + /// + /// Represents a normalized last name with the following rules: + /// + /// The maximum length is 50 characters (see ). + /// Only letters are allowed, with separators limited to space and - (list of allowed characters are accessible from the property). + /// All letters are uppercased (e.g., SMITH, SMITH-JOHNSON). + /// No consecutive or trailing separators are allowed. + /// Implicit conversions from/to are provided. + /// Implements and for standard .NET conversions. + /// Acts like a read-only array of via . + /// + /// Using this type standardizes last name representation across your domain (users, people, customers, etc.). + /// + public sealed class LastName : IReadOnlyList, IEquatable, IComparable, IFormattable, IParsable + { + /// + /// Maximum allowed length of a (50). + /// + public const int MaxLength = 50; + + private static readonly CultureInfo DefaultCulture = new CultureInfo("fr-FR"); + + private readonly string value; + + private LastName(string value) + { + this.value = value; + } + + private enum InvalidReason + { + None, + Null, + InvalidCharacter, + TooLong, + Empty, + } + + /// + /// Gets the separators allowed in a last name. + /// + public static IReadOnlyList AllowedSeparators { get; } = [' ', '-']; + + /// + /// Gets the number of characters in the last name. + /// + int IReadOnlyCollection.Count => this.value.Length; + + /// + /// Gets the number of characters in the last name. + /// + public int Length => this.value.Length; + + /// + /// Gets the character at the specified zero-based index. + /// + /// The zero-based position of the character. + /// The character at the specified . + /// Thrown when is less than 0 or greater than or equal to . + public char this[int index] => this.value[index]; + + /// + /// Implicitly converts a to its representation. + /// + /// The instance to convert. + /// The normalized string value. + /// If is . + public static implicit operator string(LastName lastName) + { + ArgumentNullException.ThrowIfNull(lastName, nameof(lastName)); + + return lastName.ToString(); + } + + /// + /// Implicitly converts a to a . + /// + /// The string value to convert. + /// The created . + /// If is . + /// If the value is empty, exceeds , or contains invalid characters. + public static implicit operator LastName(string lastName) + { + return Create(lastName); + } + + /// + /// Determines whether two values have the same content. + /// + /// The first to compare. + /// The second to compare. + /// if the values are equal; otherwise, . + public static bool operator ==(LastName? left, LastName? right) + { + return Equals(left, right); + } + + /// + /// Determines whether two values have different content. + /// + /// The first to compare. + /// The second to compare. + /// if the values are not equal; otherwise, . + public static bool operator !=(LastName? left, LastName? right) + { + return !(left == right); + } + + /// + /// Determines whether the value is lexicographically less than the value. + /// + /// The first to compare. + /// The second to compare. + /// if is less than ; otherwise, . + public static bool operator <(LastName? left, LastName? right) + { + return Comparer.Default.Compare(left, right) < 0; + } + + /// + /// Determines whether the value is lexicographically less than or equal to the value. + /// + /// The first to compare. + /// The second to compare. + /// if is less than or equal to ; otherwise, . + public static bool operator <=(LastName? left, LastName? right) + { + return Comparer.Default.Compare(left, right) <= 0; + } + + /// + /// Determines whether the value is lexicographically greater than the value. + /// + /// The first to compare. + /// The second to compare. + /// if is greater than ; otherwise, . + public static bool operator >(LastName? left, LastName? right) + { + return Comparer.Default.Compare(left, right) > 0; + } + + /// + /// Determines whether the value is lexicographically greater than or equal to the value. + /// + /// The first to compare. + /// The second to compare. + /// if is greater than or equal to ; otherwise, . + public static bool operator >=(LastName? left, LastName? right) + { + return Comparer.Default.Compare(left, right) >= 0; + } + + /// + /// Creates a from the provided string, enforcing normalization and validation rules. + /// + /// The input value. + /// A valid . + /// If is . + /// If the value is empty, exceeds , or contains invalid characters. + public static LastName Create(string lastName) + { + var result = TryCreateCore(lastName); + + if (result.LastName is null) + { + if (result.InvalidReason == InvalidReason.Null) + { + throw new ArgumentNullException(nameof(lastName)); + } + + if (result.InvalidReason == InvalidReason.TooLong) + { + throw new ArgumentException($"The last name cannot exceed more than {MaxLength} characters.", nameof(lastName)); + } + + if (result.InvalidReason == InvalidReason.Empty) + { + throw new ArgumentException($"The last name cannot be empty.", nameof(lastName)); + } + + throw new ArgumentException($"'{lastName}' is not a valid last name.", nameof(lastName)); + } + + return result.LastName; + } + + /// + /// Tries to create a from the provided value. + /// + /// The input value. + /// When this method returns, contains the created if successful; otherwise . + /// if creation succeeded; otherwise, . + public static bool TryCreate([NotNullWhen(true)] string? value, [MaybeNullWhen(false)][NotNullWhen(true)] out LastName? lastName) + { + var result = TryCreateCore(value); + + if (result.LastName is null) + { + lastName = null; + return false; + } + + lastName = result.LastName; + return true; + } + + /// + /// Determines whether the specified value is a valid last name according to the rules. + /// + /// The value to validate. + /// if valid; otherwise, . + public static bool IsValid(string lastName) + { + return TryCreate(lastName, out var _); + } + + /// + /// Parses a into a . + /// + /// The to parse. + /// A format provider (ignored). + /// The parsed . + /// If is . + /// + /// If the value is empty, exceeds , or contains invalid characters. + /// + static LastName IParsable.Parse(string s, IFormatProvider? provider) + { + return Create(s); + } + + /// + /// Tries to parse a into a . + /// + /// The to parse. + /// A format provider (ignored). + /// When this method returns, contains the parsed if successful; otherwise . + /// if parsing succeeded; otherwise, . + static bool IParsable.TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)][NotNullWhen(true)] out LastName result) + { + return TryCreate(s, out result); + } + + /// + /// Determines whether the specified object is equal to the current . + /// + /// The object to compare with the current instance. + /// if equal; otherwise, . + public override bool Equals(object? obj) + { + if (obj is not LastName lastName) + { + return false; + } + + return this.Equals(lastName); + } + + /// + /// Indicates whether the current object is equal to another . + /// + /// A to compare with this instance. + /// if equal; otherwise, . + public bool Equals(LastName? other) + { + if (other is null) + { + return false; + } + + return this.value.Equals(other.value, StringComparison.Ordinal); + } + + /// + /// Returns a hash code for this instance. + /// + /// A hash code for the current object. + public override int GetHashCode() + { + return this.value.GetHashCode(StringComparison.Ordinal); + } + + /// + /// Returns the normalized string representation of the last name. + /// + /// The normalized last name. + public override string ToString() + { + return this.value; + } + + /// + /// Formats the value of the current instance using the specified format. + /// + /// A format string (ignored). + /// A format provider (ignored). + /// The normalized last name. + string IFormattable.ToString(string? format, IFormatProvider? formatProvider) + { + return this.ToString(); + } + + /// + /// Returns an that iterates through the characters of the last name. + /// + /// An over the characters. + public IEnumerator GetEnumerator() + { + return this.value.GetEnumerator(); + } + + /// + /// Compares the current instance with another and returns an integer + /// that indicates whether the current instance precedes, follows, or occurs in the same position + /// in the sort order as the other object. + /// + /// The other to compare. + /// + /// A value less than zero if this instance precedes ; zero if they are equal; + /// greater than zero if this instance follows . + /// + public int CompareTo(LastName? other) + { + if (other is null) + { + return string.Compare(this.value, null, StringComparison.Ordinal); + } + + return string.Compare(this.value, other.value, StringComparison.Ordinal); + } + + /// + /// Returns an that iterates through the characters of the last name. + /// + /// An over the characters. + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + private static ParseResult TryCreateCore(string? value) + { + if (value is null) + { + return ParseResult.Invalid(InvalidReason.Null); + } + + var lastNameBuilder = new StringBuilder(value.Length); + + var alreadyHaveSeparator = true; + + for (var i = 0; i < value.Length; i++) + { + var letter = value[i]; + + if (char.IsLetter(letter)) + { + // It is a letter, add it as upper case. + lastNameBuilder.Append(char.ToUpper(letter, DefaultCulture)); + alreadyHaveSeparator = false; + } + else if (AllowedSeparators.Contains(letter)) + { + // Allowed character + if (!alreadyHaveSeparator) + { + // Add the separator and define the next letter to uppercase. + lastNameBuilder.Append(letter); + alreadyHaveSeparator = true; + } + else + { + // Ignore the separator (already have more than one). + } + } + else + { + // Invalid character + return ParseResult.Invalid(InvalidReason.InvalidCharacter); + } + } + + // If at the end we have a separator, remove it. + if (lastNameBuilder.Length > 0 && AllowedSeparators.Contains(lastNameBuilder[^1])) + { + lastNameBuilder.Remove(lastNameBuilder.Length - 1, 1); + } + + if (lastNameBuilder.Length == 0) + { + return ParseResult.Invalid(InvalidReason.Empty); + } + + if (lastNameBuilder.Length > MaxLength) + { + return ParseResult.Invalid(InvalidReason.TooLong); + } + + return ParseResult.Valid(new LastName(lastNameBuilder.ToString())); + } + + private readonly struct ParseResult + { + private ParseResult(LastName? lastName, InvalidReason? invalidReason) + { + this.LastName = lastName; + this.InvalidReason = invalidReason; + } + + public LastName? LastName { get; } + + public InvalidReason? InvalidReason { get; } + + public static ParseResult Invalid(InvalidReason invalidReason) + { + return new ParseResult(null, invalidReason); + } + + public static ParseResult Valid(LastName lastName) + { + return new ParseResult(lastName, null); + } + } + } +} diff --git a/src/People/NameNormalizer.cs b/src/People/NameNormalizer.cs new file mode 100644 index 0000000..a64c796 --- /dev/null +++ b/src/People/NameNormalizer.cs @@ -0,0 +1,48 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People +{ + /// + /// Provides helper methods to standardize how the name of a person is presented and referenced. + /// + public static class NameNormalizer + { + /// + /// Gets the display full name in the format "First-Name LASTNAME" (For example John DOE). + /// This full name convention allows to display the name of a person in UI. + /// + /// The of the person. + /// The of the person. + /// The normalized display full name. + /// If the specified argument is . + /// If the specified argument is . + public static string GetFullNameForDisplay(FirstName firstName, LastName lastName) + { + ArgumentNullException.ThrowIfNull(firstName, nameof(firstName)); + ArgumentNullException.ThrowIfNull(lastName, nameof(lastName)); + + return $"{firstName} {lastName}"; + } + + /// + /// Gets the ordering full name in the format "LASTNAME First-Name" (For example DOE John). + /// This full name convention allows to order a set of person by there last name first and the first name next. + /// + /// The person's first name. + /// The person's last name. + /// The normalized ordering full name. + /// If the specified argument is . + /// If the specified argument is . + public static string GetFullNameForOrder(FirstName firstName, LastName lastName) + { + ArgumentNullException.ThrowIfNull(firstName, nameof(firstName)); + ArgumentNullException.ThrowIfNull(lastName, nameof(lastName)); + + return $"{lastName} {firstName}"; + } + } +} diff --git a/src/People/People.csproj b/src/People/People.csproj new file mode 100644 index 0000000..78011ba --- /dev/null +++ b/src/People/People.csproj @@ -0,0 +1,28 @@ + + + + true + + + Provides strongly-typed FirstName and LastName value objects to standardize person names. + Enforces normalization rules (title-case for first names, uppercase for last names), validation, parsing, comparison, and formatting. + Includes IPerson abstraction with extension methods for display name, ordering name, and initials, plus name normalization helpers. + + people;person;firstname;lastname;name;valueobject;ddd;parsing;validation;normalization;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + diff --git a/src/People/PersonExtensions.cs b/src/People/PersonExtensions.cs new file mode 100644 index 0000000..869b98a --- /dev/null +++ b/src/People/PersonExtensions.cs @@ -0,0 +1,58 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People +{ + /// + /// Contains extensions methods for the . + /// + public static class PersonExtensions + { + /// + /// Gets the display full name in the format "First-Name LASTNAME" (For example John DOE). + /// This full name convention allows to display the name of a person in UI. + /// + /// to retrieve the full name for display. + /// The normalized display full name. + /// If the specified argument is . + public static string GetFullNameForDisplay(this IPerson person) + { + ArgumentNullException.ThrowIfNull(person, nameof(person)); + + return NameNormalizer.GetFullNameForDisplay(person.FirstName, person.LastName); + } + + /// + /// Gets the ordering full name in the format "LASTNAME First-Name" (For example DOE John) + /// of the specified . + /// This full name convention allows to order a set of person by there last name first and the first name next. + /// + /// to retrieve the full name for order. + /// The normalized ordering full name. + /// If the specified argument is . + public static string GetFullNameForOrder(this IPerson person) + { + ArgumentNullException.ThrowIfNull(person, nameof(person)); + + return NameNormalizer.GetFullNameForOrder(person.FirstName, person.LastName); + } + + /// + /// Gets the initials of the specified . + /// The initials are the first letter of the and the first letter + /// of the . + /// + /// The to retrieve the initials. + /// The initials of the . + /// If the specified argument is . + public static string GetInitials(this IPerson person) + { + ArgumentNullException.ThrowIfNull(person, nameof(person)); + + return $"{person.FirstName[0]}{person.LastName[0]}"; + } + } +} diff --git a/src/People/README.md b/src/People/README.md new file mode 100644 index 0000000..a0c96fb --- /dev/null +++ b/src/People/README.md @@ -0,0 +1,251 @@ +# PosInformatique.Foundations.People + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.People)](https://www.nuget.org/packages/PosInformatique.Foundations.People/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.People)](https://www.nuget.org/packages/PosInformatique.Foundations.People/) + +## Introduction +This package provides lightweight, strongly-typed value objects to standardize first names and last names across your applications: +- `FirstName`: normalized, properly cased first names. +- `LastName`: normalized, fully uppercased last names. + +It also includes: +- `IPerson`: a small interface to represent nominative people with FirstName and LastName. +- Extension methods on `IPerson` for consistent display/order names and initials. +- `NameNormalizer`: helper methods when you only have the separated first/last names. + +Typical use cases: domain entities (User, Customer, Employee, Contact), consistent UI display (Blazor components, lists), alphabetical ordering (directories), and avatar initials. + +## Install +You can install the package from NuGet: +```powershell +dotnet add package PosInformatique.Foundations.People +``` + +## Features +- Strongly-typed FirstName and LastName with validation and normalization +- Business rules: + - `FirstName`: title-cased words; letters only; separators: space, hyphen + - `LastName`: fully uppercased; letters only; separators: space, hyphen + - No consecutive/trailing separators; max length 50 +- Acts like read-only char arrays: indexer and Length +- Implements `IParsable`, `IFormattable`, `IComparable`, `IEquatable` +- Implicit conversions to/from string +- `IPerson` interface for nominative person abstraction +- `PersonExtensions` for display name, ordering name, and initials +- `NameNormalizer` utilities for first/last names without `IPerson` + +## Business rules + +### FirstName +- Max length: 50. +- Allowed characters: letters only; separators: ' ' and '-'. +- Normalization: + - Each word starts uppercase and continues lowercase (e.g., "John", "John Henri-Smith"). + - No consecutive separators, ths trailing separators are removed. + +### LastName +- Max length: 50. +- Allowed characters: letters only; separators: ' ' and '-'. +- Normalization: + - Entire value uppercased (e.g., "DOE", "SMITH-JOHNSON"). + - No consecutive separators, the trailing separators are removed. + +### FirstName examples + +#### Create / implicit conversion +```csharp +// Implicit conversion validates and normalizes: +FirstName firstName = "john henri-smith"; // -> "John Henri-Smith" + +// Explicit create: +var firstName2 = FirstName.Create(" alice--marie "); // -> "Alice-Marie" + +// IsValid +var ok = FirstName.IsValid("Élodie"); // true +var notOk = FirstName.IsValid("John_123"); // false +``` + +#### Parse / TryParse +```csharp +// Parse (throws on invalid) +var firstName = FirstName.Parse("béAtrice", provider: null); // "Béatrice" + +// TryParse (no exceptions) +if (FirstName.TryParse("jeAn - pierre", provider: null, out var firstName)) +{ + Console.WriteLine(firstName); // "Jean-Pierre" +} +``` + +#### Length and indexer +```csharp +var firstName = FirstName.Create("Jean-Pierre"); +var len = firstName.Length; // 11 +var firstLetter = firstName[0]; // 'J' +var dash = firstName[4]; // '-' + +// Enumerate characters +foreach (var c in firstName) { /* ... */ } +``` + +#### Comparisons and equality +```csharp +var a = FirstName.Create("Alice"); +var b = FirstName.Create("ALICE"); // normalized: "Alice" + +Console.WriteLine(a == b); // true +Console.WriteLine(a != b); // false +Console.WriteLine(a <= b); // true +Console.WriteLine(a.CompareTo(b)); // 0 + +var list = new List { FirstName.Create("Zoé"), FirstName.Create("Ana") }; +list.Sort(); // alphabetical by normalized value +``` + +### LastName examples + +#### Create / implicit conversion +```csharp +LastName lastName = "dupond durand"; // -> "DUPOND DURAND" +var lastName2 = LastName.Create("le--gall"); // -> "LE GALL" +var ok = LastName.IsValid("O'Connor"); // false (apostrophe not allowed) +``` + +#### Parse / TryParse +```csharp +var parsed = LastName.Parse("martin", provider: null); // "MARTIN" + +if (LastName.TryParse("van - damme", provider: null, out var lastName)) +{ + Console.WriteLine(lastName); // "VAN DAMME" +} +``` + +#### Length and indexer +```csharp +var lastName = LastName.Create("LE GALL"); +var len = lastName.Length; // 7 +var firstLetter = lastName[0]; // 'L' +var space = lastName[2]; // ' ' + +// Enumerate characters +foreach (var c in lastName) { /* ... */ } +``` + +#### Comparisons and equality +```csharp +var a = LastName.Create("DURAND"); +var b = LastName.Create("durand"); // normalized to "DURAND" + +Console.WriteLine(a == b); // true +Console.WriteLine(a < LastName.Create("MARTIN")); // true + +var list = new List { LastName.Create("ZOLA"), LastName.Create("ABEL") }; +list.Sort(); // alphabetical by normalized value +``` + +### IPerson +`IPerson` represents a nominative person with a normalized `FirstName` and `LastName`. Implement this in domain types like User, Customer, Employee, Contact. + +```csharp +public sealed class User : IPerson +{ + public User(FirstName firstName, LastName lastName) + { + FirstName = firstName; + LastName = lastName; + } + + public FirstName FirstName { get; } + public LastName LastName { get; } +} +``` + +You can also accept string and normalize: +```csharp +public sealed class Customer : IPerson +{ + public Customer(string firstName, string lastName) + { + // Implicit conversions validate and normalize + FirstName = firstName; + LastName = lastName; + } + + public FirstName FirstName { get; } + public LastName LastName { get; } +} +``` + +### PersonExtensions +Utilities for consistent display, ordering, and initials on any IPerson. + +- `GetFullNameForDisplay()`: "First-Name LASTNAME" (e.g., "John DOE"). Use in UI display (e.g., Blazor component). +- `GetFullNameForOrder()`: "LASTNAME First-Name" (e.g., "DOE John"). Use for alphabetical directories by last name. +- `GetInitials()`: first letter of FirstName + first letter of LastName (e.g., "JD"). Use as fallback when avatar image is not available. + +Examples: +```csharp +var user = new User("jean-paul", "dupont"); + +var display = user.GetFullNameForDisplay(); // "Jean-Paul DUPONT" +var order = user.GetFullNameForOrder(); // "DUPONT Jean-Paul" +var initials = user.GetInitials(); // "JD" +``` + +Blazor example: +```csharp +@code { + [Parameter] public IPerson Person { get; set; } = default!; +} +

@Person.GetFullNameForDisplay()

+@Person.GetInitials() +``` + +Sorting by order name: +```csharp +var people = new List +{ + new User("alice", "martin"), + new User("bob", "durand"), + new User("Élodie", "zola") +}; + +var ordered = people + .OrderBy(p => p.GetFullNameForOrder(), StringComparer.Ordinal) + .ToList(); +// "DURAND Bob", "MARTIN Alice", "ZOLA Élodie" +``` + +### NameNormalizer +When you only have separate `FirstName` and `LastName` (not an `IPerson`), use `NameNormalizer`. + +- `GetFullNameForDisplay(firstName, lastName)` => "First-Name LASTNAME" +- `GetFullNameForOrder(firstName, lastName)` => "LASTNAME First-Name" + +Examples: +```csharp +var firstName = FirstName.Create("marie-claire"); +var lastName = LastName.Create("le gall"); + +var display = NameNormalizer.GetFullNameForDisplay(firstName, lastName); // "Marie-Claire LE GALL" +var order = NameNormalizer.GetFullNameForOrder(firstName, lastName); // "LE GALL Marie-Claire" +``` + +### Error handling tips +- Use `TryParse` / `TryCreate` when you want to avoid exceptions: +```csharp +if (FirstName.TryParse(inputFirstName, null, out var firstName) && + LastName.TryParse(inputLastName, null, out var lastName)) +{ + var user = new User(firstName, lastName); +} +else +{ + // handle invalid inputs +} +``` + +## Links +- [NuGet package: People (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.People/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) diff --git a/tests/People.Tests/FirstNameTest.cs b/tests/People.Tests/FirstNameTest.cs new file mode 100644 index 0000000..fed01a5 --- /dev/null +++ b/tests/People.Tests/FirstNameTest.cs @@ -0,0 +1,484 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People.Tests +{ + using System.Collections; + + public class FirstNameTest + { + [Fact] + public void AllowedSeparators() + { + FirstName.AllowedSeparators.Should().Equal([' ', '-']); + } + + [Theory] + [MemberData(nameof(NameTestData.ValidFirstNames), MemberType = typeof(NameTestData))] + public void Indexer(string firstName, string expectedFirstName) + { + FirstName.Create(firstName)[0].Should().Be(expectedFirstName[0]); + } + + [Theory] + [MemberData(nameof(NameTestData.ValidFirstNames), MemberType = typeof(NameTestData))] + public void Length(string firstName, string expectedFirstName) + { + FirstName.Create(firstName).Length.Should().Be(expectedFirstName.Length); + FirstName.Create(firstName).As>().Count.Should().Be(expectedFirstName.Length); + } + + [Theory] + [MemberData(nameof(NameTestData.ValidFirstNames), MemberType = typeof(NameTestData))] + public void Create_Valid(string firstName, string expectedFirstName) + { + var result = FirstName.Create(firstName); + + result.ToString().Should().Be(expectedFirstName); + result.As().ToString(null, null).Should().Be(expectedFirstName); + } + + [Fact] + public void Create_Null() + { + var act = () => + { + FirstName.Create(null); + }; + + act.Should().Throw() + .WithParameterName("firstName"); + } + + [Theory] + [InlineData(" jean$!patrick ")] + [InlineData(" jean patrick Jr. ")] + [InlineData(" $! ")] + public void Create_Invalid(string firstName) + { + var act = () => + { + FirstName.Create(firstName); + }; + + act.Should().Throw() + .WithMessage($"'{firstName}' is not a valid first name. (Parameter 'firstName')") + .WithParameterName("firstName"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(" ")] + public void Create_Empty(string firstName) + { + var act = () => + { + FirstName.Create(firstName); + }; + + act.Should().Throw() + .WithMessage($"The first name cannot be empty. (Parameter 'firstName')") + .WithParameterName("firstName"); + } + + [Fact] + public void Create_ExceedMaxLength() + { + var act = () => + { + FirstName.Create(string.Concat(Enumerable.Repeat("A", 51))); + }; + + act.Should().Throw() + .WithMessage($"The first name cannot exceed more than 50 characters. (Parameter 'firstName')") + .WithParameterName("firstName"); + } + + [Theory] + [MemberData(nameof(NameTestData.ValidFirstNames), MemberType = typeof(NameTestData))] + public void Parse_Valid(string firstName, string expectedFirstName) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var result = CallParse(firstName, formatProvider); + + result.ToString().Should().Be(expectedFirstName); + result.As().ToString(null, null).Should().Be(expectedFirstName); + } + + [Fact] + public void Parse_Null() + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var act = () => + { + CallParse(null, formatProvider); + }; + + act.Should().Throw() + .WithParameterName("firstName"); + } + + [Theory] + [InlineData(" jean$!patrick ")] + [InlineData(" jean patrick Jr. ")] + [InlineData(" $! ")] + public void Parse_Invalid(string firstName) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var act = () => + { + CallParse(firstName, formatProvider); + }; + + act.Should().Throw() + .WithMessage($"'{firstName}' is not a valid first name. (Parameter 'firstName')") + .WithParameterName("firstName"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(" ")] + public void Parse_Empty(string firstName) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var act = () => + { + CallParse(firstName, formatProvider); + }; + + act.Should().Throw() + .WithMessage($"The first name cannot be empty. (Parameter 'firstName')") + .WithParameterName("firstName"); + } + + [Fact] + public void Parse_ExceedMaxLength() + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var act = () => + { + CallParse(string.Concat(Enumerable.Repeat("A", 51)), formatProvider); + }; + + act.Should().Throw() + .WithMessage($"The first name cannot exceed more than 50 characters. (Parameter 'firstName')") + .WithParameterName("firstName"); + } + + [Theory] + [MemberData(nameof(NameTestData.ValidFirstNames), MemberType = typeof(NameTestData))] + public void TryCreate_Valid(string firstName, string expectedFirstName) + { + FirstName.TryCreate(firstName, out var result).Should().BeTrue(); + + result.ToString().Should().Be(expectedFirstName); + result.As().ToString(null, null).Should().Be(expectedFirstName); + } + + [Theory] + [MemberData(nameof(NameTestData.InvalidFirstNames), MemberType = typeof(NameTestData))] + public void TryCreate_Invalid(string firstName) + { + FirstName.TryCreate(firstName, out var result).Should().BeFalse(); + + result.As().Should().BeNull(); + } + + [Theory] + [MemberData(nameof(NameTestData.ValidFirstNames), MemberType = typeof(NameTestData))] + public void TryParse_Valid(string firstName, string expectedFirstName) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + CallTryParse(firstName, formatProvider, out var result).Should().BeTrue(); + + result.ToString().Should().Be(expectedFirstName); + result.As().ToString(null, null).Should().Be(expectedFirstName); + } + + [Theory] + [MemberData(nameof(NameTestData.InvalidFirstNames), MemberType = typeof(NameTestData))] + public void TryParse_Invalid(string firstName) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + CallTryParse(firstName, formatProvider, out var result).Should().BeFalse(); + + result.As().Should().BeNull(); + } + + [Theory] + [MemberData(nameof(NameTestData.ValidFirstNames), MemberType = typeof(NameTestData))] +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + public void IsValid_Valid(string firstName, string _) +#pragma warning restore SA1313 // Parameter names should begin with lower-case letter + { + FirstName.IsValid(firstName).Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(NameTestData.InvalidFirstNames), MemberType = typeof(NameTestData))] + public void IsValid_Invalid(string firstName) + { + FirstName.IsValid(firstName).Should().BeFalse(); + } + + [Fact] + public void GetEnumerator() + { + var firstName = FirstName.Create("Jean"); + + var enumerator = firstName.GetEnumerator(); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('J'); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('e'); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('a'); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('n'); + + enumerator.MoveNext().Should().BeFalse(); + enumerator.MoveNext().Should().BeFalse(); + } + + [Fact] + public void GetEnumerator_NonGeneric() + { + var firstName = FirstName.Create("Jean"); + + var enumerator = firstName.As().GetEnumerator(); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('J'); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('e'); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('a'); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('n'); + } + + [Theory] + [InlineData("Jean", "Jean", true)] + [InlineData("Jean", "Other", false)] + public void Equals_Typed(string firstName1, string firstName2, bool result) + { + var f1 = FirstName.Create(firstName1); + var f2 = FirstName.Create(firstName2); + + f1.Equals(f2).Should().Be(result); + } + + [Fact] + public void Equals_Typed_Null() + { + FirstName.Create("Jean").Equals(null).Should().BeFalse(); + } + + [Theory] + [InlineData("Jean", "Jean", true)] + [InlineData("Jean", "Other", false)] + public void Equals_Object(string firstName1, string firstName2, bool result) + { + var f1 = FirstName.Create(firstName1); + var f2 = FirstName.Create(firstName2); + + f1.Equals((object)f2).Should().Be(result); + } + + [Fact] + public void Equals_Object_Null() + { + FirstName.Create("Jean").Equals((object)null).Should().BeFalse(); + } + + [Theory] + [InlineData("Jean", "Jean", true)] + [InlineData("Jean", "Other", false)] + public void Equals_Operator(string firstName1, string firstName2, bool result) + { + var f1 = FirstName.Create(firstName1); + var f2 = FirstName.Create(firstName2); + + (f1 == f2).Should().Be(result); + } + + [Theory] + [InlineData("Jean", "Jean", false)] + [InlineData("Jean", "Other", true)] + public void NotEquals_Operator(string firstName1, string firstName2, bool result) + { + var f1 = FirstName.Create(firstName1); + var f2 = FirstName.Create(firstName2); + + (f1 != f2).Should().Be(result); + } + + [Fact] + public void GetHashCode_Test() + { + FirstName.Create("Jean").GetHashCode().Should().Be("Jean".GetHashCode(StringComparison.Ordinal)); + FirstName.Create("Jean").GetHashCode().Should().NotBe("Autre".GetHashCode(StringComparison.Ordinal)); + } + + [Fact] + public void ImplicitOperator_FirstNameToString() + { + var firstName = FirstName.Create("The first name"); + + string stringValue = firstName; + + stringValue.Should().Be("The First Name"); + } + + [Fact] + public void ImplicitOperator_FirstNameToString_WithNullArgument() + { + FirstName firstName = null; + + var act = () => + { + string _ = firstName; + }; + + act.Should() + .ThrowExactly() + .WithParameterName("firstName"); + } + + [Fact] + public void ImplicitOperator_StringToFirstName() + { + FirstName firstName = "The first name"; + + firstName.ToString().Should().Be("The First Name"); + firstName.As().ToString(null, null).Should().Be("The First Name"); + } + + [Fact] + public void ImplicitOperator_StringToFirstName_WithNullArgument() + { + string firstName = null; + + var act = () => + { + FirstName _ = firstName; + }; + + act.Should() + .ThrowExactly() + .WithParameterName("firstName"); + } + + [Fact] + public void CompareTo() + { + FirstName.Create("First name A").CompareTo(FirstName.Create("First name B")).Should().BeLessThan(0); + FirstName.Create("First name B").CompareTo(FirstName.Create("First name A")).Should().BeGreaterThan(0); + + FirstName.Create("First name A").CompareTo(FirstName.Create("First name A")).Should().Be(0); + + FirstName.Create("First name A").CompareTo(FirstName.Create("First nâme A")).Should().BeLessThan(0); + FirstName.Create("First nâme A").CompareTo(FirstName.Create("First name A")).Should().BeGreaterThan(0); + + FirstName.Create("First name B").CompareTo(FirstName.Create("First nâme A")).Should().BeLessThan(0); + FirstName.Create("First nâme A").CompareTo(FirstName.Create("First name B")).Should().BeGreaterThan(0); + + FirstName.Create("First name A").CompareTo(null).Should().BeGreaterThan(0); + } + + [Theory] + [InlineData("First name A", "First name B", true)] + [InlineData("First name B", "First name A", false)] + [InlineData("First name A", "First name A", false)] + [InlineData("First name A", "First nâme A", true)] + [InlineData("First nâme A", "First name A", false)] + [InlineData("First name B", "First nâme A", true)] + [InlineData("First nâme B", "First name A", false)] + [InlineData(null, "First name A", true)] + [InlineData("First name A", null, false)] + [InlineData(null, null, false)] + public void Operator_LessThan(string firstName1, string firstName2, bool result) + { + ((firstName1 is not null ? FirstName.Create(firstName1) : null) < (firstName2 is not null ? FirstName.Create(firstName2) : null)).Should().Be(result); + } + + [Theory] + [InlineData("First name A", "First name B", true)] + [InlineData("First name B", "First name A", false)] + [InlineData("First name A", "First name A", true)] + [InlineData("First name A", "First nâme A", true)] + [InlineData("First nâme A", "First name A", false)] + [InlineData("First name B", "First nâme A", true)] + [InlineData("First nâme B", "First name A", false)] + [InlineData(null, "First name A", true)] + [InlineData("First name A", null, false)] + [InlineData(null, null, true)] + public void Operator_LessThanOrEqual(string firstName1, string firstName2, bool result) + { + ((firstName1 is not null ? FirstName.Create(firstName1) : null) <= (firstName2 is not null ? FirstName.Create(firstName2) : null)).Should().Be(result); + } + + [Theory] + [InlineData("First name A", "First name B", false)] + [InlineData("First name B", "First name A", true)] + [InlineData("First name A", "First name A", false)] + [InlineData("First name A", "First nâme A", false)] + [InlineData("First nâme A", "First name A", true)] + [InlineData("First name B", "First nâme A", false)] + [InlineData("First nâme B", "First name A", true)] + [InlineData(null, "First name A", false)] + [InlineData("First name A", null, true)] + [InlineData(null, null, false)] + public void Operator_GreaterThan(string firstName1, string firstName2, bool result) + { + ((firstName1 is not null ? FirstName.Create(firstName1) : null) > (firstName2 is not null ? FirstName.Create(firstName2) : null)).Should().Be(result); + } + + [Theory] + [InlineData("First name A", "First name B", false)] + [InlineData("First name B", "First name A", true)] + [InlineData("First name A", "First name A", true)] + [InlineData("First name A", "First nâme A", false)] + [InlineData("First nâme A", "First name A", true)] + [InlineData("First name B", "First nâme A", false)] + [InlineData("First nâme B", "First name A", true)] + [InlineData(null, "First name A", false)] + [InlineData("First name A", null, true)] + [InlineData(null, null, true)] + public void Operator_GreaterThanOrEqual(string firstName1, string firstName2, bool result) + { + ((firstName1 is not null ? FirstName.Create(firstName1) : null) >= (firstName2 is not null ? FirstName.Create(firstName2) : null)).Should().Be(result); + } + + private static T CallParse(string s, IFormatProvider formatProvider) + where T : IParsable + { + return T.Parse(s, formatProvider); + } + + private static bool CallTryParse(string s, IFormatProvider formatProvider, out T result) + where T : IParsable + { + return T.TryParse(s, formatProvider, out result); + } + } +} diff --git a/tests/People.Tests/LastNameTest.cs b/tests/People.Tests/LastNameTest.cs new file mode 100644 index 0000000..cd7e674 --- /dev/null +++ b/tests/People.Tests/LastNameTest.cs @@ -0,0 +1,493 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People.Tests +{ + using System.Collections; + + public class LastNameTest + { + [Fact] + public void AllowedSeparators() + { + LastName.AllowedSeparators.Should().Equal([' ', '-']); + } + + [Theory] + [MemberData(nameof(NameTestData.ValidLastNames), MemberType = typeof(NameTestData))] + public void Indexer(string lastName, string expectedLastName) + { + LastName.Create(lastName)[0].Should().Be(expectedLastName[0]); + } + + [Theory] + [MemberData(nameof(NameTestData.ValidLastNames), MemberType = typeof(NameTestData))] + public void Length(string lastName, string expectedLastName) + { + LastName.Create(lastName).Length.Should().Be(expectedLastName.Length); + LastName.Create(lastName).As>().Count.Should().Be(expectedLastName.Length); + } + + [Theory] + [MemberData(nameof(NameTestData.ValidLastNames), MemberType = typeof(NameTestData))] + public void Create_Valid(string lastName, string expectedLastName) + { + var result = LastName.Create(lastName); + + result.ToString().Should().Be(expectedLastName); + result.As().ToString(null, null).Should().Be(expectedLastName); + } + + [Fact] + public void Create_Null() + { + var act = () => + { + LastName.Create(null); + }; + + act.Should().Throw() + .WithParameterName("lastName"); + } + + [Theory] + [InlineData("$$Dupont")] + [InlineData("Du@$+Pont")] + [InlineData("Du-pont.")] + public void Create_Invalid(string lastName) + { + var act = () => + { + LastName.Create(lastName); + }; + + act.Should().Throw() + .WithMessage($"'{lastName}' is not a valid last name. (Parameter 'lastName')") + .WithParameterName("lastName"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(" ")] + public void Create_Empty(string lastName) + { + var act = () => + { + LastName.Create(lastName); + }; + + act.Should().Throw() + .WithMessage($"The last name cannot be empty. (Parameter 'lastName')") + .WithParameterName("lastName"); + } + + [Fact] + public void Create_ExceedMaxLength() + { + var act = () => + { + LastName.Create(string.Concat(Enumerable.Repeat("A", 51))); + }; + + act.Should().Throw() + .WithMessage($"The last name cannot exceed more than 50 characters. (Parameter 'lastName')") + .WithParameterName("lastName"); + } + + [Theory] + [MemberData(nameof(NameTestData.ValidLastNames), MemberType = typeof(NameTestData))] + public void Parse_Valid(string lastName, string expectedLastName) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var result = CallParse(lastName, formatProvider); + + result.ToString().Should().Be(expectedLastName); + result.As().ToString(null, null).Should().Be(expectedLastName); + } + + [Fact] + public void Parse_Null() + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var act = () => + { + CallParse(null, formatProvider); + }; + + act.Should().Throw() + .WithParameterName("lastName"); + } + + [Theory] + [InlineData("$$Dupont")] + [InlineData("Du@$+Pont")] + [InlineData("Du-pont.")] + public void Parse_Invalid(string lastName) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var act = () => + { + CallParse(lastName, formatProvider); + }; + + act.Should().Throw() + .WithMessage($"'{lastName}' is not a valid last name. (Parameter 'lastName')") + .WithParameterName("lastName"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(" ")] + public void Parse_Empty(string lastName) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var act = () => + { + CallParse(lastName, formatProvider); + }; + + act.Should().Throw() + .WithMessage($"The last name cannot be empty. (Parameter 'lastName')") + .WithParameterName("lastName"); + } + + [Fact] + public void Parse_ExceedMaxLength() + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var act = () => + { + CallParse(string.Concat(Enumerable.Repeat("A", 51)), formatProvider); + }; + + act.Should().Throw() + .WithMessage($"The last name cannot exceed more than 50 characters. (Parameter 'lastName')") + .WithParameterName("lastName"); + } + + [Theory] + [MemberData(nameof(NameTestData.ValidLastNames), MemberType = typeof(NameTestData))] + public void TryCreate_Valid(string lastName, string expectedLastName) + { + LastName.TryCreate(lastName, out var result).Should().BeTrue(); + + result.ToString().Should().Be(expectedLastName); + result.As().ToString(null, null).Should().Be(expectedLastName); + } + + [Theory] + [MemberData(nameof(NameTestData.InvalidLastNames), MemberType = typeof(NameTestData))] + public void TryCreate_Invalid(string lastName) + { + LastName.TryCreate(lastName, out var result).Should().BeFalse(); + + result.As().Should().BeNull(); + } + + [Theory] + [MemberData(nameof(NameTestData.ValidLastNames), MemberType = typeof(NameTestData))] + public void TryParse_Valid(string lastName, string expectedLastName) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + CallTryParse(lastName, formatProvider, out var result).Should().BeTrue(); + + result.ToString().Should().Be(expectedLastName); + result.As().ToString(null, null).Should().Be(expectedLastName); + } + + [Theory] + [MemberData(nameof(NameTestData.InvalidFirstNames), MemberType = typeof(NameTestData))] + public void TryParse_Invalid(string lastName) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + CallTryParse(lastName, formatProvider, out var result).Should().BeFalse(); + + result.As().Should().BeNull(); + } + + [Theory] + [MemberData(nameof(NameTestData.ValidLastNames), MemberType = typeof(NameTestData))] +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + public void IsValid_Valid(string lastName, string _) +#pragma warning restore SA1313 // Parameter names should begin with lower-case letter + { + LastName.IsValid(lastName).Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(NameTestData.InvalidLastNames), MemberType = typeof(NameTestData))] + public void IsValid_Invalid(string lastName) + { + LastName.IsValid(lastName).Should().BeFalse(); + } + + [Fact] + public void GetEnumerator() + { + var lastName = LastName.Create("DUPONT"); + + var enumerator = lastName.GetEnumerator(); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('D'); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('U'); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('P'); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('O'); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('N'); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('T'); + } + + [Fact] + public void GetEnumerator_NonGeneric() + { + var lastName = LastName.Create("DUPONT"); + + var enumerator = lastName.As().GetEnumerator(); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('D'); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('U'); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('P'); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('O'); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('N'); + + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Should().Be('T'); + } + + [Theory] + [InlineData("DUPONT", "DUPONT", true)] + [InlineData("DUPONT", "OTHER", false)] + public void Equals_Typed(string lastName1, string lastName2, bool result) + { + var f1 = LastName.Create(lastName1); + var f2 = LastName.Create(lastName2); + + f1.Equals(f2).Should().Be(result); + } + + [Fact] + public void Equals_Typed_Null() + { + LastName.Create("Jean").Equals(null).Should().BeFalse(); + } + + [Theory] + [InlineData("DUPONT", "DUPONT", true)] + [InlineData("DUPONT", "OTHER", false)] + public void Equals_Object(string lastName1, string lastName2, bool result) + { + var f1 = LastName.Create(lastName1); + var f2 = LastName.Create(lastName2); + + f1.Equals((object)f2).Should().Be(result); + } + + [Fact] + public void Equals_Object_Null() + { + LastName.Create("Jean").Equals((object)null).Should().BeFalse(); + } + + [Theory] + [InlineData("DUPONT", "DUPONT", true)] + [InlineData("DUPONT", "OTHER", false)] + public void Equals_Operator(string lastName1, string lastName2, bool result) + { + var f1 = LastName.Create(lastName1); + var f2 = LastName.Create(lastName2); + + (f1 == f2).Should().Be(result); + } + + [Theory] + [InlineData("DUPONT", "DUPONT", false)] + [InlineData("DUPONT", "OTHER", true)] + public void NotEquals_Operator(string lastName1, string lastName2, bool result) + { + var f1 = LastName.Create(lastName1); + var f2 = LastName.Create(lastName2); + + (f1 != f2).Should().Be(result); + } + + [Fact] + public void GetHashCode_Test() + { + LastName.Create("DUPONT").GetHashCode().Should().Be("DUPONT".GetHashCode(StringComparison.Ordinal)); + LastName.Create("DUPONT").GetHashCode().Should().NotBe("Other".GetHashCode(StringComparison.Ordinal)); + } + + [Fact] + public void ImplicitOperator_LastNameToString() + { + var lastName = LastName.Create("The last name"); + + string stringValue = lastName; + + stringValue.Should().Be("THE LAST NAME"); + } + + [Fact] + public void ImplicitOperator_LastNameToString_WithNullArgument() + { + LastName lastName = null; + + var act = () => + { + string _ = lastName; + }; + + act.Should() + .ThrowExactly() + .WithParameterName("lastName"); + } + + [Fact] + public void ImplicitOperator_StringToLastName() + { + LastName lastName = "The last name"; + + lastName.ToString().Should().Be("THE LAST NAME"); + lastName.As().ToString(null, null).Should().Be("THE LAST NAME"); + } + + [Fact] + public void ImplicitOperator_StringToLastName_WithNullArgument() + { + string lastName = null; + + var act = () => + { + LastName _ = lastName; + }; + + act.Should() + .ThrowExactly() + .WithParameterName("lastName"); + } + + [Fact] + public void CompareTo() + { + LastName.Create("Last name A").CompareTo(LastName.Create("Last name B")).Should().BeLessThan(0); + LastName.Create("Last name B").CompareTo(LastName.Create("Last name A")).Should().BeGreaterThan(0); + + LastName.Create("Last name A").CompareTo(LastName.Create("Last name A")).Should().Be(0); + + LastName.Create("Last name A").CompareTo(LastName.Create("Last nâme A")).Should().BeLessThan(0); + LastName.Create("Last nâme A").CompareTo(LastName.Create("Last name A")).Should().BeGreaterThan(0); + + LastName.Create("Last name B").CompareTo(LastName.Create("Last nâme A")).Should().BeLessThan(0); + LastName.Create("Last nâme A").CompareTo(LastName.Create("Last name B")).Should().BeGreaterThan(0); + + LastName.Create("Last name A").CompareTo(null).Should().BeGreaterThan(0); + } + + [Theory] + [InlineData("Last name A", "Last name B", true)] + [InlineData("Last name B", "Last name A", false)] + [InlineData("Last name A", "Last name A", false)] + [InlineData("Last name A", "Last nâme A", true)] + [InlineData("Last nâme A", "Last name A", false)] + [InlineData("Last name B", "Last nâme A", true)] + [InlineData("Last nâme B", "Last name A", false)] + [InlineData(null, "Last name", true)] + [InlineData("Last name", null, false)] + [InlineData(null, null, false)] + public void Operator_LessThan(string lastName1, string lastName2, bool result) + { + ((lastName1 is not null ? LastName.Create(lastName1) : null) < (lastName2 is not null ? LastName.Create(lastName2) : null)).Should().Be(result); + } + + [Theory] + [InlineData("Last name A", "Last name B", true)] + [InlineData("Last name B", "Last name A", false)] + [InlineData("Last name A", "Last name A", true)] + [InlineData("Last name A", "Last nâme A", true)] + [InlineData("Last nâme A", "Last name A", false)] + [InlineData("Last name B", "Last nâme A", true)] + [InlineData("Last nâme B", "Last name A", false)] + [InlineData(null, "Last name", true)] + [InlineData("Last name", null, false)] + [InlineData(null, null, true)] + public void Operator_LessThanOrEqual(string lastName1, string lastName2, bool result) + { + ((lastName1 is not null ? LastName.Create(lastName1) : null) <= (lastName2 is not null ? LastName.Create(lastName2) : null)).Should().Be(result); + } + + [Theory] + [InlineData("Last name A", "Last name B", false)] + [InlineData("Last name B", "Last name A", true)] + [InlineData("Last name A", "Last name A", false)] + [InlineData("Last name A", "Last nâme A", false)] + [InlineData("Last nâme A", "Last name A", true)] + [InlineData("Last name B", "Last nâme A", false)] + [InlineData("Last nâme B", "Last name A", true)] + [InlineData(null, "Last name", false)] + [InlineData("Last name", null, true)] + [InlineData(null, null, false)] + public void Operator_GreaterThan(string lastName1, string lastName2, bool result) + { + ((lastName1 is not null ? LastName.Create(lastName1) : null) > (lastName2 is not null ? LastName.Create(lastName2) : null)).Should().Be(result); + } + + [Theory] + [InlineData("Last name A", "Last name B", false)] + [InlineData("Last name B", "Last name A", true)] + [InlineData("Last name A", "Last name A", true)] + [InlineData("Last name A", "Last nâme A", false)] + [InlineData("Last nâme A", "Last name A", true)] + [InlineData("Last name B", "Last nâme A", false)] + [InlineData("Last nâme B", "Last name A", true)] + [InlineData(null, "Last name", false)] + [InlineData("Last name", null, true)] + [InlineData(null, null, true)] + public void Operator_GreaterThanOrEqual(string lastName1, string lastName2, bool result) + { + ((lastName1 is not null ? LastName.Create(lastName1) : null) >= (lastName2 is not null ? LastName.Create(lastName2) : null)).Should().Be(result); + } + + private static T CallParse(string s, IFormatProvider formatProvider) + where T : IParsable + { + return T.Parse(s, formatProvider); + } + + private static bool CallTryParse(string s, IFormatProvider formatProvider, out T result) + where T : IParsable + { + return T.TryParse(s, formatProvider, out result); + } + } +} diff --git a/tests/People.Tests/NameNormalizerTest.cs b/tests/People.Tests/NameNormalizerTest.cs new file mode 100644 index 0000000..faab7bb --- /dev/null +++ b/tests/People.Tests/NameNormalizerTest.cs @@ -0,0 +1,71 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People.Tests +{ + public class NameNormalizerTest + { + [Fact] + public void GetFullNameForDisplay() + { + NameNormalizer.GetFullNameForDisplay("The first name", "The last name").Should().Be("The First Name THE LAST NAME"); + } + + [Fact] + public void GetFullNameForDisplay_WithFirstNameNullArgument() + { + var act = () => + { + NameNormalizer.GetFullNameForDisplay(null, default); + }; + + act.Should().ThrowExactly() + .WithParameterName("firstName"); + } + + [Fact] + public void GetFullNameForDisplay_WithLastNameNullArgument() + { + var act = () => + { + NameNormalizer.GetFullNameForDisplay("The first name", null); + }; + + act.Should().ThrowExactly() + .WithParameterName("lastName"); + } + + [Fact] + public void GetFullNameForOrder() + { + NameNormalizer.GetFullNameForOrder("The first name", "The last name").Should().Be("THE LAST NAME The First Name"); + } + + [Fact] + public void GetFullNameForOrder_WithFirstNameNullArgument() + { + var act = () => + { + NameNormalizer.GetFullNameForOrder(null, default); + }; + + act.Should().ThrowExactly() + .WithParameterName("firstName"); + } + + [Fact] + public void GetFullNameForOrder_WithLastNameNullArgument() + { + var act = () => + { + NameNormalizer.GetFullNameForOrder("The first name", null); + }; + + act.Should().ThrowExactly() + .WithParameterName("lastName"); + } + } +} diff --git a/tests/People.Tests/NameTestData.cs b/tests/People.Tests/NameTestData.cs new file mode 100644 index 0000000..df9918d --- /dev/null +++ b/tests/People.Tests/NameTestData.cs @@ -0,0 +1,56 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique +{ + public static class NameTestData + { + public static TheoryData ValidFirstNames { get; } = new() + { + { "Jean", "Jean" }, + { "JEAN", "Jean" }, + { " jean ", "Jean" }, + { " jean-patrick ", "Jean-Patrick" }, + { " jean- patrick ", "Jean-Patrick" }, + { " jean- -patrick ", "Jean-Patrick" }, + { " jean- -patrick ", "Jean-Patrick" }, + { " émile ", "Émile" }, + { " jEAN-éMILE ", "Jean-Émile" }, + }; + + public static TheoryData InvalidFirstNames { get; } = new() + { + null, + "$$Jean", + "Jean@$+Patrick", + "Jean-Patrick.", + string.Empty, + " ", + " ", + }; + + public static TheoryData ValidLastNames { get; } = new() + { + { "dupont", "DUPONT" }, + { "DUPONT", "DUPONT" }, + { " Dupont ", "DUPONT" }, + { " Du pont ", "DU PONT" }, + { " Du-pont ", "DU-PONT" }, + { " émile ", "ÉMILE" }, + { " Du pont ", "DU PONT" }, + }; + + public static TheoryData InvalidLastNames { get; } = new() + { + null, + "$$Dupont", + "Du@$+Pont", + "Du-pont.", + string.Empty, + " ", + }; + } +} diff --git a/tests/People.Tests/People.Tests.csproj b/tests/People.Tests/People.Tests.csproj new file mode 100644 index 0000000..fe5f55a --- /dev/null +++ b/tests/People.Tests/People.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/tests/People.Tests/PersonExtensionsTest.cs b/tests/People.Tests/PersonExtensionsTest.cs new file mode 100644 index 0000000..6a2f7aa --- /dev/null +++ b/tests/People.Tests/PersonExtensionsTest.cs @@ -0,0 +1,89 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People.Tests +{ + public class PersonExtensionsTest + { + [Fact] + public void GetFullNameForDisplay() + { + var person = new Mock(MockBehavior.Strict); + person.Setup(p => p.FirstName) + .Returns("The first name"); + person.Setup(p => p.LastName) + .Returns("The last name"); + + PersonExtensions.GetFullNameForDisplay(person.Object).Should().Be("The First Name THE LAST NAME"); + + person.VerifyAll(); + } + + [Fact] + public void GetFullNameForDisplay_WithFirstNameNullArgument() + { + var act = () => + { + PersonExtensions.GetFullNameForDisplay(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("person"); + } + + [Fact] + public void GetFullNameForOrder() + { + var person = new Mock(MockBehavior.Strict); + person.Setup(p => p.FirstName) + .Returns("The first name"); + person.Setup(p => p.LastName) + .Returns("The last name"); + + PersonExtensions.GetFullNameForOrder(person.Object).Should().Be("THE LAST NAME The First Name"); + + person.VerifyAll(); + } + + [Fact] + public void GetFullNameForOrder_WithFirstNameNullArgument() + { + var act = () => + { + PersonExtensions.GetFullNameForOrder(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("person"); + } + + [Fact] + public void GetInitials() + { + var person = new Mock(MockBehavior.Strict); + person.Setup(p => p.FirstName) + .Returns("First name"); + person.Setup(p => p.LastName) + .Returns("Last name"); + + PersonExtensions.GetInitials(person.Object).Should().Be("FL"); + + person.VerifyAll(); + } + + [Fact] + public void GetInitials_WithFirstNameNullArgument() + { + var act = () => + { + PersonExtensions.GetInitials(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("person"); + } + } +} From 352c3cc138ac0fda4acbbe4d201422d2892b8bfb Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 9 Oct 2025 15:20:13 +0200 Subject: [PATCH 17/73] Add missing unit tests for EmailAddress.FluentValidation. --- .../EmailAddressValidatorExtensionsTest.cs | 12 ++++++++++++ .../EmailAddresses.FluentValidation.Tests.csproj | 10 ++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorExtensionsTest.cs b/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorExtensionsTest.cs index a04d947..1255d21 100644 --- a/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorExtensionsTest.cs +++ b/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorExtensionsTest.cs @@ -21,5 +21,17 @@ public void MustBeEmailAddress() ruleBuilder.VerifyAll(); } + + [Fact] + public void MustBeEmailAddress_NullRuleBuilderArgument() + { + var act = () => + { + EmailAddressValidatorExtensions.MustBeEmailAddress((IRuleBuilder)null); + }; + + act.Should().ThrowExactly() + .WithParameterName("ruleBuilder"); + } } } diff --git a/tests/EmailAddresses.FluentValidation.Tests/EmailAddresses.FluentValidation.Tests.csproj b/tests/EmailAddresses.FluentValidation.Tests/EmailAddresses.FluentValidation.Tests.csproj index 0e2fb6a..0e11a95 100644 --- a/tests/EmailAddresses.FluentValidation.Tests/EmailAddresses.FluentValidation.Tests.csproj +++ b/tests/EmailAddresses.FluentValidation.Tests/EmailAddresses.FluentValidation.Tests.csproj @@ -1,17 +1,11 @@  - - net9.0 - enable - enable - - - + - + From 0c170b45acc3955d5725e3d879d9c9c7bd059aec Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 9 Oct 2025 16:08:45 +0200 Subject: [PATCH 18/73] Fix README for EmailAddresses --- src/EmailAddresses.EntityFramework/README.md | 6 ++---- src/EmailAddresses.FluentValidation/README.md | 5 +---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/EmailAddresses.EntityFramework/README.md b/src/EmailAddresses.EntityFramework/README.md index 352b2a3..e7d1943 100644 --- a/src/EmailAddresses.EntityFramework/README.md +++ b/src/EmailAddresses.EntityFramework/README.md @@ -64,12 +64,10 @@ public class ApplicationDbContext : DbContext ``` This will configure the `Email` property of the `User` entity with: -- `VARCHAR(320)` column length -- Non-Unicode +- `VARCHAR(320)` (Non-unicode) column length - SQL column type `EmailAddress` -- Bi-directional conversion between `EmailAddress` and `string` ## Links - [NuGet package: EmailAddresses.EntityFramework](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) - [NuGet package: EmailAddresses (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) -- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) diff --git a/src/EmailAddresses.FluentValidation/README.md b/src/EmailAddresses.FluentValidation/README.md index 9b4f6fe..74ce2e0 100644 --- a/src/EmailAddresses.FluentValidation/README.md +++ b/src/EmailAddresses.FluentValidation/README.md @@ -9,9 +9,6 @@ This package provides a [FluentValidation](https://fluentvalidation.net/) extens It ensures that only **valid RFC 5322 compliant email addresses** are accepted when validating string properties. -- `null` string values are **ignored** (considered valid). -- To require non-null values, combine with the `NotNull()` or/and `NotEmpty()`. - ## Install You can install the package from [NuGet](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.FluentValidation/): @@ -22,7 +19,7 @@ dotnet add package PosInformatique.Foundations.EmailAddresses.FluentValidation This package depends on the base package [PosInformatique.Foundations.EmailAddresses](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/). ## Features -- FluentValidation extension for email address validation +- [FluentValidation](https://fluentvalidation.net/) extension for email address validation - Uses the same parsing and validation rules as the [EmailAddress](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) value object - Clear and consistent error messages > `null` values are accepted (combine with `NotNull()` validator to forbid nulls) From 188a151c31d5f0028f2ded7c6308a15abe7d9af9 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 9 Oct 2025 16:13:36 +0200 Subject: [PATCH 19/73] Add the People.EntityFramework NuGet package. --- src/People.EntityFramework/CHANGELOG.md | 2 + .../FirstNamePropertyExtensions.cs | 55 ++++++++ .../LastNamePropertyExtensions.cs | 57 ++++++++ .../People.EntityFramework.csproj | 31 ++++ src/People.EntityFramework/README.md | 133 ++++++++++++++++++ .../FirstNamePropertyExtensionsTest.cs | 126 +++++++++++++++++ .../LastNamePropertyExtensionsTest.cs | 126 +++++++++++++++++ .../People.EntityFramework.Tests.csproj | 11 ++ 8 files changed, 541 insertions(+) create mode 100644 src/People.EntityFramework/CHANGELOG.md create mode 100644 src/People.EntityFramework/FirstNamePropertyExtensions.cs create mode 100644 src/People.EntityFramework/LastNamePropertyExtensions.cs create mode 100644 src/People.EntityFramework/People.EntityFramework.csproj create mode 100644 src/People.EntityFramework/README.md create mode 100644 tests/People.EntityFramework.Tests/FirstNamePropertyExtensionsTest.cs create mode 100644 tests/People.EntityFramework.Tests/LastNamePropertyExtensionsTest.cs create mode 100644 tests/People.EntityFramework.Tests/People.EntityFramework.Tests.csproj diff --git a/src/People.EntityFramework/CHANGELOG.md b/src/People.EntityFramework/CHANGELOG.md new file mode 100644 index 0000000..7dca8a9 --- /dev/null +++ b/src/People.EntityFramework/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with the support Entity Framework persitance for FirstName and LastName value objects. diff --git a/src/People.EntityFramework/FirstNamePropertyExtensions.cs b/src/People.EntityFramework/FirstNamePropertyExtensions.cs new file mode 100644 index 0000000..dbb2402 --- /dev/null +++ b/src/People.EntityFramework/FirstNamePropertyExtensions.cs @@ -0,0 +1,55 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.EntityFrameworkCore +{ + using Microsoft.EntityFrameworkCore.ChangeTracking; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + using PosInformatique.Foundations.People; + + /// + /// Contains extension method to map a to a string column. + /// + public static class FirstNamePropertyExtensions + { + /// + /// Configures the specified to be mapped on a NVARCHAR(50) column + /// to store a instance. + /// + /// Entity property to map in the . + /// The instance to configure the configuration of the property. + /// If the specified argument is . + public static PropertyBuilder IsFirstName(this PropertyBuilder property) + { + return property + .IsUnicode(true) + .IsFixedLength(false) + .HasMaxLength(FirstName.MaxLength) + .HasConversion(FirstNameConverter.Instance, FirstNameComparer.Instance); + } + + private sealed class FirstNameConverter : ValueConverter + { + private FirstNameConverter() + : base(v => v.ToString(), v => v) + { + } + + public static FirstNameConverter Instance { get; } = new FirstNameConverter(); + } + + private sealed class FirstNameComparer : ValueComparer + { + private FirstNameComparer() + : base(true) + { + } + + public static FirstNameComparer Instance { get; } = new FirstNameComparer(); + } + } +} diff --git a/src/People.EntityFramework/LastNamePropertyExtensions.cs b/src/People.EntityFramework/LastNamePropertyExtensions.cs new file mode 100644 index 0000000..bf755a7 --- /dev/null +++ b/src/People.EntityFramework/LastNamePropertyExtensions.cs @@ -0,0 +1,57 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.EntityFrameworkCore +{ + using Microsoft.EntityFrameworkCore.ChangeTracking; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + using PosInformatique.Foundations.People; + + /// + /// Contains extension method to map a to a string column. + /// + public static class LastNamePropertyExtensions + { + /// + /// Configures the specified to be mapped on a NVARCHAR(50) column + /// to store a instance. + /// + /// Entity property to map in the . + /// The instance to configure the configuration of the property. + /// If the specified argument is . + public static PropertyBuilder IsLastName(this PropertyBuilder property) + { + ArgumentNullException.ThrowIfNull(property, nameof(property)); + + return property + .IsUnicode(true) + .IsFixedLength(false) + .HasMaxLength(LastName.MaxLength) + .HasConversion(LastNameConverter.Instance, LastNameComparer.Instance); + } + + private sealed class LastNameConverter : ValueConverter + { + private LastNameConverter() + : base(v => v.ToString(), v => v) + { + } + + public static LastNameConverter Instance { get; } = new LastNameConverter(); + } + + private sealed class LastNameComparer : ValueComparer + { + private LastNameComparer() + : base(true) + { + } + + public static LastNameComparer Instance { get; } = new LastNameComparer(); + } + } +} diff --git a/src/People.EntityFramework/People.EntityFramework.csproj b/src/People.EntityFramework/People.EntityFramework.csproj new file mode 100644 index 0000000..a3541cc --- /dev/null +++ b/src/People.EntityFramework/People.EntityFramework.csproj @@ -0,0 +1,31 @@ + + + + true + + + Provides Entity Framework Core integration for the FirstName and LastName value objects from PosInformatique.Foundations.People. + Offers fluent configuration helpers and converters to map normalized first and last names as NVARCHAR(50) columns with proper value conversion and comparison. + + firstname;lastname;people;name;entityframework;efcore;valueobject;validation;mapping;conversion;nvarchar;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/People.EntityFramework/README.md b/src/People.EntityFramework/README.md new file mode 100644 index 0000000..186d7af --- /dev/null +++ b/src/People.EntityFramework/README.md @@ -0,0 +1,133 @@ +# PosInformatique.Foundations.People.EntityFramework + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.People.EntityFramework/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.People.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.People.EntityFramework/) + +## Introduction +This package provides **Entity Framework Core** extensions and value converters to persist the +[PosInformatique.Foundations.People](https://www.nuget.org/packages/PosInformatique.Foundations.People/) value objects: +- `FirstName` stored as `NVARCHAR(50)` with proper conversion and comparisons. +- `LastName` stored as `NVARCHAR(50)` with proper conversion and comparisons. + +It exposes fluent configuration helpers to simplify mapping in your DbContext model configuration. + +## Install +You can install the package from NuGet: +```powershell +dotnet add package PosInformatique.Foundations.People.EntityFramework +``` + +This package depends on the base package [PosInformatique.Foundations.People](https://www.nuget.org/packages/PosInformatique.Foundations.People/). + +## Features +- Provides an extension method `IsFirstName()` and `IsLastName()` to configure EF Core properties for `FirstName` and `LastName`. +- Easy EF Core mapping for `FirstName` and `LastName` properties. +- Maps to NVARCHAR(50), Unicode, non-fixed-length columns. +- Built on top of the core `FirstName` and `FirstName` value objects. +- Keeps domain normalization rules in the database boundary. + +## Examples + +### Configure model with IsFirstName and IsLastName +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PosInformatique.Foundations.People; + +public sealed class PersonEntity +{ + public int Id { get; set; } + + // Persisted as NVARCHAR(50) using the FirstName converter and comparer + public FirstName FirstName { get; set; } = null!; + + // Persisted as NVARCHAR(50) using the LastName converter and comparer + public LastName LastName { get; set; } = null!; +} + +public sealed class PeopleDbContext : DbContext +{ + public DbSet People => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var person = modelBuilder.Entity(); + + person.HasKey(p => p.Id); + + // Configure FirstName as NVARCHAR(50) with conversions + person.Property(p => p.FirstName) + .IsFirstName(); + + // Configure LastName as NVARCHAR(50) with conversions + person.Property(p => p.LastName) + .IsLastName(); + } +} +``` + +This will configure: +- The `FirstName` property of the `PersonEntity` entity with: + - `NVARCHAR(50)` (Unicode) column length +- The `LastName` property of the `PersonEntity` entity with: + - `NVARCHAR(50)` (Unicode) column length + +### Saving and querying +```csharp +var options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString) + .Options; + +using var db = new PeopleDbContext(options); + +// Insert +var person = new PersonEntity +{ + FirstName = FirstName.Create("jean-paul"), // normalized to "Jean-Paul" + LastName = LastName.Create("dupont") // normalized to "DUPONT" +}; +db.Add(person); +await db.SaveChangesAsync(); + +// Query (comparison and ordering use normalized string values via comparer/converter) +var ordered = await db.People + .OrderBy(p => p.LastName) // "DUPONT" etc. + .ThenBy(p => p.FirstName) // "Jean-Paul" etc. + .ToListAsync(); +``` + +### Using With Separate Configuration Class +```csharp +public sealed class PersonEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(p => p.Id); + + builder.Property(p => p.FirstName) + .IsFirstName(); + + builder.Property(p => p.LastName) + .IsLastName(); + } +} + +public sealed class PeopleDbContext : DbContext +{ + public DbSet People => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new PersonEntityConfiguration()); + } +} +``` + +## Notes +- The mapping enforces the maximum length defined by the domain (`FirstName.MaxLength` and `LastName.MaxLength`), ensuring alignment between code and database. +- Converters/Comparers guarantee consistent persistence and querying semantics with the normalized value objects. + +## Links +- [NuGet package: People.EntityFramework](https://www.nuget.org/packages/PosInformatique.Foundations.People.EntityFramework/) +- [NuGet package: People (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.People/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) diff --git a/tests/People.EntityFramework.Tests/FirstNamePropertyExtensionsTest.cs b/tests/People.EntityFramework.Tests/FirstNamePropertyExtensionsTest.cs new file mode 100644 index 0000000..912320f --- /dev/null +++ b/tests/People.EntityFramework.Tests/FirstNamePropertyExtensionsTest.cs @@ -0,0 +1,126 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.EntityFrameworkCore.Tests +{ + using PosInformatique.Foundations.People; + + public class FirstNamePropertyExtensionsTest + { + [Fact] + public void IsFirstName() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("FirstName"); + + property.GetColumnType().Should().Be("nvarchar(50)"); + property.IsUnicode().Should().BeTrue(); + property.IsFixedLength().Should().BeFalse(); + property.GetMaxLength().Should().Be(50); + } + + [Fact] + public void Comparer() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("FirstName"); + + var comparer = property.GetValueComparer(); + + var expression = (Func)comparer.EqualsExpression.Compile(); + + expression("The first name A", "The first name A").Should().BeTrue(); + expression("The first name A", "The first name B").Should().BeFalse(); + + expression("The first name A", null).Should().BeFalse(); + expression(null, "The first name A").Should().BeFalse(); + expression(null, null).Should().BeTrue(); + } + + [Fact] + public void ConvertFromProvider() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("FirstName"); + + var converter = property.GetValueConverter(); + + converter.ConvertFromProvider("The first name").As().ToString().Should().Be("The First Name"); + } + + [Fact] + public void ConvertFromProvider_Null() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("FirstName"); + + var converter = property.GetValueConverter(); + + converter.ConvertFromProvider(null).Should().BeNull(); + } + + [Fact] + public void ConvertToProvider() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("FirstName"); + + var converter = property.GetValueConverter(); + + converter.ConvertToProvider(FirstName.Create("The first name")).Should().Be("The First Name"); + } + + [Fact] + public void ConvertToProvider_WithNull() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("FirstName"); + + var converter = property.GetValueConverter(); + + converter.ConvertToProvider(null).Should().BeNull(); + } + + private class DbContextMock : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + optionsBuilder.UseSqlServer(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + var property = modelBuilder.Entity() + .Property(e => e.FirstName); + + property.IsFirstName().Should().BeSameAs(property); + } + } + + private class EntityMock + { + public int Id { get; set; } + + public FirstName FirstName { get; set; } + } + } +} diff --git a/tests/People.EntityFramework.Tests/LastNamePropertyExtensionsTest.cs b/tests/People.EntityFramework.Tests/LastNamePropertyExtensionsTest.cs new file mode 100644 index 0000000..1c08812 --- /dev/null +++ b/tests/People.EntityFramework.Tests/LastNamePropertyExtensionsTest.cs @@ -0,0 +1,126 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.EntityFrameworkCore.Tests +{ + using PosInformatique.Foundations.People; + + public class LastNamePropertyExtensionsTest + { + [Fact] + public void IsLastName() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("LastName"); + + property.GetColumnType().Should().Be("nvarchar(50)"); + property.IsUnicode().Should().BeTrue(); + property.IsFixedLength().Should().BeFalse(); + property.GetMaxLength().Should().Be(50); + } + + [Fact] + public void Comparer() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("LastName"); + + var comparer = property.GetValueComparer(); + + var expression = (Func)comparer.EqualsExpression.Compile(); + + expression("The last name A", "The last name A").Should().BeTrue(); + expression("The last name A", "The last name B").Should().BeFalse(); + + expression("The last name A", null).Should().BeFalse(); + expression(null, "The last name A").Should().BeFalse(); + expression(null, null).Should().BeTrue(); + } + + [Fact] + public void ConvertFromProvider() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("LastName"); + + var converter = property.GetValueConverter(); + + converter.ConvertFromProvider("The last name").As().ToString().Should().Be("THE LAST NAME"); + } + + [Fact] + public void ConvertFromProvider_Null() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("LastName"); + + var converter = property.GetValueConverter(); + + converter.ConvertFromProvider(null).Should().BeNull(); + } + + [Fact] + public void ConvertToProvider() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("LastName"); + + var converter = property.GetValueConverter(); + + converter.ConvertToProvider(LastName.Create("The last name")).Should().Be("THE LAST NAME"); + } + + [Fact] + public void ConvertToProvider_WithNull() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("LastName"); + + var converter = property.GetValueConverter(); + + converter.ConvertToProvider(null).Should().BeNull(); + } + + private class DbContextMock : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + optionsBuilder.UseSqlServer(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + var property = modelBuilder.Entity() + .Property(e => e.LastName); + + property.IsLastName().Should().BeSameAs(property); + } + } + + private class EntityMock + { + public int Id { get; set; } + + public LastName LastName { get; set; } + } + } +} diff --git a/tests/People.EntityFramework.Tests/People.EntityFramework.Tests.csproj b/tests/People.EntityFramework.Tests/People.EntityFramework.Tests.csproj new file mode 100644 index 0000000..696d0e7 --- /dev/null +++ b/tests/People.EntityFramework.Tests/People.EntityFramework.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + From 738891caa52e63f0042fe0d56bfe00a33683bf64 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 9 Oct 2025 16:13:46 +0200 Subject: [PATCH 20/73] Add the People.FluentValidation NuGet package. --- PosInformatique.Foundations.sln | 34 +++++ src/People.FluentValidation/CHANGELOG.md | 2 + .../FirstNameValidator.cs | 36 +++++ .../LastNameValidator.cs | 36 +++++ .../NameValidatorExtensions.cs | 57 ++++++++ .../People.FluentValidation.csproj | 31 +++++ src/People.FluentValidation/README.md | 126 ++++++++++++++++++ tests/.editorconfig | 3 + .../FirstNameValidatorTest.cs | 51 +++++++ .../LastNameValidatorTest.cs | 51 +++++++ .../NameValidatorExtensionsTest.cs | 65 +++++++++ .../People.FluentValidation.Tests.csproj | 11 ++ 12 files changed, 503 insertions(+) create mode 100644 src/People.FluentValidation/CHANGELOG.md create mode 100644 src/People.FluentValidation/FirstNameValidator.cs create mode 100644 src/People.FluentValidation/LastNameValidator.cs create mode 100644 src/People.FluentValidation/NameValidatorExtensions.cs create mode 100644 src/People.FluentValidation/People.FluentValidation.csproj create mode 100644 src/People.FluentValidation/README.md create mode 100644 tests/People.FluentValidation.Tests/FirstNameValidatorTest.cs create mode 100644 tests/People.FluentValidation.Tests/LastNameValidatorTest.cs create mode 100644 tests/People.FluentValidation.Tests/NameValidatorExtensionsTest.cs create mode 100644 tests/People.FluentValidation.Tests/People.FluentValidation.Tests.csproj diff --git a/PosInformatique.Foundations.sln b/PosInformatique.Foundations.sln index 2190b6b..c99b1c8 100644 --- a/PosInformatique.Foundations.sln +++ b/PosInformatique.Foundations.sln @@ -64,6 +64,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People", "src\People\People EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.Tests", "tests\People.Tests\People.Tests.csproj", "{E9727893-5089-49AD-B829-9A0C7478DE8D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EntityFramework", "EntityFramework", "{AD76012D-0929-4FB7-BDF0-71B0C6CEA1C3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "FluentValidation", "FluentValidation", "{02A575A9-DC41-4FF0-A05B-6E28CFA9E14D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.EntityFramework", "src\People.EntityFramework\People.EntityFramework.csproj", "{BA669488-A9F5-45B9-B58A-FD9478FDE6E3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.EntityFramework.Tests", "tests\People.EntityFramework.Tests\People.EntityFramework.Tests.csproj", "{34F3C67F-4A9B-4307-B1F6-F229EE1CB152}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.FluentValidation", "src\People.FluentValidation\People.FluentValidation.csproj", "{DE51B2E1-27CA-4AFB-AC28-8C759012F230}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.FluentValidation.Tests", "tests\People.FluentValidation.Tests\People.FluentValidation.Tests.csproj", "{59412D14-DC4B-4583-9B33-B405BF81913D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -110,6 +122,22 @@ Global {E9727893-5089-49AD-B829-9A0C7478DE8D}.Debug|Any CPU.Build.0 = Debug|Any CPU {E9727893-5089-49AD-B829-9A0C7478DE8D}.Release|Any CPU.ActiveCfg = Release|Any CPU {E9727893-5089-49AD-B829-9A0C7478DE8D}.Release|Any CPU.Build.0 = Release|Any CPU + {BA669488-A9F5-45B9-B58A-FD9478FDE6E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA669488-A9F5-45B9-B58A-FD9478FDE6E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA669488-A9F5-45B9-B58A-FD9478FDE6E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA669488-A9F5-45B9-B58A-FD9478FDE6E3}.Release|Any CPU.Build.0 = Release|Any CPU + {34F3C67F-4A9B-4307-B1F6-F229EE1CB152}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {34F3C67F-4A9B-4307-B1F6-F229EE1CB152}.Debug|Any CPU.Build.0 = Debug|Any CPU + {34F3C67F-4A9B-4307-B1F6-F229EE1CB152}.Release|Any CPU.ActiveCfg = Release|Any CPU + {34F3C67F-4A9B-4307-B1F6-F229EE1CB152}.Release|Any CPU.Build.0 = Release|Any CPU + {DE51B2E1-27CA-4AFB-AC28-8C759012F230}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DE51B2E1-27CA-4AFB-AC28-8C759012F230}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DE51B2E1-27CA-4AFB-AC28-8C759012F230}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DE51B2E1-27CA-4AFB-AC28-8C759012F230}.Release|Any CPU.Build.0 = Release|Any CPU + {59412D14-DC4B-4583-9B33-B405BF81913D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59412D14-DC4B-4583-9B33-B405BF81913D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59412D14-DC4B-4583-9B33-B405BF81913D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59412D14-DC4B-4583-9B33-B405BF81913D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -132,6 +160,12 @@ Global {0903F791-7EE4-4644-BA90-494798B1F4B3} = {80CB9DF4-D9EB-4E13-A78F-B716093A1B6C} {5CF087B0-0B63-40F5-8E7B-6DCE1FAA1EEE} = {F2473697-B13F-422F-A267-DA263F56025F} {E9727893-5089-49AD-B829-9A0C7478DE8D} = {F2473697-B13F-422F-A267-DA263F56025F} + {AD76012D-0929-4FB7-BDF0-71B0C6CEA1C3} = {F2473697-B13F-422F-A267-DA263F56025F} + {02A575A9-DC41-4FF0-A05B-6E28CFA9E14D} = {F2473697-B13F-422F-A267-DA263F56025F} + {BA669488-A9F5-45B9-B58A-FD9478FDE6E3} = {AD76012D-0929-4FB7-BDF0-71B0C6CEA1C3} + {34F3C67F-4A9B-4307-B1F6-F229EE1CB152} = {AD76012D-0929-4FB7-BDF0-71B0C6CEA1C3} + {DE51B2E1-27CA-4AFB-AC28-8C759012F230} = {02A575A9-DC41-4FF0-A05B-6E28CFA9E14D} + {59412D14-DC4B-4583-9B33-B405BF81913D} = {02A575A9-DC41-4FF0-A05B-6E28CFA9E14D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {344068EF-5958-4241-BD83-86403ADA68F1} diff --git a/src/People.FluentValidation/CHANGELOG.md b/src/People.FluentValidation/CHANGELOG.md new file mode 100644 index 0000000..58f9834 --- /dev/null +++ b/src/People.FluentValidation/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with the support FluentValidation for the validation of FirstName and LastName value objects. diff --git a/src/People.FluentValidation/FirstNameValidator.cs b/src/People.FluentValidation/FirstNameValidator.cs new file mode 100644 index 0000000..f90d008 --- /dev/null +++ b/src/People.FluentValidation/FirstNameValidator.cs @@ -0,0 +1,36 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People +{ + using FluentValidation; + using FluentValidation.Validators; + + internal sealed class FirstNameValidator : PropertyValidator + { + private static readonly string AllowedSeparators = string.Join(", ", FirstName.AllowedSeparators.Select(s => $"'{s}'")); + + public override string Name + { + get => "FirstNameValidator"; + } + + public override bool IsValid(ValidationContext context, string value) + { + if (value is not null) + { + return FirstName.IsValid(value); + } + + return false; + } + + protected override string GetDefaultMessageTemplate(string errorCode) + { + return $"'{{PropertyName}}' must contain a first name that consists only of alphabetic characters, with the [{AllowedSeparators}] separators, and is less than {FirstName.MaxLength} characters long."; + } + } +} diff --git a/src/People.FluentValidation/LastNameValidator.cs b/src/People.FluentValidation/LastNameValidator.cs new file mode 100644 index 0000000..26bf1d8 --- /dev/null +++ b/src/People.FluentValidation/LastNameValidator.cs @@ -0,0 +1,36 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People +{ + using FluentValidation; + using FluentValidation.Validators; + + internal sealed class LastNameValidator : PropertyValidator + { + private static readonly string AllowedSeparators = string.Join(", ", LastName.AllowedSeparators.Select(s => $"'{s}'")); + + public override string Name + { + get => "LastNameValidator"; + } + + public override bool IsValid(ValidationContext context, string value) + { + if (value is not null) + { + return LastName.IsValid(value); + } + + return false; + } + + protected override string GetDefaultMessageTemplate(string errorCode) + { + return $"'{{PropertyName}}' must contain a last name that consists only of alphabetic characters, with the [{AllowedSeparators}] separators, and is less than {LastName.MaxLength} characters long."; + } + } +} diff --git a/src/People.FluentValidation/NameValidatorExtensions.cs b/src/People.FluentValidation/NameValidatorExtensions.cs new file mode 100644 index 0000000..a412d0c --- /dev/null +++ b/src/People.FluentValidation/NameValidatorExtensions.cs @@ -0,0 +1,57 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace FluentValidation +{ + using PosInformatique.Foundations.People; + + /// + /// Contains extension methods for FluentValidation to validate first name and last name + /// to check the business rules of the and . + /// + public static class NameValidatorExtensions + { + /// + /// Defines a validator that checks if a property is a valid first name + /// (parsable by the value object). + /// Validation fails if the value is not a valid first name according to the business rules: + /// letters only with separators (' ' or '-'), proper casing, no consecutive/trailing separators, and max length of 50. + /// If the value is , validation succeeds. + /// Use the validator + /// to disallow values. + /// + /// The type of the object being validated. + /// The rule builder on which the validator is defined. + /// The instance to continue configuring the property validator. + /// If the specified argument is . + public static IRuleBuilderOptions MustBeFirstName(this IRuleBuilder ruleBuilder) + { + ArgumentNullException.ThrowIfNull(ruleBuilder, nameof(ruleBuilder)); + + return ruleBuilder.SetValidator(new FirstNameValidator()); + } + + /// + /// Defines a validator that checks if a property is a valid last name + /// (parsable by the value object). + /// Validation fails if the value is not a valid last name according to the business rules: + /// letters only with separators (' ' or '-'), fully uppercased normalization, no consecutive/trailing separators, and max length of 50. + /// If the value is , validation succeeds. + /// Use the validator + /// to disallow values. + /// + /// The type of the object being validated. + /// The rule builder on which the validator is defined. + /// The instance to continue configuring the property validator. + /// If the specified argument is . + public static IRuleBuilderOptions MustBeLastName(this IRuleBuilder ruleBuilder) + { + ArgumentNullException.ThrowIfNull(ruleBuilder, nameof(ruleBuilder)); + + return ruleBuilder.SetValidator(new LastNameValidator()); + } + } +} diff --git a/src/People.FluentValidation/People.FluentValidation.csproj b/src/People.FluentValidation/People.FluentValidation.csproj new file mode 100644 index 0000000..c1fff22 --- /dev/null +++ b/src/People.FluentValidation/People.FluentValidation.csproj @@ -0,0 +1,31 @@ + + + + true + + + Provides FluentValidation extensions to validate first and last names + using the strongly-typed FirstName and LastName value objects from PosInformatique.Foundations.People. + Ensures inputs follow business rules (letters only, space/hyphen separators, proper casing, max length 50). + + fluentvalidation;validation;firstname;lastname;people;names;ddd;valueobject;parsing;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + diff --git a/src/People.FluentValidation/README.md b/src/People.FluentValidation/README.md new file mode 100644 index 0000000..9afca8e --- /dev/null +++ b/src/People.FluentValidation/README.md @@ -0,0 +1,126 @@ +# PosInformatique.Foundations.People.FluentValidation + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentValidation/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.People.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentValidation/) + +## Introduction +This package provides [FluentValidation](https://fluentvalidation.net/) extensions for validating first names and last names using the +`FirstName` and `LastName` value objects from `PosInformatique.Foundations.People`. + +It simplifies the integration of robust name validation into your [FluentValidation](https://fluentvalidation.net/) rules, +ensuring that string properties conform to the defined business rules for first and last names. + +## Install +You can install the package from NuGet: + +```powershell +dotnet add package PosInformatique.Foundations.People.FluentValidation +``` + +## Features +- [FluentValidation](https://fluentvalidation.net/) extension for first name and last name validation based on the business rules +of the [PosInformatique.Foundations.People](https://www.nuget.org/packages/PosInformatique.Foundations.People/) package. +- Uses the same parsing and validation rules as the `FirstName` and `LastName` value objects +- Clear and consistent error messages +> `null` values are accepted (combine with `NotNull()` validator to forbid nulls) + +## Use cases +- **Validation**: Ensure that user inputs for first and last names adhere to your domain's business rules. +- **Type safety**: Leverage the strong typing of `FirstName` and `LastName` within your validation logic. +- **Consistency**: Apply a single, robust name validation logic across all projects using FluentValidation. + +## Examples + +### Validating FirstName +```csharp +public class PersonValidator : AbstractValidator +{ + public PersonValidator() + { + // FirstName must be a valid FirstName (e.g., "John", "Jean-Pierre") + // Null values are allowed by default. Use NotNull() to disallow. + RuleFor(x => x.FirstName).MustBeFirstName(); + + // Example with NotNull() + RuleFor(x => x.FirstName) + .NotNull() + .MustBeFirstName(); + } +} + +public class Person +{ + public string? FirstName { get; set; } + public string? LastName { get; set; } +} + +// Usage +var validator = new PersonValidator(); + +// Valid +var result1 = validator.Validate(new Person { FirstName = "John", LastName = "DOE" }); // IsValid: true + +// Invalid (contains invalid character) +var result2 = validator.Validate(new Person { FirstName = "John_123", LastName = "DOE" }); // IsValid: false + +// Invalid (too long) +var result3 = validator.Validate(new Person { FirstName = new string('A', 51), LastName = "DOE" }); // IsValid: false + +// Null (valid by default for MustBeFirstName) +var result4 = validator.Validate(new Person { FirstName = null, LastName = "DOE" }); // IsValid: true + +// Null (invalid if NotNull() is used) +var validatorNotNull = new PersonValidator(); +validatorNotNull.RuleFor(x => x.FirstName).NotNull().MustBeFirstName(); +var result5 = validatorNotNull.Validate(new Person { FirstName = null, LastName = "DOE" }); // IsValid: false +``` + +### Validating LastName +```csharp +public class PersonValidator : AbstractValidator +{ + public PersonValidator() + { + // LastName must be a valid LastName (e.g., "DOE", "SMITH-JOHNSON") + // Null values are allowed by default. Use NotNull() to disallow. + RuleFor(x => x.LastName).MustBeLastName(); + + // Example with NotNull() + RuleFor(x => x.LastName) + .NotNull() + .MustBeLastName(); + } +} + +public class Person +{ + public string? FirstName { get; set; } + public string? LastName { get; set; } +} + +// Usage +var validator = new PersonValidator(); + +// Valid +var result1 = validator.Validate(new Person { FirstName = "John", LastName = "DOE" }); // IsValid: true + +// Invalid (contains invalid character) +var result2 = validator.Validate(new Person { FirstName = "John", LastName = "DOE_123" }); // IsValid: false + +// Invalid (too long) +var result3 = validator.Validate(new Person { FirstName = "John", LastName = new string('A', 51) }); // IsValid: false + +// Null (valid by default for MustBeLastName) +var result4 = validator.Validate(new Person { FirstName = "John", LastName = null }); // IsValid: true + +// Null (invalid if NotNull() is used) +var validatorNotNull = new PersonValidator(); +validatorNotNull.RuleFor(x => x.LastName).NotNull().MustBeLastName(); +var result5 = validatorNotNull.Validate(new Person { FirstName = "John", LastName = null }); // IsValid: false +``` + +## Links +- [NuGet package: People.FluentValidation](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentValidation/) +- [NuGet package: People (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.People/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) +- [FluentValidation](https://fluentvalidation.net/) diff --git a/tests/.editorconfig b/tests/.editorconfig index f4f1f48..0d9a8dd 100644 --- a/tests/.editorconfig +++ b/tests/.editorconfig @@ -2,5 +2,8 @@ #### StyleCop #### +# SA1312: Variable names should begin with lower-case letter +dotnet_diagnostic.SA1312.severity = none + # SA1600: Elements should be documented dotnet_diagnostic.SA1600.severity = none diff --git a/tests/People.FluentValidation.Tests/FirstNameValidatorTest.cs b/tests/People.FluentValidation.Tests/FirstNameValidatorTest.cs new file mode 100644 index 0000000..67b40cd --- /dev/null +++ b/tests/People.FluentValidation.Tests/FirstNameValidatorTest.cs @@ -0,0 +1,51 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People.FluentValidation.Tests +{ + using global::FluentValidation.Validators; + + public class FirstNameValidatorTest + { + [Fact] + public void Constructor() + { + var validator = new FirstNameValidator(); + + validator.Name.Should().Be("FirstNameValidator"); + } + + [Fact] + public void GetDefaultMessageTemplate() + { + var validator = new FirstNameValidator(); + + validator.As().GetDefaultMessageTemplate(default).Should().Be("'{PropertyName}' must contain a first name that consists only of alphabetic characters, with the [' ', '-'] separators, and is less than 50 characters long."); + } + + [Theory] + [MemberData(nameof(NameTestData.ValidFirstNames), MemberType = typeof(NameTestData))] +#pragma warning disable IDE0079 // Remove unnecessary suppression +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + public void IsValid_True(string firstName, string _) +#pragma warning restore SA1313 // Parameter names should begin with lower-case letter +#pragma warning restore IDE0079 // Remove unnecessary suppression + { + var validator = new FirstNameValidator(); + + validator.IsValid(default, firstName).Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(NameTestData.InvalidFirstNames), MemberType = typeof(NameTestData))] + public void IsValid_False(string firstName) + { + var validator = new FirstNameValidator(); + + validator.IsValid(default, firstName).Should().BeFalse(); + } + } +} diff --git a/tests/People.FluentValidation.Tests/LastNameValidatorTest.cs b/tests/People.FluentValidation.Tests/LastNameValidatorTest.cs new file mode 100644 index 0000000..6d1fc48 --- /dev/null +++ b/tests/People.FluentValidation.Tests/LastNameValidatorTest.cs @@ -0,0 +1,51 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People.FluentValidation.Tests +{ + using global::FluentValidation.Validators; + + public class LastNameValidatorTest + { + [Fact] + public void Constructor() + { + var validator = new LastNameValidator(); + + validator.Name.Should().Be("LastNameValidator"); + } + + [Fact] + public void GetDefaultMessageTemplate() + { + var validator = new LastNameValidator(); + + validator.As().GetDefaultMessageTemplate(default).Should().Be("'{PropertyName}' must contain a last name that consists only of alphabetic characters, with the [' ', '-'] separators, and is less than 50 characters long."); + } + + [Theory] + [MemberData(nameof(NameTestData.ValidLastNames), MemberType = typeof(NameTestData))] +#pragma warning disable IDE0079 // Remove unnecessary suppression +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + public void IsValid_True(string lastName, string _) +#pragma warning restore SA1313 // Parameter names should begin with lower-case letter +#pragma warning restore IDE0079 // Remove unnecessary suppression + { + var validator = new LastNameValidator(); + + validator.IsValid(default, lastName).Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(NameTestData.InvalidLastNames), MemberType = typeof(NameTestData))] + public void IsValid_False(string lastName) + { + var validator = new LastNameValidator(); + + validator.IsValid(default, lastName).Should().BeFalse(); + } + } +} diff --git a/tests/People.FluentValidation.Tests/NameValidatorExtensionsTest.cs b/tests/People.FluentValidation.Tests/NameValidatorExtensionsTest.cs new file mode 100644 index 0000000..c7c8b29 --- /dev/null +++ b/tests/People.FluentValidation.Tests/NameValidatorExtensionsTest.cs @@ -0,0 +1,65 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace FluentValidation.Tests +{ + using PosInformatique.Foundations.People; + + public class NameValidatorExtensionsTest + { + [Fact] + public void MustBeFirstName() + { + var options = Mock.Of>(MockBehavior.Strict); + + var ruleBuilder = new Mock>(MockBehavior.Strict); + ruleBuilder.Setup(rb => rb.SetValidator(It.IsNotNull>())) + .Returns(options); + + ruleBuilder.Object.MustBeFirstName().Should().BeSameAs(options); + + ruleBuilder.VerifyAll(); + } + + [Fact] + public void MustBeFirstName_NullRuleBuilderArgument() + { + var act = () => + { + NameValidatorExtensions.MustBeFirstName((IRuleBuilder)null); + }; + + act.Should().ThrowExactly() + .WithParameterName("ruleBuilder"); + } + + [Fact] + public void MustBeLastName() + { + var options = Mock.Of>(MockBehavior.Strict); + + var ruleBuilder = new Mock>(MockBehavior.Strict); + ruleBuilder.Setup(rb => rb.SetValidator(It.IsNotNull>())) + .Returns(options); + + ruleBuilder.Object.MustBeLastName().Should().BeSameAs(options); + + ruleBuilder.VerifyAll(); + } + + [Fact] + public void MustBeLastName_NullRuleBuilderArgument() + { + var act = () => + { + NameValidatorExtensions.MustBeLastName((IRuleBuilder)null); + }; + + act.Should().ThrowExactly() + .WithParameterName("ruleBuilder"); + } + } +} diff --git a/tests/People.FluentValidation.Tests/People.FluentValidation.Tests.csproj b/tests/People.FluentValidation.Tests/People.FluentValidation.Tests.csproj new file mode 100644 index 0000000..f36bdc6 --- /dev/null +++ b/tests/People.FluentValidation.Tests/People.FluentValidation.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + From 9a9ff66b518fd6d4c8b7ca4b913dc509de287edd Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 9 Oct 2025 16:21:57 +0200 Subject: [PATCH 21/73] Updates the README. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 8553032..e83346f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,11 @@ You can install any package using the .NET CLI or NuGet Package Manager. |--|---------|-------------|-------| |PosInformatique.Foundations.EmailAddresses icon|[**PosInformatique.Foundations.EmailAddresses**](./src/EmailAddresses/README.md) | Strongly-typed value object representing an email address with validation and normalization as RFC 5322 compliant. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses) | |PosInformatique.Foundations.EmailAddresses.EntityFramework icon|[**PosInformatique.Foundations.EmailAddresses.EntityFramework**](./src/EmailAddresses.EntityFramework/README.md) | Entity Framework Core integration for the EmailAddress value object, including property configuration and value converter for seamless database persistence. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework) | +|PosInformatique.Foundations.EmailAddresses.FluentValidation icon|[**PosInformatique.Foundations.EmailAddresses.FluentValidation**](./src/EmailAddresses.FluentValidation/README.md) | FluentValidation integration for the EmailAddress value object, providing dedicated validators and rules to ensure RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.FluentValidation) | |PosInformatique.Foundations.EmailAddresses.Json icon|[**PosInformatique.Foundations.EmailAddresses.Json**](./src/EmailAddresses.Json/README.md) | System.Text.Json converter for the EmailAddress value object, enabling seamless serialization and deserialization of RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json) | +|PosInformatique.Foundations.People icon|[**PosInformatique.Foundations.People**](./src/People/README.md) | Strongly-typed value objects for first and last names with validation and normalization. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People)](https://www.nuget.org/packages/PosInformatique.Foundations.People) | +|PosInformatique.Foundations.People.EntityFramework icon|[**PosInformatique.Foundations.People.EntityFramework**](./src/People.EntityFramework/README.md) | Entity Framework Core integration for FirstName and LastName value objects, providing fluent property configuration and value converters. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.People.EntityFramework) | +|PosInformatique.Foundations.People.FluentValidation icon|[**PosInformatique.Foundations.People.FluentValidation**](./src/People.FluentValidation/README.md) | FluentValidation extensions for FirstName and LastName value objects. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentValidation) | > Note: Each package is completely independent. You install only what you need. From 73e5719c413ed3540345ee5c4112cdfa0cf781d1 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 9 Oct 2025 16:28:10 +0200 Subject: [PATCH 22/73] Upgrade the NuGet packages. --- Directory.Packages.props | 10 +++++----- tests/Directory.Build.props | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b5ba072..204d1da 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,13 +8,13 @@ - - + + - + - - + + \ No newline at end of file diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index cbb07ee..7c10ae7 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -16,7 +16,10 @@ all runtime; build; native; contentfiles; analyzers - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + From 0a41c51bedafe110f68bfc210a3978bb41a7301c Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 10 Oct 2025 15:31:30 +0200 Subject: [PATCH 23/73] Add the People.Json and People.DataAnnotations packages. --- PosInformatique.Foundations.sln | 34 +++++ README.md | 8 +- .../EmailAddresses.Json.csproj | 1 - src/People.DataAnnotations/CHANGELOG.md | 2 + .../FirstNameAttribute.cs | 43 ++++++ .../LastNameAttribute.cs | 43 ++++++ .../People.DataAnnotations.csproj | 49 +++++++ ...PeopleDataAnnotationsResources.Designer.cs | 82 +++++++++++ .../PeopleDataAnnotationsResources.fr.resx | 126 ++++++++++++++++ .../PeopleDataAnnotationsResources.resx | 126 ++++++++++++++++ src/People.DataAnnotations/README.md | 134 ++++++++++++++++++ .../People.FluentValidation.csproj | 1 - src/People.FluentValidation/README.md | 2 +- src/People.Json/CHANGELOG.md | 2 + src/People.Json/FirstNameJsonConverter.cs | 45 ++++++ src/People.Json/LastNameJsonConverter.cs | 45 ++++++ src/People.Json/People.Json.csproj | 31 ++++ .../PeopleJsonSerializerOptionsExtensions.cs | 40 ++++++ src/People.Json/README.md | 120 ++++++++++++++++ .../FirstNameAttributeTest.cs | 46 ++++++ .../LastNameAttributeTest.cs | 46 ++++++ .../People.DataAnnotations.Tests.csproj | 7 + .../FirstNameJsonConverterTest.cs | 118 +++++++++++++++ .../LastNameJsonConverterTest.cs | 118 +++++++++++++++ .../People.Json.Tests.csproj | 11 ++ ...opleJsonSerializerOptionsExtensionsTest.cs | 44 ++++++ 26 files changed, 1318 insertions(+), 6 deletions(-) create mode 100644 src/People.DataAnnotations/CHANGELOG.md create mode 100644 src/People.DataAnnotations/FirstNameAttribute.cs create mode 100644 src/People.DataAnnotations/LastNameAttribute.cs create mode 100644 src/People.DataAnnotations/People.DataAnnotations.csproj create mode 100644 src/People.DataAnnotations/PeopleDataAnnotationsResources.Designer.cs create mode 100644 src/People.DataAnnotations/PeopleDataAnnotationsResources.fr.resx create mode 100644 src/People.DataAnnotations/PeopleDataAnnotationsResources.resx create mode 100644 src/People.DataAnnotations/README.md create mode 100644 src/People.Json/CHANGELOG.md create mode 100644 src/People.Json/FirstNameJsonConverter.cs create mode 100644 src/People.Json/LastNameJsonConverter.cs create mode 100644 src/People.Json/People.Json.csproj create mode 100644 src/People.Json/PeopleJsonSerializerOptionsExtensions.cs create mode 100644 src/People.Json/README.md create mode 100644 tests/People.DataAnnotations.Tests/FirstNameAttributeTest.cs create mode 100644 tests/People.DataAnnotations.Tests/LastNameAttributeTest.cs create mode 100644 tests/People.DataAnnotations.Tests/People.DataAnnotations.Tests.csproj create mode 100644 tests/People.Json.Tests/FirstNameJsonConverterTest.cs create mode 100644 tests/People.Json.Tests/LastNameJsonConverterTest.cs create mode 100644 tests/People.Json.Tests/People.Json.Tests.csproj create mode 100644 tests/People.Json.Tests/PeopleJsonSerializerOptionsExtensionsTest.cs diff --git a/PosInformatique.Foundations.sln b/PosInformatique.Foundations.sln index c99b1c8..9877ff4 100644 --- a/PosInformatique.Foundations.sln +++ b/PosInformatique.Foundations.sln @@ -76,6 +76,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.FluentValidation", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.FluentValidation.Tests", "tests\People.FluentValidation.Tests\People.FluentValidation.Tests.csproj", "{59412D14-DC4B-4583-9B33-B405BF81913D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Json", "Json", "{A94B7ED9-45FB-4D57-8B9D-B52C47F72D0A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DataAnnotations", "DataAnnotations", "{1EB29D6C-ABD8-4F9D-8608-72B69DD9B756}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.Json", "src\People.Json\People.Json.csproj", "{219AA4F0-E49E-4352-865F-D3DB450B0DB4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.Json.Tests", "tests\People.Json.Tests\People.Json.Tests.csproj", "{294EA128-CE0C-40CC-B3E6-CF1F40D3DC80}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.DataAnnotations", "src\People.DataAnnotations\People.DataAnnotations.csproj", "{6B65DB34-1360-4D74-A925-57DE07009D2E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.DataAnnotations.Tests", "tests\People.DataAnnotations.Tests\People.DataAnnotations.Tests.csproj", "{AFFBCD07-8D7F-4DC6-91E6-76D993345D0B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -138,6 +150,22 @@ Global {59412D14-DC4B-4583-9B33-B405BF81913D}.Debug|Any CPU.Build.0 = Debug|Any CPU {59412D14-DC4B-4583-9B33-B405BF81913D}.Release|Any CPU.ActiveCfg = Release|Any CPU {59412D14-DC4B-4583-9B33-B405BF81913D}.Release|Any CPU.Build.0 = Release|Any CPU + {219AA4F0-E49E-4352-865F-D3DB450B0DB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {219AA4F0-E49E-4352-865F-D3DB450B0DB4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {219AA4F0-E49E-4352-865F-D3DB450B0DB4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {219AA4F0-E49E-4352-865F-D3DB450B0DB4}.Release|Any CPU.Build.0 = Release|Any CPU + {294EA128-CE0C-40CC-B3E6-CF1F40D3DC80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {294EA128-CE0C-40CC-B3E6-CF1F40D3DC80}.Debug|Any CPU.Build.0 = Debug|Any CPU + {294EA128-CE0C-40CC-B3E6-CF1F40D3DC80}.Release|Any CPU.ActiveCfg = Release|Any CPU + {294EA128-CE0C-40CC-B3E6-CF1F40D3DC80}.Release|Any CPU.Build.0 = Release|Any CPU + {6B65DB34-1360-4D74-A925-57DE07009D2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B65DB34-1360-4D74-A925-57DE07009D2E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B65DB34-1360-4D74-A925-57DE07009D2E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B65DB34-1360-4D74-A925-57DE07009D2E}.Release|Any CPU.Build.0 = Release|Any CPU + {AFFBCD07-8D7F-4DC6-91E6-76D993345D0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AFFBCD07-8D7F-4DC6-91E6-76D993345D0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AFFBCD07-8D7F-4DC6-91E6-76D993345D0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AFFBCD07-8D7F-4DC6-91E6-76D993345D0B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -166,6 +194,12 @@ Global {34F3C67F-4A9B-4307-B1F6-F229EE1CB152} = {AD76012D-0929-4FB7-BDF0-71B0C6CEA1C3} {DE51B2E1-27CA-4AFB-AC28-8C759012F230} = {02A575A9-DC41-4FF0-A05B-6E28CFA9E14D} {59412D14-DC4B-4583-9B33-B405BF81913D} = {02A575A9-DC41-4FF0-A05B-6E28CFA9E14D} + {A94B7ED9-45FB-4D57-8B9D-B52C47F72D0A} = {F2473697-B13F-422F-A267-DA263F56025F} + {1EB29D6C-ABD8-4F9D-8608-72B69DD9B756} = {F2473697-B13F-422F-A267-DA263F56025F} + {219AA4F0-E49E-4352-865F-D3DB450B0DB4} = {A94B7ED9-45FB-4D57-8B9D-B52C47F72D0A} + {294EA128-CE0C-40CC-B3E6-CF1F40D3DC80} = {A94B7ED9-45FB-4D57-8B9D-B52C47F72D0A} + {6B65DB34-1360-4D74-A925-57DE07009D2E} = {1EB29D6C-ABD8-4F9D-8608-72B69DD9B756} + {AFFBCD07-8D7F-4DC6-91E6-76D993345D0B} = {1EB29D6C-ABD8-4F9D-8608-72B69DD9B756} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {344068EF-5958-4241-BD83-86403ADA68F1} diff --git a/README.md b/README.md index e83346f..270ac75 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,12 @@ You can install any package using the .NET CLI or NuGet Package Manager. |PosInformatique.Foundations.EmailAddresses icon|[**PosInformatique.Foundations.EmailAddresses**](./src/EmailAddresses/README.md) | Strongly-typed value object representing an email address with validation and normalization as RFC 5322 compliant. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses) | |PosInformatique.Foundations.EmailAddresses.EntityFramework icon|[**PosInformatique.Foundations.EmailAddresses.EntityFramework**](./src/EmailAddresses.EntityFramework/README.md) | Entity Framework Core integration for the EmailAddress value object, including property configuration and value converter for seamless database persistence. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework) | |PosInformatique.Foundations.EmailAddresses.FluentValidation icon|[**PosInformatique.Foundations.EmailAddresses.FluentValidation**](./src/EmailAddresses.FluentValidation/README.md) | FluentValidation integration for the EmailAddress value object, providing dedicated validators and rules to ensure RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.FluentValidation) | -|PosInformatique.Foundations.EmailAddresses.Json icon|[**PosInformatique.Foundations.EmailAddresses.Json**](./src/EmailAddresses.Json/README.md) | System.Text.Json converter for the EmailAddress value object, enabling seamless serialization and deserialization of RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json) | +|PosInformatique.Foundations.EmailAddresses.Json icon|[**PosInformatique.Foundations.EmailAddresses.Json**](./src/EmailAddresses.Json/README.md) | `System.Text.Json` converter for the EmailAddress value object, enabling seamless serialization and deserialization of RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json) | |PosInformatique.Foundations.People icon|[**PosInformatique.Foundations.People**](./src/People/README.md) | Strongly-typed value objects for first and last names with validation and normalization. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People)](https://www.nuget.org/packages/PosInformatique.Foundations.People) | -|PosInformatique.Foundations.People.EntityFramework icon|[**PosInformatique.Foundations.People.EntityFramework**](./src/People.EntityFramework/README.md) | Entity Framework Core integration for FirstName and LastName value objects, providing fluent property configuration and value converters. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.People.EntityFramework) | -|PosInformatique.Foundations.People.FluentValidation icon|[**PosInformatique.Foundations.People.FluentValidation**](./src/People.FluentValidation/README.md) | FluentValidation extensions for FirstName and LastName value objects. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentValidation) | +|PosInformatique.Foundations.People.DataAnnotations icon|[**PosInformatique.Foundations.People.DataAnnotations**](./src/People.DataAnnotations/README.md) | DataAnnotations attributes for `FirstName` and `LastName` value objects. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.DataAnnotations)](https://www.nuget.org/packages/PosInformatique.Foundations.People.DataAnnotations) | +|PosInformatique.Foundations.People.EntityFramework icon|[**PosInformatique.Foundations.People.EntityFramework**](./src/People.EntityFramework/README.md) | Entity Framework Core integration for `FirstName` and `LastName` value objects, providing fluent property configuration and value converters. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.People.EntityFramework) | +|PosInformatique.Foundations.People.FluentValidation icon|[**PosInformatique.Foundations.People.FluentValidation**](./src/People.FluentValidation/README.md) | FluentValidation extensions for `FirstName` and `LastName` value objects. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentValidation) | +|PosInformatique.Foundations.People.Json icon|[**PosInformatique.Foundations.People.Json**](./src/People.Json/README.md) | `System.Text.Json` converters for `FirstName` and `LastName`, with validation and easy registration via `AddPeopleConverters()`. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.People.Json) | > Note: Each package is completely independent. You install only what you need. diff --git a/src/EmailAddresses.Json/EmailAddresses.Json.csproj b/src/EmailAddresses.Json/EmailAddresses.Json.csproj index 9986d12..72b8550 100644 --- a/src/EmailAddresses.Json/EmailAddresses.Json.csproj +++ b/src/EmailAddresses.Json/EmailAddresses.Json.csproj @@ -6,7 +6,6 @@ Provides a System.Text.Json converter for the EmailAddress value object. Enables seamless serialization and deserialization of RFC 5322 compliant email addresses within JSON documents. - Designed to integrate smoothly with System.Text.Json options, ensuring consistent validation and parsing during JSON processing. email;emailaddress;valueobject;ddd;json;rfc5322;parsing;validation;dotnet;posinformatique diff --git a/src/People.DataAnnotations/CHANGELOG.md b/src/People.DataAnnotations/CHANGELOG.md new file mode 100644 index 0000000..80897be --- /dev/null +++ b/src/People.DataAnnotations/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with the support Data Annotations for the validation of FirstName and LastName value objects. diff --git a/src/People.DataAnnotations/FirstNameAttribute.cs b/src/People.DataAnnotations/FirstNameAttribute.cs new file mode 100644 index 0000000..d9a86c6 --- /dev/null +++ b/src/People.DataAnnotations/FirstNameAttribute.cs @@ -0,0 +1,43 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People.DataAnnotations +{ + using System.ComponentModel.DataAnnotations; + + /// + /// Validates that a string is a valid first name. + /// + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public sealed class FirstNameAttribute : ValidationAttribute + { + private static readonly string AllowedSeparators = string.Join(", ", FirstName.AllowedSeparators.Select(s => $"'{s}'")); + + /// + /// Initializes a new instance of the class. + /// + public FirstNameAttribute() + : base(() => string.Format(PeopleDataAnnotationsResources.InvalidFirstName, AllowedSeparators)) + { + } + + /// + public override bool IsValid(object? value) + { + if (value is null) + { + return true; + } + + if (value is not string firstName) + { + return true; + } + + return FirstName.IsValid(firstName); + } + } +} diff --git a/src/People.DataAnnotations/LastNameAttribute.cs b/src/People.DataAnnotations/LastNameAttribute.cs new file mode 100644 index 0000000..bd89034 --- /dev/null +++ b/src/People.DataAnnotations/LastNameAttribute.cs @@ -0,0 +1,43 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People.DataAnnotations +{ + using System.ComponentModel.DataAnnotations; + + /// + /// Validates that a string is a valid last name. + /// + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public sealed class LastNameAttribute : ValidationAttribute + { + private static readonly string AllowedSeparators = string.Join(", ", LastName.AllowedSeparators.Select(s => $"'{s}'")); + + /// + /// Initializes a new instance of the class. + /// + public LastNameAttribute() + : base(() => string.Format(PeopleDataAnnotationsResources.InvalidLastName, AllowedSeparators)) + { + } + + /// + public override bool IsValid(object? value) + { + if (value is null) + { + return true; + } + + if (value is not string lastName) + { + return true; + } + + return LastName.IsValid(lastName); + } + } +} diff --git a/src/People.DataAnnotations/People.DataAnnotations.csproj b/src/People.DataAnnotations/People.DataAnnotations.csproj new file mode 100644 index 0000000..4c22589 --- /dev/null +++ b/src/People.DataAnnotations/People.DataAnnotations.csproj @@ -0,0 +1,49 @@ + + + + true + + + Provides DataAnnotations attributes to validate first and last names + using the strongly-typed FirstName and LastName value objects from PosInformatique.Foundations.People. + + dataannotations;validation;firstname;lastname;people;names;ddd;valueobject;parsing;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + + + True + True + PeopleDataAnnotationsResources.resx + + + + + + + + + ResXFileCodeGenerator + PeopleDataAnnotationsResources.Designer.cs + + + + \ No newline at end of file diff --git a/src/People.DataAnnotations/PeopleDataAnnotationsResources.Designer.cs b/src/People.DataAnnotations/PeopleDataAnnotationsResources.Designer.cs new file mode 100644 index 0000000..b4e0e8b --- /dev/null +++ b/src/People.DataAnnotations/PeopleDataAnnotationsResources.Designer.cs @@ -0,0 +1,82 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace PosInformatique.Foundations.People.DataAnnotations { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class PeopleDataAnnotationsResources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal PeopleDataAnnotationsResources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("PosInformatique.Foundations.People.DataAnnotations.PeopleDataAnnotationsResources" + + "", typeof(PeopleDataAnnotationsResources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to First name must contain only alphabetic characters or the separators [{0}].. + /// + internal static string InvalidFirstName { + get { + return ResourceManager.GetString("InvalidFirstName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Last name must contain only alphabetic characters or the separators [{0}].. + /// + internal static string InvalidLastName { + get { + return ResourceManager.GetString("InvalidLastName", resourceCulture); + } + } + } +} diff --git a/src/People.DataAnnotations/PeopleDataAnnotationsResources.fr.resx b/src/People.DataAnnotations/PeopleDataAnnotationsResources.fr.resx new file mode 100644 index 0000000..02a8948 --- /dev/null +++ b/src/People.DataAnnotations/PeopleDataAnnotationsResources.fr.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Le prénom doit contenir uniquement des caractères alphabétiques ou les séparateurs [{0}]. + + + Le nom doit contenir uniquement des caractères alphabétiques ou les séparateurs [{0}]. + + \ No newline at end of file diff --git a/src/People.DataAnnotations/PeopleDataAnnotationsResources.resx b/src/People.DataAnnotations/PeopleDataAnnotationsResources.resx new file mode 100644 index 0000000..99437e3 --- /dev/null +++ b/src/People.DataAnnotations/PeopleDataAnnotationsResources.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + First name must contain only alphabetic characters or the separators [{0}]. + + + Last name must contain only alphabetic characters or the separators [{0}]. + + \ No newline at end of file diff --git a/src/People.DataAnnotations/README.md b/src/People.DataAnnotations/README.md new file mode 100644 index 0000000..11c3dbf --- /dev/null +++ b/src/People.DataAnnotations/README.md @@ -0,0 +1,134 @@ +# PosInformatique.Foundations.People.DataAnnotations + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.DataAnnotations)](https://www.nuget.org/packages/PosInformatique.Foundations.People.DataAnnotations/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.People.DataAnnotations)](https://www.nuget.org/packages/PosInformatique.Foundations.People.DataAnnotations/) + +## Introduction +This package provides .NET `DataAnnotations` attributes to validate first names and last names using the `FirstName` and `LastName` value objects +from [PosInformatique.Foundations.People](https://www.nuget.org/packages/PosInformatique.Foundations.People/). + +It allows you to apply robust name validation directly on your models with attributes like `[FirstName]` attribute and `[LastName]`, ensuring that string properties conform to the business rules for first and last names. + +## Install +You can install the package from NuGet: + +```powershell +dotnet add package PosInformatique.Foundations.People.DataAnnotations +``` + +## Features +- `DataAnnotations` attributes for first name and last name validation based on the business rules of the [PosInformatique.Foundations.People](https://www.nuget.org/packages/PosInformatique.Foundations.People/) package. +- Uses the same parsing and validation rules as the `FirstName` and `LastName` value objects. +- Clear and consistent error messages. +> `null` values are accepted (combine with `[Required]` attribute to forbid nulls). + +## Examples + +### Validating FirstName +```csharp +using System.ComponentModel.DataAnnotations; +using PosInformatique.Foundations.People.DataAnnotations; + +public class Person +{ + // FirstName must be a valid FirstName (e.g., "John", "Jean-Pierre") + // Null values are allowed by default. Use [Required] to disallow. + [FirstName] + public string? FirstName { get; set; } + + public string? LastName { get; set; } +} + +// Usage +var person1 = new Person { FirstName = "John", LastName = "DOE" }; +var context = new ValidationContext(person1); +var results = new List(); +var isValid1 = Validator.TryValidateObject(person1, context, results, validateAllProperties: true); // true + +var person2 = new Person { FirstName = "John_123", LastName = "DOE" }; +context = new ValidationContext(person2); +results = new List(); +var isValid2 = Validator.TryValidateObject(person2, context, results, validateAllProperties: true); // false + +var person3 = new Person { FirstName = new string('A', 51), LastName = "DOE" }; +context = new ValidationContext(person3); +results = new List(); +var isValid3 = Validator.TryValidateObject(person3, context, results, validateAllProperties: true); // false + +// Null (valid by default for [FirstName]) +var person4 = new Person { FirstName = null, LastName = "DOE" }; +context = new ValidationContext(person4); +results = new List(); +var isValid4 = Validator.TryValidateObject(person4, context, results, validateAllProperties: true); // true + +// Null (invalid if [Required] is used) +public class PersonRequiredFirstName +{ + [Required] + [FirstName] + public string? FirstName { get; set; } +} + +var person5 = new PersonRequiredFirstName { FirstName = null }; +context = new ValidationContext(person5); +results = new List(); +var isValid5 = Validator.TryValidateObject(person5, context, results, validateAllProperties: true); // false +``` + +### Validating LastName +```csharp +using System.ComponentModel.DataAnnotations; +using PosInformatique.Foundations.People.DataAnnotations; + +public class Person +{ + public string? FirstName { get; set; } + + // LastName must be a valid LastName (e.g., "DOE", "SMITH-JOHNSON") + // Null values are allowed by default. Use [Required] to disallow. + [LastName] + public string? LastName { get; set; } +} + +// Usage +var person1 = new Person { FirstName = "John", LastName = "DOE" }; +var context = new ValidationContext(person1); +var results = new List(); +var isValid1 = Validator.TryValidateObject(person1, context, results, validateAllProperties: true); // true + +var person2 = new Person { FirstName = "John", LastName = "DOE_123" }; +context = new ValidationContext(person2); +results = new List(); +var isValid2 = Validator.TryValidateObject(person2, context, results, validateAllProperties: true); // false + +var person3 = new Person { FirstName = "John", LastName = new string('A', 51) }; +context = new ValidationContext(person3); +results = new List(); +var isValid3 = Validator.TryValidateObject(person3, context, results, validateAllProperties: true); // false + +// Null (valid by default for [LastName]) +var person4 = new Person { FirstName = "John", LastName = null }; +context = new ValidationContext(person4); +results = new List(); +var isValid4 = Validator.TryValidateObject(person4, context, results, validateAllProperties: true); // true + +// Null (invalid if [Required] is used) +public class PersonRequiredLastName +{ + public string? FirstName { get; set; } + + [Required] + [LastName] + public string? LastName { get; set; } +} + +var person5 = new PersonRequiredLastName { FirstName = "John", LastName = null }; +context = new ValidationContext(person5); +results = new List(); +var isValid5 = Validator.TryValidateObject(person5, context, results, validateAllProperties: true); // false +``` + +## Links +- [NuGet package: People.DataAnnotations](https://www.nuget.org/packages/PosInformatique.Foundations.People.DataAnnotations/) +- [NuGet package: People (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.People/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) diff --git a/src/People.FluentValidation/People.FluentValidation.csproj b/src/People.FluentValidation/People.FluentValidation.csproj index c1fff22..2776a68 100644 --- a/src/People.FluentValidation/People.FluentValidation.csproj +++ b/src/People.FluentValidation/People.FluentValidation.csproj @@ -6,7 +6,6 @@ Provides FluentValidation extensions to validate first and last names using the strongly-typed FirstName and LastName value objects from PosInformatique.Foundations.People. - Ensures inputs follow business rules (letters only, space/hyphen separators, proper casing, max length 50). fluentvalidation;validation;firstname;lastname;people;names;ddd;valueobject;parsing;dotnet;posinformatique diff --git a/src/People.FluentValidation/README.md b/src/People.FluentValidation/README.md index 9afca8e..54ec23c 100644 --- a/src/People.FluentValidation/README.md +++ b/src/People.FluentValidation/README.md @@ -5,7 +5,7 @@ ## Introduction This package provides [FluentValidation](https://fluentvalidation.net/) extensions for validating first names and last names using the -`FirstName` and `LastName` value objects from `PosInformatique.Foundations.People`. +`FirstName` and `LastName` value objects from [PosInformatique.Foundations.People](https://www.nuget.org/packages/PosInformatique.Foundations.People/). It simplifies the integration of robust name validation into your [FluentValidation](https://fluentvalidation.net/) rules, ensuring that string properties conform to the defined business rules for first and last names. diff --git a/src/People.Json/CHANGELOG.md b/src/People.Json/CHANGELOG.md new file mode 100644 index 0000000..a19be50 --- /dev/null +++ b/src/People.Json/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with the support JSON serialization (with System.Text.Json) for FirstName and LastName value objects. diff --git a/src/People.Json/FirstNameJsonConverter.cs b/src/People.Json/FirstNameJsonConverter.cs new file mode 100644 index 0000000..5579fba --- /dev/null +++ b/src/People.Json/FirstNameJsonConverter.cs @@ -0,0 +1,45 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People.Json +{ + using System.Text.Json; + using System.Text.Json.Serialization; + + /// + /// which allows to serialize and deserialize an + /// as a JSON string. + /// + public sealed class FirstNameJsonConverter : JsonConverter + { + /// + public override bool HandleNull => true; + + /// + public override FirstName? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var input = reader.GetString(); + + if (input is null) + { + return null; + } + + if (!FirstName.TryCreate(input, out var firstName)) + { + throw new JsonException($"'{input}' is not a valid first name."); + } + + return firstName; + } + + /// + public override void Write(Utf8JsonWriter writer, FirstName value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); + } + } +} diff --git a/src/People.Json/LastNameJsonConverter.cs b/src/People.Json/LastNameJsonConverter.cs new file mode 100644 index 0000000..b80a868 --- /dev/null +++ b/src/People.Json/LastNameJsonConverter.cs @@ -0,0 +1,45 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People.Json +{ + using System.Text.Json; + using System.Text.Json.Serialization; + + /// + /// which allows to serialize and deserialize an + /// as a JSON string. + /// + public sealed class LastNameJsonConverter : JsonConverter + { + /// + public override bool HandleNull => true; + + /// + public override LastName? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var input = reader.GetString(); + + if (input is null) + { + return null; + } + + if (!LastName.TryCreate(input, out var lastName)) + { + throw new JsonException($"'{input}' is not a valid last name."); + } + + return lastName; + } + + /// + public override void Write(Utf8JsonWriter writer, LastName value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); + } + } +} diff --git a/src/People.Json/People.Json.csproj b/src/People.Json/People.Json.csproj new file mode 100644 index 0000000..e8136e5 --- /dev/null +++ b/src/People.Json/People.Json.csproj @@ -0,0 +1,31 @@ + + + + true + + + Provides System.Text.Json converters for the FirstName and LastName value objects. + Enables seamless serialization and deserialization of validated person names within JSON documents. + + people;firstname;lastname;name;valueobject;ddd;json;validation;parsing;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/People.Json/PeopleJsonSerializerOptionsExtensions.cs b/src/People.Json/PeopleJsonSerializerOptionsExtensions.cs new file mode 100644 index 0000000..3d062ae --- /dev/null +++ b/src/People.Json/PeopleJsonSerializerOptionsExtensions.cs @@ -0,0 +1,40 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace System.Text.Json +{ + using PosInformatique.Foundations.People.Json; + + /// + /// Contains extension methods to configure . + /// + public static class PeopleJsonSerializerOptionsExtensions + { + /// + /// Registers the and to the . + /// + /// which the and + /// converter will be added in the collection. + /// The instance to continue the configuration. + /// If the specified argument is . + public static JsonSerializerOptions AddPeopleConverters(this JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(options, nameof(options)); + + if (!options.Converters.Any(c => c.GetType() == typeof(FirstNameJsonConverter))) + { + options.Converters.Add(new FirstNameJsonConverter()); + } + + if (!options.Converters.Any(c => c.GetType() == typeof(LastNameJsonConverter))) + { + options.Converters.Add(new LastNameJsonConverter()); + } + + return options; + } + } +} diff --git a/src/People.Json/README.md b/src/People.Json/README.md new file mode 100644 index 0000000..b012f88 --- /dev/null +++ b/src/People.Json/README.md @@ -0,0 +1,120 @@ +# PosInformatique.Foundations.People.Json + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.People.Json/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.People.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.People.Json/) + +## Introduction +Provides `System.Text.Json` converters for the `FirstName` and `LastName` value objects from +[PosInformatique.Foundations.People](../People/README.md). Enables seamless serialization and deserialization of validated names within JSON documents. + +## Install +You can install the package from NuGet: + +```powershell +dotnet add package PosInformatique.Foundations.People.Json +``` + +This package depends on the base package [PosInformatique.Foundations.People](https://www.nuget.org/packages/PosInformatique.Foundations.People/). + +## Features +- `JsonConverter` and `JsonConverter` for serialization and deserialization. +- Validation on deserialization using `FirstName.TryCreate` and `LastName.TryCreate` (throws `JsonException` on invalid input). +- Usable via `[JsonConverter]` attribute or via `JsonSerializerOptions` extension method `AddPeopleConverters()`. + +## Use cases +- Serialization: Persist `FirstName` and `LastName` as JSON strings. +- Validation: Ensure only valid names are accepted in JSON payloads. +- Integration: Plug directly into `System.Text.Json` configuration. + +## Examples + +### Example 1: DTOs with [JsonConverter] attributes +```csharp +using System.Text.Json; +using System.Text.Json.Serialization; +using PosInformatique.Foundations; +using PosInformatique.Foundations.People.Json; + +public sealed class PersonDto +{ + [JsonConverter(typeof(FirstNameJsonConverter))] + public FirstName? FirstName { get; set; } + + [JsonConverter(typeof(LastNameJsonConverter))] + public LastName? LastName { get; set; } +} + +// Serialization +var dto = new PersonDto +{ + FirstName = FirstName.Create("John"), + LastName = LastName.Create("Doe") +}; + +var json = JsonSerializer.Serialize(dto); +// Result: {"FirstName":"John","LastName":"Doe"} + +// Deserialization +var input = "{ \"FirstName\": \"Alice\", \"LastName\": \"Smith\" }"; +var deserialized = JsonSerializer.Deserialize(input); +``` + +### Example 2: Register converters globally with options +```csharp +using System.Text.Json; +using PosInformatique.Foundations; +using PosInformatique.Foundations.People.Json; + +public sealed class EmployeeDto +{ + public FirstName? FirstName { get; set; } + public LastName? LastName { get; set; } +} + +var options = new JsonSerializerOptions().AddPeopleConverters(); + +// Serialization +var employee = new EmployeeDto +{ + FirstName = FirstName.Create("Bob"), + LastName = LastName.Create("Marley") +}; + +var json = JsonSerializer.Serialize(employee, options); +// Result: {"FirstName":"Bob","LastName":"Marley"} + +// Deserialization +var input = "{ \"FirstName\": \"Carol\", \"LastName\": \"Johnson\" }"; +var deserialized = JsonSerializer.Deserialize(input, options); +``` + +### Example 3: Handling nulls and invalid values +```csharp +using System.Text.Json; +using PosInformatique.Foundations; +using PosInformatique.Foundations.People.Json; + +var options = new JsonSerializerOptions().AddPeopleConverters(); + +// Null handling +var jsonWithNulls = "{ \"FirstName\": null, \"LastName\": \"Doe\" }"; +var obj = JsonSerializer.Deserialize(jsonWithNulls, options); +// obj.FirstName == null, obj.LastName == "Doe" + +// Invalid value causes JsonException +try +{ + var invalid = "{ \"FirstName\": \"\", \"LastName\": \"Doe\" }"; + + JsonSerializer.Deserialize(invalid, options); +} +catch (JsonException ex) +{ + // "'': is not a valid first name." (message from converter) +} +``` + +## Links +- [NuGet package: People.Json](https://www.nuget.org/packages/PosInformatique.Foundations.People.Json/) +- [NuGet package: People (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.People/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) diff --git a/tests/People.DataAnnotations.Tests/FirstNameAttributeTest.cs b/tests/People.DataAnnotations.Tests/FirstNameAttributeTest.cs new file mode 100644 index 0000000..ddc814b --- /dev/null +++ b/tests/People.DataAnnotations.Tests/FirstNameAttributeTest.cs @@ -0,0 +1,46 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People.DataAnnotations.Tests +{ + using System.Globalization; + + public class FirstNameAttributeTest + { + [Theory] + [InlineData("any", "First name must contain only alphabetic characters or the separators [' ', '-'].")] + [InlineData("fr", "Le prénom doit contenir uniquement des caractères alphabétiques ou les séparateurs [' ', '-'].")] + public void FormatErrorMessage(string culture, string expectedErrorMessage) + { + PeopleDataAnnotationsResources.Culture = new CultureInfo(culture); + + var attribute = new FirstNameAttribute(); + + attribute.FormatErrorMessage(default).Should().Be(expectedErrorMessage); + } + + [Theory] + [InlineData(null)] + [InlineData("The first name")] + [InlineData(1234)] + public void IsValid_True(object value) + { + var attribute = new FirstNameAttribute(); + + attribute.IsValid(value).Should().BeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData("The first name $$")] + public void IsValid_False(object value) + { + var attribute = new FirstNameAttribute(); + + attribute.IsValid(value).Should().BeFalse(); + } + } +} diff --git a/tests/People.DataAnnotations.Tests/LastNameAttributeTest.cs b/tests/People.DataAnnotations.Tests/LastNameAttributeTest.cs new file mode 100644 index 0000000..17b27b7 --- /dev/null +++ b/tests/People.DataAnnotations.Tests/LastNameAttributeTest.cs @@ -0,0 +1,46 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People.DataAnnotations.Tests +{ + using System.Globalization; + + public class LastNameAttributeTest + { + [Theory] + [InlineData("any", "Last name must contain only alphabetic characters or the separators [' ', '-'].")] + [InlineData("fr", "Le nom doit contenir uniquement des caractères alphabétiques ou les séparateurs [' ', '-'].")] + public void FormatErrorMessage(string culture, string expectedErrorMessage) + { + PeopleDataAnnotationsResources.Culture = new CultureInfo(culture); + + var attribute = new LastNameAttribute(); + + attribute.FormatErrorMessage(default).Should().Be(expectedErrorMessage); + } + + [Theory] + [InlineData(null)] + [InlineData("The first name")] + [InlineData(1234)] + public void IsValid_True(object value) + { + var attribute = new LastNameAttribute(); + + attribute.IsValid(value).Should().BeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData("The last name $$")] + public void IsValid_False(object value) + { + var attribute = new LastNameAttribute(); + + attribute.IsValid(value).Should().BeFalse(); + } + } +} diff --git a/tests/People.DataAnnotations.Tests/People.DataAnnotations.Tests.csproj b/tests/People.DataAnnotations.Tests/People.DataAnnotations.Tests.csproj new file mode 100644 index 0000000..ce0a53d --- /dev/null +++ b/tests/People.DataAnnotations.Tests/People.DataAnnotations.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/People.Json.Tests/FirstNameJsonConverterTest.cs b/tests/People.Json.Tests/FirstNameJsonConverterTest.cs new file mode 100644 index 0000000..7860aa2 --- /dev/null +++ b/tests/People.Json.Tests/FirstNameJsonConverterTest.cs @@ -0,0 +1,118 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People.Json.Tests +{ + using System.Text.Json; + + public class FirstNameJsonConverterTest + { + [Fact] + public void Serialization() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new FirstNameJsonConverter(), + }, + }; + + var @object = new JsonClass + { + StringValue = "The string value", + FirstName = "The first name", + }; + + @object.Should().BeJsonSerializableInto( + new + { + StringValue = "The string value", + FirstName = "The First Name", + }, + options); + } + + [Fact] + public void Deserialization() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new FirstNameJsonConverter(), + }, + }; + + var json = new + { + StringValue = "The string value", + FirstName = "The first name", + }; + + json.Should().BeJsonDeserializableInto( + new JsonClass + { + StringValue = "The string value", + FirstName = @"The first name", + }, + options); + } + + [Fact] + public void Deserialization_WithNullValue() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new FirstNameJsonConverter(), + }, + }; + + var json = new + { + StringValue = "The string value", + FirstName = (string)null, + }; + + json.Should().BeJsonDeserializableInto( + new JsonClass + { + StringValue = "The string value", + FirstName = null, + }, + options); + } + + [Fact] + public void Deserialization_WithInvalidEmail() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new FirstNameJsonConverter(), + }, + }; + + var act = () => + { + JsonSerializer.Deserialize("{\"StringValue\":\"\",\"FirstName\":\"The $$ first name\"}", options); + }; + + act.Should().ThrowExactly() + .WithMessage("'The $$ first name' is not a valid first name."); + } + + private class JsonClass + { + public string StringValue { get; set; } + + public FirstName FirstName { get; set; } + } + } +} diff --git a/tests/People.Json.Tests/LastNameJsonConverterTest.cs b/tests/People.Json.Tests/LastNameJsonConverterTest.cs new file mode 100644 index 0000000..4e4ec75 --- /dev/null +++ b/tests/People.Json.Tests/LastNameJsonConverterTest.cs @@ -0,0 +1,118 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People.Json.Tests +{ + using System.Text.Json; + + public class LastNameJsonConverterTest + { + [Fact] + public void Serialization() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new LastNameJsonConverter(), + }, + }; + + var @object = new JsonClass + { + StringValue = "The string value", + LastName = "The last name", + }; + + @object.Should().BeJsonSerializableInto( + new + { + StringValue = "The string value", + LastName = "THE LAST NAME", + }, + options); + } + + [Fact] + public void Deserialization() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new LastNameJsonConverter(), + }, + }; + + var json = new + { + StringValue = "The string value", + LastName = "The last name", + }; + + json.Should().BeJsonDeserializableInto( + new JsonClass + { + StringValue = "The string value", + LastName = @"The last name", + }, + options); + } + + [Fact] + public void Deserialization_WithNullValue() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new LastNameJsonConverter(), + }, + }; + + var json = new + { + StringValue = "The string value", + LastName = (string)null, + }; + + json.Should().BeJsonDeserializableInto( + new JsonClass + { + StringValue = "The string value", + LastName = null, + }, + options); + } + + [Fact] + public void Deserialization_WithInvalidEmail() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new LastNameJsonConverter(), + }, + }; + + var act = () => + { + JsonSerializer.Deserialize("{\"StringValue\":\"\",\"LastName\":\"The $$ last name\"}", options); + }; + + act.Should().ThrowExactly() + .WithMessage("'The $$ last name' is not a valid last name."); + } + + private class JsonClass + { + public string StringValue { get; set; } + + public LastName LastName { get; set; } + } + } +} diff --git a/tests/People.Json.Tests/People.Json.Tests.csproj b/tests/People.Json.Tests/People.Json.Tests.csproj new file mode 100644 index 0000000..e182679 --- /dev/null +++ b/tests/People.Json.Tests/People.Json.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/People.Json.Tests/PeopleJsonSerializerOptionsExtensionsTest.cs b/tests/People.Json.Tests/PeopleJsonSerializerOptionsExtensionsTest.cs new file mode 100644 index 0000000..dd8a5c9 --- /dev/null +++ b/tests/People.Json.Tests/PeopleJsonSerializerOptionsExtensionsTest.cs @@ -0,0 +1,44 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace System.Text.Json.Tests +{ + using PosInformatique.Foundations.People.Json; + + public class PeopleJsonSerializerOptionsExtensionsTest + { + [Fact] + public void AddEmailAddressesConverters() + { + var options = new JsonSerializerOptions(); + + options.AddPeopleConverters(); + + options.Converters.Should().HaveCount(2); + options.Converters[0].Should().BeOfType(); + options.Converters[1].Should().BeOfType(); + + // Call again to check nothing has been changed. + options.AddPeopleConverters(); + + options.Converters.Should().HaveCount(2); + options.Converters[0].Should().BeOfType(); + options.Converters[1].Should().BeOfType(); + } + + [Fact] + public void AddEmailAddressesConverters_WithNullArgument() + { + var act = () => + { + PeopleJsonSerializerOptionsExtensions.AddPeopleConverters(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("options"); + } + } +} From 4118db83abcd2d84a033c8c63ad542283b48e805 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 10 Oct 2025 16:41:29 +0200 Subject: [PATCH 24/73] Add the People.FluentAssertions package. --- PosInformatique.Foundations.sln | 17 ++++ README.md | 3 +- src/People.FluentAssertions/CHANGELOG.md | 2 + .../FirstNameAssertions.cs | 40 +++++++++ .../LastNameAssertions.cs | 40 +++++++++ .../People.FluentAssertions.csproj | 36 ++++++++ .../PeopleAssertionsExtensions.cs | 44 +++++++++ src/People.FluentAssertions/README.md | 90 +++++++++++++++++++ .../People.FluentAssertions.Tests.csproj | 7 ++ .../PeopleAssertionsExtensionsTest.cs | 59 ++++++++++++ 10 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 src/People.FluentAssertions/CHANGELOG.md create mode 100644 src/People.FluentAssertions/FirstNameAssertions.cs create mode 100644 src/People.FluentAssertions/LastNameAssertions.cs create mode 100644 src/People.FluentAssertions/People.FluentAssertions.csproj create mode 100644 src/People.FluentAssertions/PeopleAssertionsExtensions.cs create mode 100644 src/People.FluentAssertions/README.md create mode 100644 tests/People.FluentAssertions.Tests/People.FluentAssertions.Tests.csproj create mode 100644 tests/People.FluentAssertions.Tests/PeopleAssertionsExtensionsTest.cs diff --git a/PosInformatique.Foundations.sln b/PosInformatique.Foundations.sln index 9877ff4..4df140d 100644 --- a/PosInformatique.Foundations.sln +++ b/PosInformatique.Foundations.sln @@ -88,6 +88,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.DataAnnotations", "s EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.DataAnnotations.Tests", "tests\People.DataAnnotations.Tests\People.DataAnnotations.Tests.csproj", "{AFFBCD07-8D7F-4DC6-91E6-76D993345D0B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.FluentAssertions", "src\People.FluentAssertions\People.FluentAssertions.csproj", "{E1580130-A749-4EC7-8E88-CBB5A01C12D3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.FluentAssertions.Tests", "tests\People.FluentAssertions.Tests\People.FluentAssertions.Tests.csproj", "{8DF67451-D99C-4027-93F2-C77D9B4CD6F3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "FluentAssertions", "FluentAssertions", "{BF01C64B-7C68-46C3-A7AD-084B4BE5AE38}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -166,6 +172,14 @@ Global {AFFBCD07-8D7F-4DC6-91E6-76D993345D0B}.Debug|Any CPU.Build.0 = Debug|Any CPU {AFFBCD07-8D7F-4DC6-91E6-76D993345D0B}.Release|Any CPU.ActiveCfg = Release|Any CPU {AFFBCD07-8D7F-4DC6-91E6-76D993345D0B}.Release|Any CPU.Build.0 = Release|Any CPU + {E1580130-A749-4EC7-8E88-CBB5A01C12D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E1580130-A749-4EC7-8E88-CBB5A01C12D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E1580130-A749-4EC7-8E88-CBB5A01C12D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E1580130-A749-4EC7-8E88-CBB5A01C12D3}.Release|Any CPU.Build.0 = Release|Any CPU + {8DF67451-D99C-4027-93F2-C77D9B4CD6F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8DF67451-D99C-4027-93F2-C77D9B4CD6F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8DF67451-D99C-4027-93F2-C77D9B4CD6F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8DF67451-D99C-4027-93F2-C77D9B4CD6F3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -200,6 +214,9 @@ Global {294EA128-CE0C-40CC-B3E6-CF1F40D3DC80} = {A94B7ED9-45FB-4D57-8B9D-B52C47F72D0A} {6B65DB34-1360-4D74-A925-57DE07009D2E} = {1EB29D6C-ABD8-4F9D-8608-72B69DD9B756} {AFFBCD07-8D7F-4DC6-91E6-76D993345D0B} = {1EB29D6C-ABD8-4F9D-8608-72B69DD9B756} + {E1580130-A749-4EC7-8E88-CBB5A01C12D3} = {BF01C64B-7C68-46C3-A7AD-084B4BE5AE38} + {8DF67451-D99C-4027-93F2-C77D9B4CD6F3} = {BF01C64B-7C68-46C3-A7AD-084B4BE5AE38} + {BF01C64B-7C68-46C3-A7AD-084B4BE5AE38} = {F2473697-B13F-422F-A267-DA263F56025F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {344068EF-5958-4241-BD83-86403ADA68F1} diff --git a/README.md b/README.md index 270ac75..e57f27e 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ You can install any package using the .NET CLI or NuGet Package Manager. |PosInformatique.Foundations.People icon|[**PosInformatique.Foundations.People**](./src/People/README.md) | Strongly-typed value objects for first and last names with validation and normalization. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People)](https://www.nuget.org/packages/PosInformatique.Foundations.People) | |PosInformatique.Foundations.People.DataAnnotations icon|[**PosInformatique.Foundations.People.DataAnnotations**](./src/People.DataAnnotations/README.md) | DataAnnotations attributes for `FirstName` and `LastName` value objects. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.DataAnnotations)](https://www.nuget.org/packages/PosInformatique.Foundations.People.DataAnnotations) | |PosInformatique.Foundations.People.EntityFramework icon|[**PosInformatique.Foundations.People.EntityFramework**](./src/People.EntityFramework/README.md) | Entity Framework Core integration for `FirstName` and `LastName` value objects, providing fluent property configuration and value converters. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.People.EntityFramework) | -|PosInformatique.Foundations.People.FluentValidation icon|[**PosInformatique.Foundations.People.FluentValidation**](./src/People.FluentValidation/README.md) | FluentValidation extensions for `FirstName` and `LastName` value objects. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentValidation) | +|PosInformatique.Foundations.People.FluentAssertions icon|[**PosInformatique.Foundations.People.FluentAssertions**](./src/People.FluentAssertions/README.md) | [FluentAssertions](https://fluentassertions.com/) extensions for `FirstName` and `LastName` to avoid ambiguity and provide `Should().Be(string)` assertions (case-sensitive on normalized values). | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.FluentAssertions)](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentAssertions) | +|PosInformatique.Foundations.People.FluentValidation icon|[**PosInformatique.Foundations.People.FluentValidation**](./src/People.FluentValidation/README.md) | [FluentValidation](https://fluentvalidation.net/) extensions for `FirstName` and `LastName` value objects. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentValidation) | |PosInformatique.Foundations.People.Json icon|[**PosInformatique.Foundations.People.Json**](./src/People.Json/README.md) | `System.Text.Json` converters for `FirstName` and `LastName`, with validation and easy registration via `AddPeopleConverters()`. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.People.Json) | > Note: Each package is completely independent. You install only what you need. diff --git a/src/People.FluentAssertions/CHANGELOG.md b/src/People.FluentAssertions/CHANGELOG.md new file mode 100644 index 0000000..c091464 --- /dev/null +++ b/src/People.FluentAssertions/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with the support FluentAssertions to assert the FirstName and LastName value objects. diff --git a/src/People.FluentAssertions/FirstNameAssertions.cs b/src/People.FluentAssertions/FirstNameAssertions.cs new file mode 100644 index 0000000..e7aa18c --- /dev/null +++ b/src/People.FluentAssertions/FirstNameAssertions.cs @@ -0,0 +1,40 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People +{ + using FluentAssertions; + using FluentAssertions.Primitives; + + /// + /// Contains assert methods to check the instances. + /// + public sealed class FirstNameAssertions : ObjectAssertions + { + internal FirstNameAssertions(FirstName value) + : base(value) + { + } + + /// + /// Asserts that is exactly the same as another string. + /// + /// The expected first name in . + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// Zero or more objects to format using the placeholders in . + /// An instance of with a to continue + /// assertion on the value. + public AndConstraint Be(string firstName, string? because = null, params object[] becauseArgs) + { + var assertion = new StringAssertions(this.Subject.ToString()); + + assertion.Be(firstName, because, becauseArgs); + + return new AndConstraint(this); + } + } +} diff --git a/src/People.FluentAssertions/LastNameAssertions.cs b/src/People.FluentAssertions/LastNameAssertions.cs new file mode 100644 index 0000000..517386b --- /dev/null +++ b/src/People.FluentAssertions/LastNameAssertions.cs @@ -0,0 +1,40 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People +{ + using FluentAssertions; + using FluentAssertions.Primitives; + + /// + /// Contains assert methods to check the instances. + /// + public sealed class LastNameAssertions : ObjectAssertions + { + internal LastNameAssertions(LastName value) + : base(value) + { + } + + /// + /// Asserts that is exactly the same as another string. + /// + /// The expected first name in . + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// Zero or more objects to format using the placeholders in . + /// An instance of with a to continue + /// assertion on the string value. + public AndConstraint Be(string lastName, string? because = null, params object[] becauseArgs) + { + var assertion = new StringAssertions(this.Subject.ToString()); + + assertion.Be(lastName, because, becauseArgs); + + return new AndConstraint(this); + } + } +} diff --git a/src/People.FluentAssertions/People.FluentAssertions.csproj b/src/People.FluentAssertions/People.FluentAssertions.csproj new file mode 100644 index 0000000..59551e0 --- /dev/null +++ b/src/People.FluentAssertions/People.FluentAssertions.csproj @@ -0,0 +1,36 @@ + + + + true + + + Provides FluentAssertions extensions for FirstName and LastName value objects + from PosInformatique.Foundations.People, resolving the Should() ambiguity and + enabling idiomatic assertions like Should().Be(string) on normalized values. + + fluentassertions;assertions;testing;unittest;firstname;lastname;people;names;ddd;valueobject;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/People.FluentAssertions/PeopleAssertionsExtensions.cs b/src/People.FluentAssertions/PeopleAssertionsExtensions.cs new file mode 100644 index 0000000..b4ca736 --- /dev/null +++ b/src/People.FluentAssertions/PeopleAssertionsExtensions.cs @@ -0,0 +1,44 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace FluentAssertions +{ + using PosInformatique.Foundations.People; + + /// + /// Contains extension methods for custom assertions in unit tests. + /// + public static class PeopleAssertionsExtensions + { + /// + /// Returns an object that can be used to assert the + /// current . + /// + /// The to assert. + /// An instance of the which allows to assert the . + /// If the specified argument is . + public static FirstNameAssertions Should(this FirstName subject) + { + ArgumentNullException.ThrowIfNull(subject, nameof(subject)); + + return new FirstNameAssertions(subject); + } + + /// + /// Returns an object that can be used to assert the + /// current . + /// + /// The to assert. + /// An instance of the which allows to assert the . + /// If the specified argument is . + public static LastNameAssertions Should(this LastName subject) + { + ArgumentNullException.ThrowIfNull(subject, nameof(subject)); + + return new LastNameAssertions(subject); + } + } +} diff --git a/src/People.FluentAssertions/README.md b/src/People.FluentAssertions/README.md new file mode 100644 index 0000000..1849f24 --- /dev/null +++ b/src/People.FluentAssertions/README.md @@ -0,0 +1,90 @@ +# PosInformatique.Foundations.People.FluentAssertions + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.FluentAssertions)](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentAssertions/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.People.FluentAssertions)](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentAssertions/) + +## Introduction +Assertion extensions for `FirstName` and `LastName` value objects from +[PosInformatique.Foundations.People](https://www.nuget.org/packages/PosInformatique.Foundations.FluentAssertions/) +using [FluentAssertions](https://fluentassertions.com/). + +This package resolves the ambiguity that occurs when using [FluentAssertions](https://fluentassertions.com/) directly on these value +objects and provides a simple, idiomatic assertion API where `Be(string)` compares the literal content +(case-sensitive for `FirstName`, uppercased for `LastName` as per normalization). + +Why this package? +- `FirstName` and `LastName` implement both `IEnumerable` and `IComparable`. +- Calling `Should()` from [FluentAssertions](https://fluentassertions.com/) on such types leads to a compile-time ambiguity: + - The call is ambiguous between the following methods or properties: `AssertionExtensions.Should(IEnumerable)` + and `AssertionExtensions.Should(IComparable)` +- This package introduces dedicated `Should()` extensions that return specialized assertions to avoid that ambiguity. + +## Install +You can install the package from NuGet: + +```powershell +dotnet add package PosInformatique.Foundations.People.FluentAssertions +``` + +## Features +- `Should()` extension for `FirstName` returning `FirstNameAssertions`. +- `Should()` extension for `LastName` returning `LastNameAssertions`. +- `Be(string)` compares the value object to a string using the normalized literal content: + - For `FirstName`, comparison is case-sensitive against the normalized first name (e.g., "Jean-Pierre"). + - For `LastName`, comparison is case-sensitive against the normalized last name (e.g., "DUPONT"). + +## Examples +Basic usage with `FirstName`: +```csharp +using FluentAssertions; +using PosInformatique.Foundations.People; + +var firstName = FirstName.Create("jean-pierre"); + +// Passes: "jean-pierre" is normalized to "Jean-Pierre" +firstName.Should().Be("Jean-Pierre"); + +// Fails (case-sensitive): expected "JEAN-PIERRE" +firstName.Should().Be("JEAN-PIERRE"); +``` + +Basic usage with `LastName`: +```csharp +using FluentAssertions; +using PosInformatique.Foundations.People; + +var lastName = LastName.Create("dupont"); + +// Passes: normalization uppercases to "DUPONT" +lastName.Should().Be("DUPONT"); + +// Fails (case-sensitive): expected "Dupont" +lastName.Should().Be("Dupont"); +``` + +Using with your domain model: +```csharp +using FluentAssertions; +using PosInformatique.Foundations.People; + +public sealed class User +{ + public User(string firstName, string lastName) + { + FirstName = FirstName.Create(firstName); + LastName = LastName.Create(lastName); + } + + public FirstName FirstName { get; } + public LastName LastName { get; } +} + +var user = new User("alice", "martin"); +user.FirstName.Should().Be("Alice"); +user.LastName.Should().Be("MARTIN"); +``` + +## Links +- [NuGet package: People.FluentAssertions](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentAssertions/) +- [NuGet package: People (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.FluentAssertions/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) diff --git a/tests/People.FluentAssertions.Tests/People.FluentAssertions.Tests.csproj b/tests/People.FluentAssertions.Tests/People.FluentAssertions.Tests.csproj new file mode 100644 index 0000000..3e58501 --- /dev/null +++ b/tests/People.FluentAssertions.Tests/People.FluentAssertions.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/People.FluentAssertions.Tests/PeopleAssertionsExtensionsTest.cs b/tests/People.FluentAssertions.Tests/PeopleAssertionsExtensionsTest.cs new file mode 100644 index 0000000..0a11700 --- /dev/null +++ b/tests/People.FluentAssertions.Tests/PeopleAssertionsExtensionsTest.cs @@ -0,0 +1,59 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.People.FluentAssertions.Tests +{ + using Xunit.Sdk; + + public class PeopleAssertionsExtensionsTest + { + [Fact] + public void FirstName_Be() + { + var firstName = FirstName.Create("john"); + + firstName.Should().Be(FirstName.Create("john")) + .And.NotBeNull(); + firstName.Should().Be("John") + .And.NotBeNull(); + } + + [Theory] + [InlineData(null, null, "Expected string to be \"john\", but \"John\" differs near \"Joh\" (index 0).")] + [InlineData("Because {0}", 10, "Expected string to be \"john\" Because 10, but \"John\" differs near \"Joh\" (index 0).")] + public void FirstName_BeFailed(string because, object becauseArgs, string expectedMessage) + { + var firstName = FirstName.Create("John"); + + firstName.Should().Invoking(f => f.Be("john", because, becauseArgs)) + .Should().ThrowExactly() + .WithMessage(expectedMessage); + } + + [Fact] + public void LastName_Be() + { + var lastName = LastName.Create("Doe"); + + lastName.Should().Be(LastName.Create("doe")) + .And.NotBeNull(); + lastName.Should().Be("DOE") + .And.NotBeNull(); + } + + [Theory] + [InlineData(null, null, "Expected string to be \"doe\", but \"DOE\" differs near \"DOE\" (index 0).")] + [InlineData("Because {0}", 10, "Expected string to be \"doe\" Because 10, but \"DOE\" differs near \"DOE\" (index 0).")] + public void LastName_BeFailed(string because, object becauseArgs, string expectedMessage) + { + var lastName = LastName.Create("Doe"); + + lastName.Should().Invoking(f => f.Be("doe", because, becauseArgs)) + .Should().ThrowExactly() + .WithMessage(expectedMessage); + } + } +} From e6a876c622d744c0fc2c71cbdfb2cf79666e5573 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 10 Oct 2025 16:47:36 +0200 Subject: [PATCH 25/73] Migrate the solution to .slnx --- PosInformatique.Foundations.sln | 224 ------------------------------- PosInformatique.Foundations.slnx | 64 +++++++++ 2 files changed, 64 insertions(+), 224 deletions(-) delete mode 100644 PosInformatique.Foundations.sln create mode 100644 PosInformatique.Foundations.slnx diff --git a/PosInformatique.Foundations.sln b/PosInformatique.Foundations.sln deleted file mode 100644 index 4df140d..0000000 --- a/PosInformatique.Foundations.sln +++ /dev/null @@ -1,224 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36511.14 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses", "src\EmailAddresses\EmailAddresses.csproj", "{6B43B51B-A93C-4B4F-AD4D-4B74A2E7DB3F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.Tests", "tests\EmailAddresses.Tests\EmailAddresses.Tests.csproj", "{BAE006E4-4A1E-4E52-8CA1-D341BD0B26EE}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "00 - Solution Items", "00 - Solution Items", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - .gitignore = .gitignore - Directory.Build.props = Directory.Build.props - Directory.Packages.props = Directory.Packages.props - Icon.png = Icon.png - LICENSE = LICENSE - README.md = README.md - stylecop.json = stylecop.json - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{DFE1E9A1-3CB7-4FA4-B304-711772346C43}" - ProjectSection(SolutionItems) = preProject - tests\.editorconfig = tests\.editorconfig - tests\Directory.Build.props = tests\Directory.Build.props - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{FAA7960F-95C1-45E2-9A42-EED477DF97F1}" - ProjectSection(SolutionItems) = preProject - src\Directory.Build.props = src\Directory.Build.props - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{99066C81-F6AB-4A66-9E52-9D324A840307}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{CE76142F-BAA5-4D79-9CBB-9C298378FFF9}" - ProjectSection(SolutionItems) = preProject - .github\workflows\github-actions-ci.yaml = .github\workflows\github-actions-ci.yaml - .github\workflows\github-actions-release.yaml = .github\workflows\github-actions-release.yaml - EndProjectSection -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.Json", "src\EmailAddresses.Json\EmailAddresses.Json.csproj", "{C203E40E-37C1-49F0-B74C-E3559EB74DA7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.Json.Tests", "tests\EmailAddresses.Json.Tests\EmailAddresses.Json.Tests.csproj", "{3CEDE123-579A-4E3F-B32C-7FC53950075A}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EmailAddresses", "EmailAddresses", "{B461FB79-24F2-4091-BB91-F56392A0560D}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Json", "Json", "{EAC60D27-67F3-4A5E-8772-A2C6AE0631CB}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EntityFramework", "EntityFramework", "{BA63670E-1038-491A-85B2-76DC954A59A5}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.EntityFramework", "src\EmailAddresses.EntityFramework\EmailAddresses.EntityFramework.csproj", "{0EA33463-8B0B-057B-E76E-2D441A86832A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.EntityFramework.Tests", "tests\EmailAddresses.EntityFramework.Tests\EmailAddresses.EntityFramework.Tests.csproj", "{1BA02A1F-D02D-4FB1-99F9-A6740B593389}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "FluentValidation", "FluentValidation", "{80CB9DF4-D9EB-4E13-A78F-B716093A1B6C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.FluentValidation", "src\EmailAddresses.FluentValidation\EmailAddresses.FluentValidation.csproj", "{9B9B9120-25EE-4DAA-9D09-63B4BBBAEEAA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailAddresses.FluentValidation.Tests", "tests\EmailAddresses.FluentValidation.Tests\EmailAddresses.FluentValidation.Tests.csproj", "{0903F791-7EE4-4644-BA90-494798B1F4B3}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "People", "People", "{F2473697-B13F-422F-A267-DA263F56025F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People", "src\People\People.csproj", "{5CF087B0-0B63-40F5-8E7B-6DCE1FAA1EEE}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.Tests", "tests\People.Tests\People.Tests.csproj", "{E9727893-5089-49AD-B829-9A0C7478DE8D}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EntityFramework", "EntityFramework", "{AD76012D-0929-4FB7-BDF0-71B0C6CEA1C3}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "FluentValidation", "FluentValidation", "{02A575A9-DC41-4FF0-A05B-6E28CFA9E14D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.EntityFramework", "src\People.EntityFramework\People.EntityFramework.csproj", "{BA669488-A9F5-45B9-B58A-FD9478FDE6E3}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.EntityFramework.Tests", "tests\People.EntityFramework.Tests\People.EntityFramework.Tests.csproj", "{34F3C67F-4A9B-4307-B1F6-F229EE1CB152}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.FluentValidation", "src\People.FluentValidation\People.FluentValidation.csproj", "{DE51B2E1-27CA-4AFB-AC28-8C759012F230}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.FluentValidation.Tests", "tests\People.FluentValidation.Tests\People.FluentValidation.Tests.csproj", "{59412D14-DC4B-4583-9B33-B405BF81913D}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Json", "Json", "{A94B7ED9-45FB-4D57-8B9D-B52C47F72D0A}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DataAnnotations", "DataAnnotations", "{1EB29D6C-ABD8-4F9D-8608-72B69DD9B756}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.Json", "src\People.Json\People.Json.csproj", "{219AA4F0-E49E-4352-865F-D3DB450B0DB4}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.Json.Tests", "tests\People.Json.Tests\People.Json.Tests.csproj", "{294EA128-CE0C-40CC-B3E6-CF1F40D3DC80}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.DataAnnotations", "src\People.DataAnnotations\People.DataAnnotations.csproj", "{6B65DB34-1360-4D74-A925-57DE07009D2E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.DataAnnotations.Tests", "tests\People.DataAnnotations.Tests\People.DataAnnotations.Tests.csproj", "{AFFBCD07-8D7F-4DC6-91E6-76D993345D0B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.FluentAssertions", "src\People.FluentAssertions\People.FluentAssertions.csproj", "{E1580130-A749-4EC7-8E88-CBB5A01C12D3}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "People.FluentAssertions.Tests", "tests\People.FluentAssertions.Tests\People.FluentAssertions.Tests.csproj", "{8DF67451-D99C-4027-93F2-C77D9B4CD6F3}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "FluentAssertions", "FluentAssertions", "{BF01C64B-7C68-46C3-A7AD-084B4BE5AE38}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {6B43B51B-A93C-4B4F-AD4D-4B74A2E7DB3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6B43B51B-A93C-4B4F-AD4D-4B74A2E7DB3F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6B43B51B-A93C-4B4F-AD4D-4B74A2E7DB3F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6B43B51B-A93C-4B4F-AD4D-4B74A2E7DB3F}.Release|Any CPU.Build.0 = Release|Any CPU - {BAE006E4-4A1E-4E52-8CA1-D341BD0B26EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BAE006E4-4A1E-4E52-8CA1-D341BD0B26EE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BAE006E4-4A1E-4E52-8CA1-D341BD0B26EE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BAE006E4-4A1E-4E52-8CA1-D341BD0B26EE}.Release|Any CPU.Build.0 = Release|Any CPU - {C203E40E-37C1-49F0-B74C-E3559EB74DA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C203E40E-37C1-49F0-B74C-E3559EB74DA7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C203E40E-37C1-49F0-B74C-E3559EB74DA7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C203E40E-37C1-49F0-B74C-E3559EB74DA7}.Release|Any CPU.Build.0 = Release|Any CPU - {3CEDE123-579A-4E3F-B32C-7FC53950075A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3CEDE123-579A-4E3F-B32C-7FC53950075A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3CEDE123-579A-4E3F-B32C-7FC53950075A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3CEDE123-579A-4E3F-B32C-7FC53950075A}.Release|Any CPU.Build.0 = Release|Any CPU - {0EA33463-8B0B-057B-E76E-2D441A86832A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0EA33463-8B0B-057B-E76E-2D441A86832A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0EA33463-8B0B-057B-E76E-2D441A86832A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0EA33463-8B0B-057B-E76E-2D441A86832A}.Release|Any CPU.Build.0 = Release|Any CPU - {1BA02A1F-D02D-4FB1-99F9-A6740B593389}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1BA02A1F-D02D-4FB1-99F9-A6740B593389}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1BA02A1F-D02D-4FB1-99F9-A6740B593389}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1BA02A1F-D02D-4FB1-99F9-A6740B593389}.Release|Any CPU.Build.0 = Release|Any CPU - {9B9B9120-25EE-4DAA-9D09-63B4BBBAEEAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9B9B9120-25EE-4DAA-9D09-63B4BBBAEEAA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9B9B9120-25EE-4DAA-9D09-63B4BBBAEEAA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9B9B9120-25EE-4DAA-9D09-63B4BBBAEEAA}.Release|Any CPU.Build.0 = Release|Any CPU - {0903F791-7EE4-4644-BA90-494798B1F4B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0903F791-7EE4-4644-BA90-494798B1F4B3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0903F791-7EE4-4644-BA90-494798B1F4B3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0903F791-7EE4-4644-BA90-494798B1F4B3}.Release|Any CPU.Build.0 = Release|Any CPU - {5CF087B0-0B63-40F5-8E7B-6DCE1FAA1EEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5CF087B0-0B63-40F5-8E7B-6DCE1FAA1EEE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5CF087B0-0B63-40F5-8E7B-6DCE1FAA1EEE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5CF087B0-0B63-40F5-8E7B-6DCE1FAA1EEE}.Release|Any CPU.Build.0 = Release|Any CPU - {E9727893-5089-49AD-B829-9A0C7478DE8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E9727893-5089-49AD-B829-9A0C7478DE8D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E9727893-5089-49AD-B829-9A0C7478DE8D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E9727893-5089-49AD-B829-9A0C7478DE8D}.Release|Any CPU.Build.0 = Release|Any CPU - {BA669488-A9F5-45B9-B58A-FD9478FDE6E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BA669488-A9F5-45B9-B58A-FD9478FDE6E3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BA669488-A9F5-45B9-B58A-FD9478FDE6E3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BA669488-A9F5-45B9-B58A-FD9478FDE6E3}.Release|Any CPU.Build.0 = Release|Any CPU - {34F3C67F-4A9B-4307-B1F6-F229EE1CB152}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {34F3C67F-4A9B-4307-B1F6-F229EE1CB152}.Debug|Any CPU.Build.0 = Debug|Any CPU - {34F3C67F-4A9B-4307-B1F6-F229EE1CB152}.Release|Any CPU.ActiveCfg = Release|Any CPU - {34F3C67F-4A9B-4307-B1F6-F229EE1CB152}.Release|Any CPU.Build.0 = Release|Any CPU - {DE51B2E1-27CA-4AFB-AC28-8C759012F230}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DE51B2E1-27CA-4AFB-AC28-8C759012F230}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DE51B2E1-27CA-4AFB-AC28-8C759012F230}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DE51B2E1-27CA-4AFB-AC28-8C759012F230}.Release|Any CPU.Build.0 = Release|Any CPU - {59412D14-DC4B-4583-9B33-B405BF81913D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {59412D14-DC4B-4583-9B33-B405BF81913D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {59412D14-DC4B-4583-9B33-B405BF81913D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {59412D14-DC4B-4583-9B33-B405BF81913D}.Release|Any CPU.Build.0 = Release|Any CPU - {219AA4F0-E49E-4352-865F-D3DB450B0DB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {219AA4F0-E49E-4352-865F-D3DB450B0DB4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {219AA4F0-E49E-4352-865F-D3DB450B0DB4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {219AA4F0-E49E-4352-865F-D3DB450B0DB4}.Release|Any CPU.Build.0 = Release|Any CPU - {294EA128-CE0C-40CC-B3E6-CF1F40D3DC80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {294EA128-CE0C-40CC-B3E6-CF1F40D3DC80}.Debug|Any CPU.Build.0 = Debug|Any CPU - {294EA128-CE0C-40CC-B3E6-CF1F40D3DC80}.Release|Any CPU.ActiveCfg = Release|Any CPU - {294EA128-CE0C-40CC-B3E6-CF1F40D3DC80}.Release|Any CPU.Build.0 = Release|Any CPU - {6B65DB34-1360-4D74-A925-57DE07009D2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6B65DB34-1360-4D74-A925-57DE07009D2E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6B65DB34-1360-4D74-A925-57DE07009D2E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6B65DB34-1360-4D74-A925-57DE07009D2E}.Release|Any CPU.Build.0 = Release|Any CPU - {AFFBCD07-8D7F-4DC6-91E6-76D993345D0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AFFBCD07-8D7F-4DC6-91E6-76D993345D0B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AFFBCD07-8D7F-4DC6-91E6-76D993345D0B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AFFBCD07-8D7F-4DC6-91E6-76D993345D0B}.Release|Any CPU.Build.0 = Release|Any CPU - {E1580130-A749-4EC7-8E88-CBB5A01C12D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E1580130-A749-4EC7-8E88-CBB5A01C12D3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E1580130-A749-4EC7-8E88-CBB5A01C12D3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E1580130-A749-4EC7-8E88-CBB5A01C12D3}.Release|Any CPU.Build.0 = Release|Any CPU - {8DF67451-D99C-4027-93F2-C77D9B4CD6F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8DF67451-D99C-4027-93F2-C77D9B4CD6F3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8DF67451-D99C-4027-93F2-C77D9B4CD6F3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8DF67451-D99C-4027-93F2-C77D9B4CD6F3}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {6B43B51B-A93C-4B4F-AD4D-4B74A2E7DB3F} = {B461FB79-24F2-4091-BB91-F56392A0560D} - {BAE006E4-4A1E-4E52-8CA1-D341BD0B26EE} = {B461FB79-24F2-4091-BB91-F56392A0560D} - {DFE1E9A1-3CB7-4FA4-B304-711772346C43} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} - {FAA7960F-95C1-45E2-9A42-EED477DF97F1} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} - {99066C81-F6AB-4A66-9E52-9D324A840307} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} - {CE76142F-BAA5-4D79-9CBB-9C298378FFF9} = {99066C81-F6AB-4A66-9E52-9D324A840307} - {C203E40E-37C1-49F0-B74C-E3559EB74DA7} = {EAC60D27-67F3-4A5E-8772-A2C6AE0631CB} - {3CEDE123-579A-4E3F-B32C-7FC53950075A} = {EAC60D27-67F3-4A5E-8772-A2C6AE0631CB} - {EAC60D27-67F3-4A5E-8772-A2C6AE0631CB} = {B461FB79-24F2-4091-BB91-F56392A0560D} - {BA63670E-1038-491A-85B2-76DC954A59A5} = {B461FB79-24F2-4091-BB91-F56392A0560D} - {0EA33463-8B0B-057B-E76E-2D441A86832A} = {BA63670E-1038-491A-85B2-76DC954A59A5} - {1BA02A1F-D02D-4FB1-99F9-A6740B593389} = {BA63670E-1038-491A-85B2-76DC954A59A5} - {80CB9DF4-D9EB-4E13-A78F-B716093A1B6C} = {B461FB79-24F2-4091-BB91-F56392A0560D} - {9B9B9120-25EE-4DAA-9D09-63B4BBBAEEAA} = {80CB9DF4-D9EB-4E13-A78F-B716093A1B6C} - {0903F791-7EE4-4644-BA90-494798B1F4B3} = {80CB9DF4-D9EB-4E13-A78F-B716093A1B6C} - {5CF087B0-0B63-40F5-8E7B-6DCE1FAA1EEE} = {F2473697-B13F-422F-A267-DA263F56025F} - {E9727893-5089-49AD-B829-9A0C7478DE8D} = {F2473697-B13F-422F-A267-DA263F56025F} - {AD76012D-0929-4FB7-BDF0-71B0C6CEA1C3} = {F2473697-B13F-422F-A267-DA263F56025F} - {02A575A9-DC41-4FF0-A05B-6E28CFA9E14D} = {F2473697-B13F-422F-A267-DA263F56025F} - {BA669488-A9F5-45B9-B58A-FD9478FDE6E3} = {AD76012D-0929-4FB7-BDF0-71B0C6CEA1C3} - {34F3C67F-4A9B-4307-B1F6-F229EE1CB152} = {AD76012D-0929-4FB7-BDF0-71B0C6CEA1C3} - {DE51B2E1-27CA-4AFB-AC28-8C759012F230} = {02A575A9-DC41-4FF0-A05B-6E28CFA9E14D} - {59412D14-DC4B-4583-9B33-B405BF81913D} = {02A575A9-DC41-4FF0-A05B-6E28CFA9E14D} - {A94B7ED9-45FB-4D57-8B9D-B52C47F72D0A} = {F2473697-B13F-422F-A267-DA263F56025F} - {1EB29D6C-ABD8-4F9D-8608-72B69DD9B756} = {F2473697-B13F-422F-A267-DA263F56025F} - {219AA4F0-E49E-4352-865F-D3DB450B0DB4} = {A94B7ED9-45FB-4D57-8B9D-B52C47F72D0A} - {294EA128-CE0C-40CC-B3E6-CF1F40D3DC80} = {A94B7ED9-45FB-4D57-8B9D-B52C47F72D0A} - {6B65DB34-1360-4D74-A925-57DE07009D2E} = {1EB29D6C-ABD8-4F9D-8608-72B69DD9B756} - {AFFBCD07-8D7F-4DC6-91E6-76D993345D0B} = {1EB29D6C-ABD8-4F9D-8608-72B69DD9B756} - {E1580130-A749-4EC7-8E88-CBB5A01C12D3} = {BF01C64B-7C68-46C3-A7AD-084B4BE5AE38} - {8DF67451-D99C-4027-93F2-C77D9B4CD6F3} = {BF01C64B-7C68-46C3-A7AD-084B4BE5AE38} - {BF01C64B-7C68-46C3-A7AD-084B4BE5AE38} = {F2473697-B13F-422F-A267-DA263F56025F} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {344068EF-5958-4241-BD83-86403ADA68F1} - EndGlobalSection -EndGlobal diff --git a/PosInformatique.Foundations.slnx b/PosInformatique.Foundations.slnx new file mode 100644 index 0000000..413c0bd --- /dev/null +++ b/PosInformatique.Foundations.slnx @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From f22449daa7b7e377241e7140a1646123d923451c Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 10 Oct 2025 16:49:02 +0200 Subject: [PATCH 26/73] Add the global.json --- global.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 global.json diff --git a/global.json b/global.json new file mode 100644 index 0000000..9d34b15 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "9.0.305", + "rollForward": "latestFeature" + } +} From 116927e9808f1006bbf875e8fa82d8426ce020cd Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 10 Oct 2025 16:50:09 +0200 Subject: [PATCH 27/73] Fix the CI / Release GitHub actions. --- .github/workflows/github-actions-ci.yaml | 6 +++--- .github/workflows/github-actions-release.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/github-actions-ci.yaml b/.github/workflows/github-actions-ci.yaml index ead18b0..ffe2920 100644 --- a/.github/workflows/github-actions-ci.yaml +++ b/.github/workflows/github-actions-ci.yaml @@ -21,17 +21,17 @@ jobs: dotnet-version: 9.0.x - name: Restore dependencies - run: dotnet restore PosInformatique.Foundations.sln + run: dotnet restore PosInformatique.Foundations.slnx - name: Build solution run: | - dotnet build PosInformatique.Foundations.sln \ + dotnet build PosInformatique.Foundations.slnx \ --configuration Release \ --no-restore - name: Run tests run: | - dotnet test PosInformatique.Foundations.sln \ + dotnet test PosInformatique.Foundations.slnx \ --configuration Release \ --no-build \ --logger "trx;LogFileName=test_results.trx" \ diff --git a/.github/workflows/github-actions-release.yaml b/.github/workflows/github-actions-release.yaml index ef82da1..ee671c3 100644 --- a/.github/workflows/github-actions-release.yaml +++ b/.github/workflows/github-actions-release.yaml @@ -27,7 +27,7 @@ jobs: - name: Build all the NuGet packages run: | - dotnet pack PosInformatique.Foundations.sln \ + dotnet pack PosInformatique.Foundations.slnx \ --configuration Release \ --property:VersionPrefix=${{ github.event.inputs.VersionPrefix }} \ --property:VersionSuffix=${{ github.event.inputs.VersionSuffix }} \ From 5b087183f7305f99e54fa3e1ceb46cb29572026e Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 23 Oct 2025 09:08:10 +0200 Subject: [PATCH 28/73] Add SonarAnalyers package. --- Directory.Build.props | 8 ++++++-- Directory.Packages.props | 1 + .../EmailAddressPropertyExtensions.cs | 4 ++-- .../EmailAddressValidatorExtensions.cs | 2 +- .../EmailAddressJsonSerializerOptionsExtensions.cs | 4 ++-- src/EmailAddresses/EmailAddress.cs | 8 ++++---- .../LastNamePropertyExtensions.cs | 2 +- .../PeopleAssertionsExtensions.cs | 4 ++-- .../NameValidatorExtensions.cs | 4 ++-- .../PeopleJsonSerializerOptionsExtensions.cs | 6 +++--- src/People/FirstName.cs | 2 +- src/People/LastName.cs | 2 +- src/People/NameNormalizer.cs | 8 ++++---- src/People/PersonExtensions.cs | 6 +++--- tests/.editorconfig | 11 +++++++++++ .../EmailAddressPropertyExtensionsTest.cs | 4 ++-- tests/EmailAddresses.Tests/EmailAddressTest.cs | 4 ++-- 17 files changed, 48 insertions(+), 32 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 1f0aa1a..0b76af0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,4 +1,4 @@ - + @@ -34,6 +34,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -46,4 +50,4 @@ - \ No newline at end of file + diff --git a/Directory.Packages.props b/Directory.Packages.props index 204d1da..9dada11 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs b/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs index 9be8b2c..0da1686 100644 --- a/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs +++ b/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs @@ -26,11 +26,11 @@ public static class EmailAddressPropertyExtensions /// If the specified generic type is not a . public static PropertyBuilder IsEmailAddress(this PropertyBuilder property) { - ArgumentNullException.ThrowIfNull(property, nameof(property)); + ArgumentNullException.ThrowIfNull(property); if (typeof(T) != typeof(EmailAddress)) { - throw new ArgumentException($"The '{nameof(IsEmailAddress)}()' method must be called on '{nameof(EmailAddress)} class.", nameof(T)); + throw new ArgumentException($"The '{nameof(IsEmailAddress)}()' method must be called on '{nameof(EmailAddress)} class.", nameof(property)); } return property diff --git a/src/EmailAddresses.FluentValidation/EmailAddressValidatorExtensions.cs b/src/EmailAddresses.FluentValidation/EmailAddressValidatorExtensions.cs index 6124ff7..8f2d3ac 100644 --- a/src/EmailAddresses.FluentValidation/EmailAddressValidatorExtensions.cs +++ b/src/EmailAddresses.FluentValidation/EmailAddressValidatorExtensions.cs @@ -27,7 +27,7 @@ public static class EmailAddressValidatorExtensions /// If the specified argument is . public static IRuleBuilderOptions MustBeEmailAddress(this IRuleBuilder ruleBuilder) { - ArgumentNullException.ThrowIfNull(ruleBuilder, nameof(ruleBuilder)); + ArgumentNullException.ThrowIfNull(ruleBuilder); return ruleBuilder.SetValidator(new EmailAddressValidator()); } diff --git a/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs b/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs index ce54379..eb7a3bc 100644 --- a/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs +++ b/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs @@ -22,9 +22,9 @@ public static class EmailAddressJsonSerializerOptionsExtensions /// If the specified argument is . public static JsonSerializerOptions AddEmailAddressesConverters(this JsonSerializerOptions options) { - ArgumentNullException.ThrowIfNull(options, nameof(options)); + ArgumentNullException.ThrowIfNull(options); - if (!options.Converters.Any(c => c.GetType() == typeof(EmailAddressJsonConverter))) + if (!options.Converters.Any(c => c is EmailAddressJsonConverter)) { options.Converters.Add(new EmailAddressJsonConverter()); } diff --git a/src/EmailAddresses/EmailAddress.cs b/src/EmailAddresses/EmailAddress.cs index 2e56b53..a39d10b 100644 --- a/src/EmailAddresses/EmailAddress.cs +++ b/src/EmailAddresses/EmailAddress.cs @@ -66,7 +66,7 @@ private EmailAddress(MailboxAddress address) /// Thrown when the argument is . public static implicit operator string(EmailAddress emailAddress) { - ArgumentNullException.ThrowIfNull(emailAddress, nameof(emailAddress)); + ArgumentNullException.ThrowIfNull(emailAddress); return emailAddress.value; } @@ -80,7 +80,7 @@ public static implicit operator string(EmailAddress emailAddress) /// Thrown when the argument is . public static implicit operator EmailAddress(string emailAddress) { - ArgumentNullException.ThrowIfNull(emailAddress, nameof(emailAddress)); + ArgumentNullException.ThrowIfNull(emailAddress); return Parse(emailAddress, null); } @@ -160,7 +160,7 @@ public static implicit operator EmailAddress(string emailAddress) /// Thrown when the string is not a valid email address. public static EmailAddress Parse(string s) { - ArgumentNullException.ThrowIfNull(s, nameof(s)); + ArgumentNullException.ThrowIfNull(s); return Parse(s, null); } @@ -175,7 +175,7 @@ public static EmailAddress Parse(string s) /// Thrown when the string is not a valid email address. public static EmailAddress Parse(string s, IFormatProvider? provider) { - ArgumentNullException.ThrowIfNull(s, nameof(s)); + ArgumentNullException.ThrowIfNull(s); if (!TryParse(s, out var result)) { diff --git a/src/People.EntityFramework/LastNamePropertyExtensions.cs b/src/People.EntityFramework/LastNamePropertyExtensions.cs index bf755a7..63e535f 100644 --- a/src/People.EntityFramework/LastNamePropertyExtensions.cs +++ b/src/People.EntityFramework/LastNamePropertyExtensions.cs @@ -25,7 +25,7 @@ public static class LastNamePropertyExtensions /// If the specified argument is . public static PropertyBuilder IsLastName(this PropertyBuilder property) { - ArgumentNullException.ThrowIfNull(property, nameof(property)); + ArgumentNullException.ThrowIfNull(property); return property .IsUnicode(true) diff --git a/src/People.FluentAssertions/PeopleAssertionsExtensions.cs b/src/People.FluentAssertions/PeopleAssertionsExtensions.cs index b4ca736..62467b8 100644 --- a/src/People.FluentAssertions/PeopleAssertionsExtensions.cs +++ b/src/People.FluentAssertions/PeopleAssertionsExtensions.cs @@ -22,7 +22,7 @@ public static class PeopleAssertionsExtensions /// If the specified argument is . public static FirstNameAssertions Should(this FirstName subject) { - ArgumentNullException.ThrowIfNull(subject, nameof(subject)); + ArgumentNullException.ThrowIfNull(subject); return new FirstNameAssertions(subject); } @@ -36,7 +36,7 @@ public static FirstNameAssertions Should(this FirstName subject) /// If the specified argument is . public static LastNameAssertions Should(this LastName subject) { - ArgumentNullException.ThrowIfNull(subject, nameof(subject)); + ArgumentNullException.ThrowIfNull(subject); return new LastNameAssertions(subject); } diff --git a/src/People.FluentValidation/NameValidatorExtensions.cs b/src/People.FluentValidation/NameValidatorExtensions.cs index a412d0c..921fd5b 100644 --- a/src/People.FluentValidation/NameValidatorExtensions.cs +++ b/src/People.FluentValidation/NameValidatorExtensions.cs @@ -29,7 +29,7 @@ public static class NameValidatorExtensions /// If the specified argument is . public static IRuleBuilderOptions MustBeFirstName(this IRuleBuilder ruleBuilder) { - ArgumentNullException.ThrowIfNull(ruleBuilder, nameof(ruleBuilder)); + ArgumentNullException.ThrowIfNull(ruleBuilder); return ruleBuilder.SetValidator(new FirstNameValidator()); } @@ -49,7 +49,7 @@ public static IRuleBuilderOptions MustBeFirstName(this IRuleBuilde /// If the specified argument is . public static IRuleBuilderOptions MustBeLastName(this IRuleBuilder ruleBuilder) { - ArgumentNullException.ThrowIfNull(ruleBuilder, nameof(ruleBuilder)); + ArgumentNullException.ThrowIfNull(ruleBuilder); return ruleBuilder.SetValidator(new LastNameValidator()); } diff --git a/src/People.Json/PeopleJsonSerializerOptionsExtensions.cs b/src/People.Json/PeopleJsonSerializerOptionsExtensions.cs index 3d062ae..cf6130f 100644 --- a/src/People.Json/PeopleJsonSerializerOptionsExtensions.cs +++ b/src/People.Json/PeopleJsonSerializerOptionsExtensions.cs @@ -22,14 +22,14 @@ public static class PeopleJsonSerializerOptionsExtensions /// If the specified argument is . public static JsonSerializerOptions AddPeopleConverters(this JsonSerializerOptions options) { - ArgumentNullException.ThrowIfNull(options, nameof(options)); + ArgumentNullException.ThrowIfNull(options); - if (!options.Converters.Any(c => c.GetType() == typeof(FirstNameJsonConverter))) + if (!options.Converters.Any(c => c is FirstNameJsonConverter)) { options.Converters.Add(new FirstNameJsonConverter()); } - if (!options.Converters.Any(c => c.GetType() == typeof(LastNameJsonConverter))) + if (!options.Converters.Any(c => c is LastNameJsonConverter)) { options.Converters.Add(new LastNameJsonConverter()); } diff --git a/src/People/FirstName.cs b/src/People/FirstName.cs index 602d43c..2496a84 100644 --- a/src/People/FirstName.cs +++ b/src/People/FirstName.cs @@ -80,7 +80,7 @@ private enum InvalidReason /// If is . public static implicit operator string(FirstName firstName) { - ArgumentNullException.ThrowIfNull(firstName, nameof(firstName)); + ArgumentNullException.ThrowIfNull(firstName); return firstName.ToString(); } diff --git a/src/People/LastName.cs b/src/People/LastName.cs index 83d803f..21dd87a 100644 --- a/src/People/LastName.cs +++ b/src/People/LastName.cs @@ -80,7 +80,7 @@ private enum InvalidReason /// If is . public static implicit operator string(LastName lastName) { - ArgumentNullException.ThrowIfNull(lastName, nameof(lastName)); + ArgumentNullException.ThrowIfNull(lastName); return lastName.ToString(); } diff --git a/src/People/NameNormalizer.cs b/src/People/NameNormalizer.cs index a64c796..ecc003c 100644 --- a/src/People/NameNormalizer.cs +++ b/src/People/NameNormalizer.cs @@ -22,8 +22,8 @@ public static class NameNormalizer /// If the specified argument is . public static string GetFullNameForDisplay(FirstName firstName, LastName lastName) { - ArgumentNullException.ThrowIfNull(firstName, nameof(firstName)); - ArgumentNullException.ThrowIfNull(lastName, nameof(lastName)); + ArgumentNullException.ThrowIfNull(firstName); + ArgumentNullException.ThrowIfNull(lastName); return $"{firstName} {lastName}"; } @@ -39,8 +39,8 @@ public static string GetFullNameForDisplay(FirstName firstName, LastName lastNam /// If the specified argument is . public static string GetFullNameForOrder(FirstName firstName, LastName lastName) { - ArgumentNullException.ThrowIfNull(firstName, nameof(firstName)); - ArgumentNullException.ThrowIfNull(lastName, nameof(lastName)); + ArgumentNullException.ThrowIfNull(firstName); + ArgumentNullException.ThrowIfNull(lastName); return $"{lastName} {firstName}"; } diff --git a/src/People/PersonExtensions.cs b/src/People/PersonExtensions.cs index 869b98a..7843fa6 100644 --- a/src/People/PersonExtensions.cs +++ b/src/People/PersonExtensions.cs @@ -20,7 +20,7 @@ public static class PersonExtensions /// If the specified argument is . public static string GetFullNameForDisplay(this IPerson person) { - ArgumentNullException.ThrowIfNull(person, nameof(person)); + ArgumentNullException.ThrowIfNull(person); return NameNormalizer.GetFullNameForDisplay(person.FirstName, person.LastName); } @@ -35,7 +35,7 @@ public static string GetFullNameForDisplay(this IPerson person) /// If the specified argument is . public static string GetFullNameForOrder(this IPerson person) { - ArgumentNullException.ThrowIfNull(person, nameof(person)); + ArgumentNullException.ThrowIfNull(person); return NameNormalizer.GetFullNameForOrder(person.FirstName, person.LastName); } @@ -50,7 +50,7 @@ public static string GetFullNameForOrder(this IPerson person) /// If the specified argument is . public static string GetInitials(this IPerson person) { - ArgumentNullException.ThrowIfNull(person, nameof(person)); + ArgumentNullException.ThrowIfNull(person); return $"{person.FirstName[0]}{person.LastName[0]}"; } diff --git a/tests/.editorconfig b/tests/.editorconfig index 0d9a8dd..642b698 100644 --- a/tests/.editorconfig +++ b/tests/.editorconfig @@ -1,5 +1,16 @@ [*.cs] +#### Sonar Analyzers #### + +# S1144: Unused private types or members should be removed +dotnet_diagnostic.S1144.severity = none + +# S3459: Unassigned members should be removed +dotnet_diagnostic.S3459.severity = none + +# S3878: Arrays should not be created for params parameters +dotnet_diagnostic.S3878.severity = none + #### StyleCop #### # SA1312: Variable names should begin with lower-case letter diff --git a/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs b/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs index e9c189c..6d2cbbd 100644 --- a/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs +++ b/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs @@ -54,8 +54,8 @@ public void IsEmailAddress_NotEmailAddressProperty() }; act.Should().ThrowExactly() - .WithMessage("The 'IsEmailAddress()' method must be called on 'EmailAddress class. (Parameter 'T')") - .WithParameterName("T"); + .WithMessage("The 'IsEmailAddress()' method must be called on 'EmailAddress class. (Parameter 'property')") + .WithParameterName("property"); } [Theory] diff --git a/tests/EmailAddresses.Tests/EmailAddressTest.cs b/tests/EmailAddresses.Tests/EmailAddressTest.cs index 5c188bd..1edb855 100644 --- a/tests/EmailAddresses.Tests/EmailAddressTest.cs +++ b/tests/EmailAddresses.Tests/EmailAddressTest.cs @@ -273,7 +273,7 @@ public void Operator_EmailAddressToString_WithNullArgument() { var act = () => { - string toStringValue = (EmailAddress)null; + string _ = (EmailAddress)null; }; act.Should().ThrowExactly() @@ -298,7 +298,7 @@ public void Operator_StringToEmailAddress_WithNullArgument() { var act = () => { - EmailAddress toStringValue = (string)null; + EmailAddress _ = (string)null; }; act.Should().ThrowExactly() From aa2248a36b5064bb78df6a97bafd10fbaa40d66f Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 23 Oct 2025 09:09:35 +0200 Subject: [PATCH 29/73] Upgrade NuGet packages. --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 9dada11..47db96f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,8 +6,8 @@ - - + + From 62c25557aacbf162712e9327dd72b0bde49a0567 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 7 Nov 2025 10:19:39 +0100 Subject: [PATCH 30/73] Upgrade NuGet packages. --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 47db96f..4b446ba 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ - + @@ -15,7 +15,7 @@ - + \ No newline at end of file From 515a33eaa7878c4c25016d72ccb177009c980c3e Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 10 Nov 2025 07:33:44 +0100 Subject: [PATCH 31/73] Remove new line at the end of file. --- .editorconfig | 2 +- .../EmailAddressPropertyExtensions.cs | 2 +- src/EmailAddresses.FluentValidation/EmailAddressValidator.cs | 2 +- .../EmailAddressValidatorExtensions.cs | 2 +- src/EmailAddresses.Json/EmailAddressJsonConverter.cs | 2 +- .../EmailAddressJsonSerializerOptionsExtensions.cs | 2 +- src/EmailAddresses/EmailAddress.cs | 2 +- src/People.DataAnnotations/FirstNameAttribute.cs | 2 +- src/People.DataAnnotations/LastNameAttribute.cs | 2 +- src/People.EntityFramework/FirstNamePropertyExtensions.cs | 2 +- src/People.EntityFramework/LastNamePropertyExtensions.cs | 2 +- src/People.FluentAssertions/FirstNameAssertions.cs | 2 +- src/People.FluentAssertions/LastNameAssertions.cs | 2 +- src/People.FluentAssertions/PeopleAssertionsExtensions.cs | 2 +- src/People.FluentValidation/FirstNameValidator.cs | 2 +- src/People.FluentValidation/LastNameValidator.cs | 2 +- src/People.FluentValidation/NameValidatorExtensions.cs | 2 +- src/People.Json/FirstNameJsonConverter.cs | 2 +- src/People.Json/LastNameJsonConverter.cs | 2 +- src/People.Json/PeopleJsonSerializerOptionsExtensions.cs | 2 +- src/People/FirstName.cs | 2 +- src/People/IPerson.cs | 2 +- src/People/LastName.cs | 2 +- src/People/NameNormalizer.cs | 2 +- src/People/PersonExtensions.cs | 2 +- .../EmailAddressPropertyExtensionsTest.cs | 2 +- .../EmailAddressValidatorExtensionsTest.cs | 2 +- .../EmailAddressValidatorTest.cs | 2 +- .../EmailAddresses.Json.Tests/EmailAddressJsonConverterTest.cs | 2 +- .../EmailAddressJsonSerializerOptionsExtensionsTest.cs | 2 +- tests/EmailAddresses.Tests/EmailAddressTest.cs | 2 +- tests/EmailAddresses.Tests/EmailAddressTestData.cs | 2 +- tests/People.DataAnnotations.Tests/FirstNameAttributeTest.cs | 2 +- tests/People.DataAnnotations.Tests/LastNameAttributeTest.cs | 2 +- .../FirstNamePropertyExtensionsTest.cs | 2 +- .../LastNamePropertyExtensionsTest.cs | 2 +- .../PeopleAssertionsExtensionsTest.cs | 2 +- tests/People.FluentValidation.Tests/FirstNameValidatorTest.cs | 2 +- tests/People.FluentValidation.Tests/LastNameValidatorTest.cs | 2 +- .../NameValidatorExtensionsTest.cs | 2 +- tests/People.Json.Tests/FirstNameJsonConverterTest.cs | 2 +- tests/People.Json.Tests/LastNameJsonConverterTest.cs | 2 +- .../PeopleJsonSerializerOptionsExtensionsTest.cs | 2 +- tests/People.Tests/FirstNameTest.cs | 2 +- tests/People.Tests/LastNameTest.cs | 2 +- tests/People.Tests/NameNormalizerTest.cs | 2 +- tests/People.Tests/NameTestData.cs | 2 +- tests/People.Tests/PersonExtensionsTest.cs | 2 +- 48 files changed, 48 insertions(+), 48 deletions(-) diff --git a/.editorconfig b/.editorconfig index bff7b65..1117d16 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,7 +2,7 @@ [*] charset = utf-8-bom -insert_final_newline = true +insert_final_newline = false trim_trailing_whitespace = true # Markdown specific settings diff --git a/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs b/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs index 0da1686..289d25d 100644 --- a/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs +++ b/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs @@ -50,4 +50,4 @@ private EmailAddressConverter() public static EmailAddressConverter Instance { get; } = new EmailAddressConverter(); } } -} +} \ No newline at end of file diff --git a/src/EmailAddresses.FluentValidation/EmailAddressValidator.cs b/src/EmailAddresses.FluentValidation/EmailAddressValidator.cs index 98de8d3..067438d 100644 --- a/src/EmailAddresses.FluentValidation/EmailAddressValidator.cs +++ b/src/EmailAddresses.FluentValidation/EmailAddressValidator.cs @@ -31,4 +31,4 @@ protected override string GetDefaultMessageTemplate(string errorCode) return $"'{{PropertyName}}' must be a valid email address."; } } -} +} \ No newline at end of file diff --git a/src/EmailAddresses.FluentValidation/EmailAddressValidatorExtensions.cs b/src/EmailAddresses.FluentValidation/EmailAddressValidatorExtensions.cs index 8f2d3ac..5aa167f 100644 --- a/src/EmailAddresses.FluentValidation/EmailAddressValidatorExtensions.cs +++ b/src/EmailAddresses.FluentValidation/EmailAddressValidatorExtensions.cs @@ -32,4 +32,4 @@ public static IRuleBuilderOptions MustBeEmailAddress(this IRuleBui return ruleBuilder.SetValidator(new EmailAddressValidator()); } } -} +} \ No newline at end of file diff --git a/src/EmailAddresses.Json/EmailAddressJsonConverter.cs b/src/EmailAddresses.Json/EmailAddressJsonConverter.cs index 2b2f58a..99a4745 100644 --- a/src/EmailAddresses.Json/EmailAddressJsonConverter.cs +++ b/src/EmailAddresses.Json/EmailAddressJsonConverter.cs @@ -42,4 +42,4 @@ public override void Write(Utf8JsonWriter writer, EmailAddress value, JsonSerial writer.WriteStringValue(value.ToString()); } } -} +} \ No newline at end of file diff --git a/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs b/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs index eb7a3bc..28504fb 100644 --- a/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs +++ b/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs @@ -32,4 +32,4 @@ public static JsonSerializerOptions AddEmailAddressesConverters(this JsonSeriali return options; } } -} +} \ No newline at end of file diff --git a/src/EmailAddresses/EmailAddress.cs b/src/EmailAddresses/EmailAddress.cs index a39d10b..e5c009b 100644 --- a/src/EmailAddresses/EmailAddress.cs +++ b/src/EmailAddresses/EmailAddress.cs @@ -323,4 +323,4 @@ public int CompareTo(EmailAddress? other) return address; } } -} +} \ No newline at end of file diff --git a/src/People.DataAnnotations/FirstNameAttribute.cs b/src/People.DataAnnotations/FirstNameAttribute.cs index d9a86c6..bf5ad6d 100644 --- a/src/People.DataAnnotations/FirstNameAttribute.cs +++ b/src/People.DataAnnotations/FirstNameAttribute.cs @@ -40,4 +40,4 @@ public override bool IsValid(object? value) return FirstName.IsValid(firstName); } } -} +} \ No newline at end of file diff --git a/src/People.DataAnnotations/LastNameAttribute.cs b/src/People.DataAnnotations/LastNameAttribute.cs index bd89034..34e9f43 100644 --- a/src/People.DataAnnotations/LastNameAttribute.cs +++ b/src/People.DataAnnotations/LastNameAttribute.cs @@ -40,4 +40,4 @@ public override bool IsValid(object? value) return LastName.IsValid(lastName); } } -} +} \ No newline at end of file diff --git a/src/People.EntityFramework/FirstNamePropertyExtensions.cs b/src/People.EntityFramework/FirstNamePropertyExtensions.cs index dbb2402..755821d 100644 --- a/src/People.EntityFramework/FirstNamePropertyExtensions.cs +++ b/src/People.EntityFramework/FirstNamePropertyExtensions.cs @@ -52,4 +52,4 @@ private FirstNameComparer() public static FirstNameComparer Instance { get; } = new FirstNameComparer(); } } -} +} \ No newline at end of file diff --git a/src/People.EntityFramework/LastNamePropertyExtensions.cs b/src/People.EntityFramework/LastNamePropertyExtensions.cs index 63e535f..0741cce 100644 --- a/src/People.EntityFramework/LastNamePropertyExtensions.cs +++ b/src/People.EntityFramework/LastNamePropertyExtensions.cs @@ -54,4 +54,4 @@ private LastNameComparer() public static LastNameComparer Instance { get; } = new LastNameComparer(); } } -} +} \ No newline at end of file diff --git a/src/People.FluentAssertions/FirstNameAssertions.cs b/src/People.FluentAssertions/FirstNameAssertions.cs index e7aa18c..92288a8 100644 --- a/src/People.FluentAssertions/FirstNameAssertions.cs +++ b/src/People.FluentAssertions/FirstNameAssertions.cs @@ -37,4 +37,4 @@ public AndConstraint Be(string firstName, string? because = return new AndConstraint(this); } } -} +} \ No newline at end of file diff --git a/src/People.FluentAssertions/LastNameAssertions.cs b/src/People.FluentAssertions/LastNameAssertions.cs index 517386b..790f537 100644 --- a/src/People.FluentAssertions/LastNameAssertions.cs +++ b/src/People.FluentAssertions/LastNameAssertions.cs @@ -37,4 +37,4 @@ public AndConstraint Be(string lastName, string? because = n return new AndConstraint(this); } } -} +} \ No newline at end of file diff --git a/src/People.FluentAssertions/PeopleAssertionsExtensions.cs b/src/People.FluentAssertions/PeopleAssertionsExtensions.cs index 62467b8..bc3ed10 100644 --- a/src/People.FluentAssertions/PeopleAssertionsExtensions.cs +++ b/src/People.FluentAssertions/PeopleAssertionsExtensions.cs @@ -41,4 +41,4 @@ public static LastNameAssertions Should(this LastName subject) return new LastNameAssertions(subject); } } -} +} \ No newline at end of file diff --git a/src/People.FluentValidation/FirstNameValidator.cs b/src/People.FluentValidation/FirstNameValidator.cs index f90d008..d55540d 100644 --- a/src/People.FluentValidation/FirstNameValidator.cs +++ b/src/People.FluentValidation/FirstNameValidator.cs @@ -33,4 +33,4 @@ protected override string GetDefaultMessageTemplate(string errorCode) return $"'{{PropertyName}}' must contain a first name that consists only of alphabetic characters, with the [{AllowedSeparators}] separators, and is less than {FirstName.MaxLength} characters long."; } } -} +} \ No newline at end of file diff --git a/src/People.FluentValidation/LastNameValidator.cs b/src/People.FluentValidation/LastNameValidator.cs index 26bf1d8..bcea2b9 100644 --- a/src/People.FluentValidation/LastNameValidator.cs +++ b/src/People.FluentValidation/LastNameValidator.cs @@ -33,4 +33,4 @@ protected override string GetDefaultMessageTemplate(string errorCode) return $"'{{PropertyName}}' must contain a last name that consists only of alphabetic characters, with the [{AllowedSeparators}] separators, and is less than {LastName.MaxLength} characters long."; } } -} +} \ No newline at end of file diff --git a/src/People.FluentValidation/NameValidatorExtensions.cs b/src/People.FluentValidation/NameValidatorExtensions.cs index 921fd5b..54ce100 100644 --- a/src/People.FluentValidation/NameValidatorExtensions.cs +++ b/src/People.FluentValidation/NameValidatorExtensions.cs @@ -54,4 +54,4 @@ public static IRuleBuilderOptions MustBeLastName(this IRuleBuilder return ruleBuilder.SetValidator(new LastNameValidator()); } } -} +} \ No newline at end of file diff --git a/src/People.Json/FirstNameJsonConverter.cs b/src/People.Json/FirstNameJsonConverter.cs index 5579fba..d0da749 100644 --- a/src/People.Json/FirstNameJsonConverter.cs +++ b/src/People.Json/FirstNameJsonConverter.cs @@ -42,4 +42,4 @@ public override void Write(Utf8JsonWriter writer, FirstName value, JsonSerialize writer.WriteStringValue(value); } } -} +} \ No newline at end of file diff --git a/src/People.Json/LastNameJsonConverter.cs b/src/People.Json/LastNameJsonConverter.cs index b80a868..07db2c7 100644 --- a/src/People.Json/LastNameJsonConverter.cs +++ b/src/People.Json/LastNameJsonConverter.cs @@ -42,4 +42,4 @@ public override void Write(Utf8JsonWriter writer, LastName value, JsonSerializer writer.WriteStringValue(value); } } -} +} \ No newline at end of file diff --git a/src/People.Json/PeopleJsonSerializerOptionsExtensions.cs b/src/People.Json/PeopleJsonSerializerOptionsExtensions.cs index cf6130f..733616c 100644 --- a/src/People.Json/PeopleJsonSerializerOptionsExtensions.cs +++ b/src/People.Json/PeopleJsonSerializerOptionsExtensions.cs @@ -37,4 +37,4 @@ public static JsonSerializerOptions AddPeopleConverters(this JsonSerializerOptio return options; } } -} +} \ No newline at end of file diff --git a/src/People/FirstName.cs b/src/People/FirstName.cs index 2496a84..0dc465d 100644 --- a/src/People/FirstName.cs +++ b/src/People/FirstName.cs @@ -442,4 +442,4 @@ public static ParseResult Valid(FirstName firstName) } } } -} +} \ No newline at end of file diff --git a/src/People/IPerson.cs b/src/People/IPerson.cs index dcd0e2b..f5d2a1d 100644 --- a/src/People/IPerson.cs +++ b/src/People/IPerson.cs @@ -23,4 +23,4 @@ public interface IPerson /// LastName LastName { get; } } -} +} \ No newline at end of file diff --git a/src/People/LastName.cs b/src/People/LastName.cs index 21dd87a..4e223dc 100644 --- a/src/People/LastName.cs +++ b/src/People/LastName.cs @@ -435,4 +435,4 @@ public static ParseResult Valid(LastName lastName) } } } -} +} \ No newline at end of file diff --git a/src/People/NameNormalizer.cs b/src/People/NameNormalizer.cs index ecc003c..3b7fee9 100644 --- a/src/People/NameNormalizer.cs +++ b/src/People/NameNormalizer.cs @@ -45,4 +45,4 @@ public static string GetFullNameForOrder(FirstName firstName, LastName lastName) return $"{lastName} {firstName}"; } } -} +} \ No newline at end of file diff --git a/src/People/PersonExtensions.cs b/src/People/PersonExtensions.cs index 7843fa6..8e5e324 100644 --- a/src/People/PersonExtensions.cs +++ b/src/People/PersonExtensions.cs @@ -55,4 +55,4 @@ public static string GetInitials(this IPerson person) return $"{person.FirstName[0]}{person.LastName[0]}"; } } -} +} \ No newline at end of file diff --git a/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs b/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs index 6d2cbbd..e94fbd0 100644 --- a/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs +++ b/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs @@ -150,4 +150,4 @@ private class EntityMock #nullable restore } } -} +} \ No newline at end of file diff --git a/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorExtensionsTest.cs b/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorExtensionsTest.cs index 1255d21..20d474e 100644 --- a/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorExtensionsTest.cs +++ b/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorExtensionsTest.cs @@ -34,4 +34,4 @@ public void MustBeEmailAddress_NullRuleBuilderArgument() .WithParameterName("ruleBuilder"); } } -} +} \ No newline at end of file diff --git a/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorTest.cs b/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorTest.cs index ea054f9..f8d767d 100644 --- a/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorTest.cs +++ b/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorTest.cs @@ -53,4 +53,4 @@ public void IsValid_False(string emailAddress) validator.IsValid(default!, emailAddress).Should().BeFalse(); } } -} +} \ No newline at end of file diff --git a/tests/EmailAddresses.Json.Tests/EmailAddressJsonConverterTest.cs b/tests/EmailAddresses.Json.Tests/EmailAddressJsonConverterTest.cs index 112fe7c..8b3a064 100644 --- a/tests/EmailAddresses.Json.Tests/EmailAddressJsonConverterTest.cs +++ b/tests/EmailAddresses.Json.Tests/EmailAddressJsonConverterTest.cs @@ -115,4 +115,4 @@ private class JsonClass public EmailAddress EmailAddress { get; set; } } } -} +} \ No newline at end of file diff --git a/tests/EmailAddresses.Json.Tests/EmailAddressJsonSerializerOptionsExtensionsTest.cs b/tests/EmailAddresses.Json.Tests/EmailAddressJsonSerializerOptionsExtensionsTest.cs index 967ef8d..d0153ef 100644 --- a/tests/EmailAddresses.Json.Tests/EmailAddressJsonSerializerOptionsExtensionsTest.cs +++ b/tests/EmailAddresses.Json.Tests/EmailAddressJsonSerializerOptionsExtensionsTest.cs @@ -39,4 +39,4 @@ public void AddEmailAddressesConverters_WithNullArgument() .WithParameterName("options"); } } -} +} \ No newline at end of file diff --git a/tests/EmailAddresses.Tests/EmailAddressTest.cs b/tests/EmailAddresses.Tests/EmailAddressTest.cs index 1edb855..16585af 100644 --- a/tests/EmailAddresses.Tests/EmailAddressTest.cs +++ b/tests/EmailAddresses.Tests/EmailAddressTest.cs @@ -364,4 +364,4 @@ public void Operator_GreaterThanOrEqual(string emailAddress1, string emailAddres ((emailAddress1 is not null ? EmailAddress.Parse(emailAddress1) : null) >= (emailAddress2 is not null ? EmailAddress.Parse(emailAddress2) : null)).Should().Be(expectedResult); } } -} +} \ No newline at end of file diff --git a/tests/EmailAddresses.Tests/EmailAddressTestData.cs b/tests/EmailAddresses.Tests/EmailAddressTestData.cs index 991542c..45f5f99 100644 --- a/tests/EmailAddresses.Tests/EmailAddressTestData.cs +++ b/tests/EmailAddresses.Tests/EmailAddressTestData.cs @@ -24,4 +24,4 @@ public static class EmailAddressTestData "TEST1@TEST.COM", ]; } -} +} \ No newline at end of file diff --git a/tests/People.DataAnnotations.Tests/FirstNameAttributeTest.cs b/tests/People.DataAnnotations.Tests/FirstNameAttributeTest.cs index ddc814b..c0a9b88 100644 --- a/tests/People.DataAnnotations.Tests/FirstNameAttributeTest.cs +++ b/tests/People.DataAnnotations.Tests/FirstNameAttributeTest.cs @@ -43,4 +43,4 @@ public void IsValid_False(object value) attribute.IsValid(value).Should().BeFalse(); } } -} +} \ No newline at end of file diff --git a/tests/People.DataAnnotations.Tests/LastNameAttributeTest.cs b/tests/People.DataAnnotations.Tests/LastNameAttributeTest.cs index 17b27b7..dbeb13f 100644 --- a/tests/People.DataAnnotations.Tests/LastNameAttributeTest.cs +++ b/tests/People.DataAnnotations.Tests/LastNameAttributeTest.cs @@ -43,4 +43,4 @@ public void IsValid_False(object value) attribute.IsValid(value).Should().BeFalse(); } } -} +} \ No newline at end of file diff --git a/tests/People.EntityFramework.Tests/FirstNamePropertyExtensionsTest.cs b/tests/People.EntityFramework.Tests/FirstNamePropertyExtensionsTest.cs index 912320f..401d78b 100644 --- a/tests/People.EntityFramework.Tests/FirstNamePropertyExtensionsTest.cs +++ b/tests/People.EntityFramework.Tests/FirstNamePropertyExtensionsTest.cs @@ -123,4 +123,4 @@ private class EntityMock public FirstName FirstName { get; set; } } } -} +} \ No newline at end of file diff --git a/tests/People.EntityFramework.Tests/LastNamePropertyExtensionsTest.cs b/tests/People.EntityFramework.Tests/LastNamePropertyExtensionsTest.cs index 1c08812..af80945 100644 --- a/tests/People.EntityFramework.Tests/LastNamePropertyExtensionsTest.cs +++ b/tests/People.EntityFramework.Tests/LastNamePropertyExtensionsTest.cs @@ -123,4 +123,4 @@ private class EntityMock public LastName LastName { get; set; } } } -} +} \ No newline at end of file diff --git a/tests/People.FluentAssertions.Tests/PeopleAssertionsExtensionsTest.cs b/tests/People.FluentAssertions.Tests/PeopleAssertionsExtensionsTest.cs index 0a11700..91c2465 100644 --- a/tests/People.FluentAssertions.Tests/PeopleAssertionsExtensionsTest.cs +++ b/tests/People.FluentAssertions.Tests/PeopleAssertionsExtensionsTest.cs @@ -56,4 +56,4 @@ public void LastName_BeFailed(string because, object becauseArgs, string expecte .WithMessage(expectedMessage); } } -} +} \ No newline at end of file diff --git a/tests/People.FluentValidation.Tests/FirstNameValidatorTest.cs b/tests/People.FluentValidation.Tests/FirstNameValidatorTest.cs index 67b40cd..20c3123 100644 --- a/tests/People.FluentValidation.Tests/FirstNameValidatorTest.cs +++ b/tests/People.FluentValidation.Tests/FirstNameValidatorTest.cs @@ -48,4 +48,4 @@ public void IsValid_False(string firstName) validator.IsValid(default, firstName).Should().BeFalse(); } } -} +} \ No newline at end of file diff --git a/tests/People.FluentValidation.Tests/LastNameValidatorTest.cs b/tests/People.FluentValidation.Tests/LastNameValidatorTest.cs index 6d1fc48..0b1e127 100644 --- a/tests/People.FluentValidation.Tests/LastNameValidatorTest.cs +++ b/tests/People.FluentValidation.Tests/LastNameValidatorTest.cs @@ -48,4 +48,4 @@ public void IsValid_False(string lastName) validator.IsValid(default, lastName).Should().BeFalse(); } } -} +} \ No newline at end of file diff --git a/tests/People.FluentValidation.Tests/NameValidatorExtensionsTest.cs b/tests/People.FluentValidation.Tests/NameValidatorExtensionsTest.cs index c7c8b29..1c74ce2 100644 --- a/tests/People.FluentValidation.Tests/NameValidatorExtensionsTest.cs +++ b/tests/People.FluentValidation.Tests/NameValidatorExtensionsTest.cs @@ -62,4 +62,4 @@ public void MustBeLastName_NullRuleBuilderArgument() .WithParameterName("ruleBuilder"); } } -} +} \ No newline at end of file diff --git a/tests/People.Json.Tests/FirstNameJsonConverterTest.cs b/tests/People.Json.Tests/FirstNameJsonConverterTest.cs index 7860aa2..66553e9 100644 --- a/tests/People.Json.Tests/FirstNameJsonConverterTest.cs +++ b/tests/People.Json.Tests/FirstNameJsonConverterTest.cs @@ -115,4 +115,4 @@ private class JsonClass public FirstName FirstName { get; set; } } } -} +} \ No newline at end of file diff --git a/tests/People.Json.Tests/LastNameJsonConverterTest.cs b/tests/People.Json.Tests/LastNameJsonConverterTest.cs index 4e4ec75..48ce550 100644 --- a/tests/People.Json.Tests/LastNameJsonConverterTest.cs +++ b/tests/People.Json.Tests/LastNameJsonConverterTest.cs @@ -115,4 +115,4 @@ private class JsonClass public LastName LastName { get; set; } } } -} +} \ No newline at end of file diff --git a/tests/People.Json.Tests/PeopleJsonSerializerOptionsExtensionsTest.cs b/tests/People.Json.Tests/PeopleJsonSerializerOptionsExtensionsTest.cs index dd8a5c9..9d81aa5 100644 --- a/tests/People.Json.Tests/PeopleJsonSerializerOptionsExtensionsTest.cs +++ b/tests/People.Json.Tests/PeopleJsonSerializerOptionsExtensionsTest.cs @@ -41,4 +41,4 @@ public void AddEmailAddressesConverters_WithNullArgument() .WithParameterName("options"); } } -} +} \ No newline at end of file diff --git a/tests/People.Tests/FirstNameTest.cs b/tests/People.Tests/FirstNameTest.cs index fed01a5..0b72739 100644 --- a/tests/People.Tests/FirstNameTest.cs +++ b/tests/People.Tests/FirstNameTest.cs @@ -481,4 +481,4 @@ private static bool CallTryParse(string s, IFormatProvider formatProvider, ou return T.TryParse(s, formatProvider, out result); } } -} +} \ No newline at end of file diff --git a/tests/People.Tests/LastNameTest.cs b/tests/People.Tests/LastNameTest.cs index cd7e674..b11306b 100644 --- a/tests/People.Tests/LastNameTest.cs +++ b/tests/People.Tests/LastNameTest.cs @@ -490,4 +490,4 @@ private static bool CallTryParse(string s, IFormatProvider formatProvider, ou return T.TryParse(s, formatProvider, out result); } } -} +} \ No newline at end of file diff --git a/tests/People.Tests/NameNormalizerTest.cs b/tests/People.Tests/NameNormalizerTest.cs index faab7bb..3101901 100644 --- a/tests/People.Tests/NameNormalizerTest.cs +++ b/tests/People.Tests/NameNormalizerTest.cs @@ -68,4 +68,4 @@ public void GetFullNameForOrder_WithLastNameNullArgument() .WithParameterName("lastName"); } } -} +} \ No newline at end of file diff --git a/tests/People.Tests/NameTestData.cs b/tests/People.Tests/NameTestData.cs index df9918d..1a8b99a 100644 --- a/tests/People.Tests/NameTestData.cs +++ b/tests/People.Tests/NameTestData.cs @@ -53,4 +53,4 @@ public static class NameTestData " ", }; } -} +} \ No newline at end of file diff --git a/tests/People.Tests/PersonExtensionsTest.cs b/tests/People.Tests/PersonExtensionsTest.cs index 6a2f7aa..46b4632 100644 --- a/tests/People.Tests/PersonExtensionsTest.cs +++ b/tests/People.Tests/PersonExtensionsTest.cs @@ -86,4 +86,4 @@ public void GetInitials_WithFirstNameNullArgument() .WithParameterName("person"); } } -} +} \ No newline at end of file From dc96f171e2bd5d17cdd6c5d65c597d0a5dd2f02b Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 13 Nov 2025 17:26:39 +0100 Subject: [PATCH 32/73] Exclude the CHANGELOG.md in the packaging of the NuGet packages. --- .../EmailAddresses.EntityFramework.csproj | 2 +- .../EmailAddresses.FluentValidation.csproj | 2 +- src/EmailAddresses.Json/EmailAddresses.Json.csproj | 2 +- src/EmailAddresses/EmailAddresses.csproj | 2 +- src/People.DataAnnotations/People.DataAnnotations.csproj | 2 +- src/People.EntityFramework/People.EntityFramework.csproj | 2 +- src/People.FluentAssertions/People.FluentAssertions.csproj | 2 +- src/People.FluentValidation/People.FluentValidation.csproj | 2 +- src/People.Json/People.Json.csproj | 2 +- src/People/People.csproj | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/EmailAddresses.EntityFramework/EmailAddresses.EntityFramework.csproj b/src/EmailAddresses.EntityFramework/EmailAddresses.EntityFramework.csproj index 84f8d0c..2bb7599 100644 --- a/src/EmailAddresses.EntityFramework/EmailAddresses.EntityFramework.csproj +++ b/src/EmailAddresses.EntityFramework/EmailAddresses.EntityFramework.csproj @@ -23,7 +23,7 @@ - + diff --git a/src/EmailAddresses.FluentValidation/EmailAddresses.FluentValidation.csproj b/src/EmailAddresses.FluentValidation/EmailAddresses.FluentValidation.csproj index 4bd2726..035ca70 100644 --- a/src/EmailAddresses.FluentValidation/EmailAddresses.FluentValidation.csproj +++ b/src/EmailAddresses.FluentValidation/EmailAddresses.FluentValidation.csproj @@ -23,7 +23,7 @@ - + diff --git a/src/EmailAddresses.Json/EmailAddresses.Json.csproj b/src/EmailAddresses.Json/EmailAddresses.Json.csproj index 72b8550..01112f0 100644 --- a/src/EmailAddresses.Json/EmailAddresses.Json.csproj +++ b/src/EmailAddresses.Json/EmailAddresses.Json.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/EmailAddresses/EmailAddresses.csproj b/src/EmailAddresses/EmailAddresses.csproj index a2e9fbc..18dbbbd 100644 --- a/src/EmailAddresses/EmailAddresses.csproj +++ b/src/EmailAddresses/EmailAddresses.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/People.DataAnnotations/People.DataAnnotations.csproj b/src/People.DataAnnotations/People.DataAnnotations.csproj index 4c22589..3070cad 100644 --- a/src/People.DataAnnotations/People.DataAnnotations.csproj +++ b/src/People.DataAnnotations/People.DataAnnotations.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/People.EntityFramework/People.EntityFramework.csproj b/src/People.EntityFramework/People.EntityFramework.csproj index a3541cc..3194793 100644 --- a/src/People.EntityFramework/People.EntityFramework.csproj +++ b/src/People.EntityFramework/People.EntityFramework.csproj @@ -23,7 +23,7 @@ - + diff --git a/src/People.FluentAssertions/People.FluentAssertions.csproj b/src/People.FluentAssertions/People.FluentAssertions.csproj index 59551e0..9f3244f 100644 --- a/src/People.FluentAssertions/People.FluentAssertions.csproj +++ b/src/People.FluentAssertions/People.FluentAssertions.csproj @@ -20,7 +20,7 @@ - + diff --git a/src/People.FluentValidation/People.FluentValidation.csproj b/src/People.FluentValidation/People.FluentValidation.csproj index 2776a68..5e1b039 100644 --- a/src/People.FluentValidation/People.FluentValidation.csproj +++ b/src/People.FluentValidation/People.FluentValidation.csproj @@ -23,7 +23,7 @@ - + diff --git a/src/People.Json/People.Json.csproj b/src/People.Json/People.Json.csproj index e8136e5..b384b8d 100644 --- a/src/People.Json/People.Json.csproj +++ b/src/People.Json/People.Json.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/People/People.csproj b/src/People/People.csproj index 78011ba..60d7f08 100644 --- a/src/People/People.csproj +++ b/src/People/People.csproj @@ -20,7 +20,7 @@ - + From 521573119ab8cbef6d41d996441d35ec52a5e785 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 14 Nov 2025 08:11:44 +0100 Subject: [PATCH 33/73] Upgrade NuGet packages --- Directory.Packages.props | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 4b446ba..57374ca 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,13 +6,13 @@ - - - + + + - + From 11bff49b674419172303508b8ca8f2ebb06dfd2d Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 14 Nov 2025 09:04:29 +0100 Subject: [PATCH 34/73] Integrate partial source code of MailKit. --- Directory.Packages.props | 1 - src/EmailAddresses/EmailAddresses.csproj | 5 +- src/EmailAddresses/MailKit/.editorconfig | 11 + .../MailKit/AddressParserFlags.cs | 42 ++ src/EmailAddresses/MailKit/DomainList.cs | 167 +++++ .../MailKit/Encodings/Base64Decoder.cs | 184 +++++ .../MailKit/Encodings/IMimeDecoder.cs | 69 ++ .../MailKit/Encodings/IPunycode.cs | 46 ++ .../MailKit/Encodings/Punycode.cs | 69 ++ .../Encodings/QuotedPrintableDecoder.cs | 183 +++++ src/EmailAddresses/MailKit/GroupAddress.cs | 97 +++ src/EmailAddresses/MailKit/InternetAddress.cs | 703 ++++++++++++++++++ .../MailKit/InternetAddressList.cs | 170 +++++ src/EmailAddresses/MailKit/MailboxAddress.cs | 214 ++++++ src/EmailAddresses/MailKit/ParseException.cs | 150 ++++ src/EmailAddresses/MailKit/ParserOptions.cs | 193 +++++ .../MailKit/RfcComplianceMode.cs | 50 ++ .../MailKit/Utils/ArgumentValidator.cs | 43 ++ .../MailKit/Utils/ByteArrayBuilder.cs | 76 ++ .../MailKit/Utils/ByteExtensions.cs | 189 +++++ .../MailKit/Utils/CharsetUtils.cs | 495 ++++++++++++ src/EmailAddresses/MailKit/Utils/MimeUtils.cs | 171 +++++ .../MailKit/Utils/ParseUtils.cs | 304 ++++++++ src/EmailAddresses/MailKit/Utils/Rfc2047.cs | 536 +++++++++++++ .../MailKit/Utils/ValueStringBuilder.cs | 318 ++++++++ 25 files changed, 4481 insertions(+), 5 deletions(-) create mode 100644 src/EmailAddresses/MailKit/.editorconfig create mode 100644 src/EmailAddresses/MailKit/AddressParserFlags.cs create mode 100644 src/EmailAddresses/MailKit/DomainList.cs create mode 100644 src/EmailAddresses/MailKit/Encodings/Base64Decoder.cs create mode 100644 src/EmailAddresses/MailKit/Encodings/IMimeDecoder.cs create mode 100644 src/EmailAddresses/MailKit/Encodings/IPunycode.cs create mode 100644 src/EmailAddresses/MailKit/Encodings/Punycode.cs create mode 100644 src/EmailAddresses/MailKit/Encodings/QuotedPrintableDecoder.cs create mode 100644 src/EmailAddresses/MailKit/GroupAddress.cs create mode 100644 src/EmailAddresses/MailKit/InternetAddress.cs create mode 100644 src/EmailAddresses/MailKit/InternetAddressList.cs create mode 100644 src/EmailAddresses/MailKit/MailboxAddress.cs create mode 100644 src/EmailAddresses/MailKit/ParseException.cs create mode 100644 src/EmailAddresses/MailKit/ParserOptions.cs create mode 100644 src/EmailAddresses/MailKit/RfcComplianceMode.cs create mode 100644 src/EmailAddresses/MailKit/Utils/ArgumentValidator.cs create mode 100644 src/EmailAddresses/MailKit/Utils/ByteArrayBuilder.cs create mode 100644 src/EmailAddresses/MailKit/Utils/ByteExtensions.cs create mode 100644 src/EmailAddresses/MailKit/Utils/CharsetUtils.cs create mode 100644 src/EmailAddresses/MailKit/Utils/MimeUtils.cs create mode 100644 src/EmailAddresses/MailKit/Utils/ParseUtils.cs create mode 100644 src/EmailAddresses/MailKit/Utils/Rfc2047.cs create mode 100644 src/EmailAddresses/MailKit/Utils/ValueStringBuilder.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 57374ca..8b9b49c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,6 @@ - diff --git a/src/EmailAddresses/EmailAddresses.csproj b/src/EmailAddresses/EmailAddresses.csproj index 18dbbbd..22b7061 100644 --- a/src/EmailAddresses/EmailAddresses.csproj +++ b/src/EmailAddresses/EmailAddresses.csproj @@ -12,6 +12,7 @@ $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + true @@ -23,8 +24,4 @@ - - - - diff --git a/src/EmailAddresses/MailKit/.editorconfig b/src/EmailAddresses/MailKit/.editorconfig new file mode 100644 index 0000000..c9b6d64 --- /dev/null +++ b/src/EmailAddresses/MailKit/.editorconfig @@ -0,0 +1,11 @@ +[*.cs] + +# For MailKit source code, disable warnings. +dotnet_analyzer_diagnostic.severity = none +dotnet_diagnostic.CS1574.severity = none +dotnet_diagnostic.CS1734.severity = none +dotnet_diagnostic.CS8600.severity = none +dotnet_diagnostic.CS8604.severity = none +dotnet_diagnostic.CS8618.severity = none +dotnet_diagnostic.CS8625.severity = none +dotnet_diagnostic.IDE0005.severity = none diff --git a/src/EmailAddresses/MailKit/AddressParserFlags.cs b/src/EmailAddresses/MailKit/AddressParserFlags.cs new file mode 100644 index 0000000..c851a57 --- /dev/null +++ b/src/EmailAddresses/MailKit/AddressParserFlags.cs @@ -0,0 +1,42 @@ +// +// AddressParserFlags.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; + +namespace MimeKit { + [Flags] + internal enum AddressParserFlags + { + AllowMailboxAddress = 1 << 0, + AllowGroupAddress = 1 << 1, + ThrowOnError = 1 << 2, + Internal = 1 << 3, + + TryParse = AllowMailboxAddress | AllowGroupAddress, + InternalTryParse = TryParse | Internal, + Parse = TryParse | ThrowOnError + } +} diff --git a/src/EmailAddresses/MailKit/DomainList.cs b/src/EmailAddresses/MailKit/DomainList.cs new file mode 100644 index 0000000..e42420f --- /dev/null +++ b/src/EmailAddresses/MailKit/DomainList.cs @@ -0,0 +1,167 @@ +// +// DomainList.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; +using System.Text; +using System.Collections; +using System.Globalization; +using System.Collections.Generic; + +using MimeKit.Utils; + +namespace MimeKit { + /// + /// A domain list. + /// + /// + /// Represents a list of domains, such as those that an email was routed through. + /// + internal class DomainList : IEnumerable + { + readonly static byte[] DomainSentinels = new [] { (byte) ',', (byte) ':' }; + IList domains; + + /// + /// Initialize a new instance of the class. + /// + /// + /// Creates a new based on the domains provided. + /// + /// A domain list. + /// + /// is . + /// + public DomainList (IEnumerable domains) + { + if (domains is null) + throw new ArgumentNullException (nameof (domains)); + + this.domains = new List (domains); + } + + /// + /// Initialize a new instance of the class. + /// + /// + /// Creates a new . + /// + public DomainList () + { + domains = Array.Empty (); + } + + #region IEnumerable implementation + + /// + /// Get an enumerator for the list of domains. + /// + /// + /// Gets an enumerator for the list of domains. + /// + /// The enumerator. + public IEnumerator GetEnumerator () + { + return domains.GetEnumerator (); + } + + #endregion + + #region IEnumerable implementation + + /// + /// Get an enumerator for the list of domains. + /// + /// + /// Gets an enumerator for the list of domains. + /// + /// The enumerator. + IEnumerator IEnumerable.GetEnumerator () + { + return domains.GetEnumerator (); + } + + #endregion + + /// + /// Try to parse a list of domains. + /// + /// + /// Attempts to parse a from the text buffer starting at the + /// specified index. The index will only be updated if a was + /// successfully parsed. + /// + /// if a was successfully parsed; + /// otherwise, . + /// The buffer to parse. + /// The index to start parsing. + /// An index of the end of the input. + /// A flag indicating whether an + /// exception should be thrown on error. + /// The parsed DomainList. + internal static bool TryParse (byte[] buffer, ref int index, int endIndex, bool throwOnError, out DomainList route) + { + var domains = new List (); + int startIndex = index; + + route = null; + + do { + // skip over the '@' + index++; + + if (index >= endIndex) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Incomplete domain-list at offset: {0}", startIndex), startIndex, index); + + return false; + } + + if (!ParseUtils.TryParseDomain (buffer, ref index, endIndex, DomainSentinels, throwOnError, out var domain)) + return false; + + domains.Add (domain); + + // Note: obs-domain-list allows for null domains between commas + do { + if (!ParseUtils.SkipCommentsAndWhiteSpace (buffer, ref index, endIndex, throwOnError)) + return false; + + if (index >= endIndex || buffer[index] != (byte) ',') + break; + + index++; + } while (true); + + if (!ParseUtils.SkipCommentsAndWhiteSpace (buffer, ref index, endIndex, throwOnError)) + return false; + } while (index < buffer.Length && buffer[index] == (byte) '@'); + + route = new DomainList (domains); + + return true; + } + } +} diff --git a/src/EmailAddresses/MailKit/Encodings/Base64Decoder.cs b/src/EmailAddresses/MailKit/Encodings/Base64Decoder.cs new file mode 100644 index 0000000..c97c487 --- /dev/null +++ b/src/EmailAddresses/MailKit/Encodings/Base64Decoder.cs @@ -0,0 +1,184 @@ +// +// Base64Decoder.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + + +#if NET6_0_OR_GREATER +using System.Buffers.Text; +using System.Runtime.Intrinsics.X86; +using System.Runtime.Intrinsics.Arm; +#endif + +namespace MimeKit.Encodings { + /// + /// Incrementally decodes content encoded with the base64 encoding. + /// + /// + /// Base64 is an encoding often used in MIME to encode binary content such + /// as images and other types of multimedia to ensure that the data remains + /// intact when sent via 7bit transports such as SMTP. + /// + internal class Base64Decoder : IMimeDecoder + { + internal static ReadOnlySpan base64_rank => new byte[256] { + 255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, + 255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, + 255,255,255,255,255,255,255,255,255,255,255, 62,255,255,255, 63, + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61,255,255,255, 0,255,255, + 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,255,255,255,255,255, + 255, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, + 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51,255,255,255,255,255, + 255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, + 255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, + 255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, + 255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, + 255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, + 255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, + 255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, + 255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, + }; + + int previous; + uint saved; + byte bytes; + + /// + /// Initialize a new instance of the class. + /// + /// + /// Creates a new base64 decoder. + /// + public Base64Decoder () + { +#if NET9_0_OR_GREATER + EnableHardwareAcceleration = Ssse3.IsSupported || AdvSimd.Arm64.IsSupported; +#elif NET6_0_OR_GREATER + EnableHardwareAcceleration = Ssse3.IsSupported || (AdvSimd.Arm64.IsSupported && BitConverter.IsLittleEndian); +#endif + } + +#if NET6_0_OR_GREATER + /// + /// Get or set whether the should use hardware acceleration when available. + /// + /// + /// Gets or sets whether the should use hardware acceleration when available. + /// + /// if hardware acceleration should be enabled; otherwise, . + public bool EnableHardwareAcceleration { + get; set; + } +#endif + + /// + /// Estimate the length of the output. + /// + /// + /// Estimates the number of bytes needed to decode the specified number of input bytes. + /// + /// The estimated output length. + /// The input length. + public int EstimateOutputLength (int inputLength) + { + // decoding base64 converts 4 bytes of input into 3 bytes of output + return ((inputLength / 4) * 3) + 3; + } + +#if NET6_0_OR_GREATER + [SkipLocalsInit] +#endif + [MethodImpl (MethodImplOptions.AggressiveInlining)] + unsafe int Decode (ref byte table, byte* input, byte* inend, byte* output) + { + byte* outptr = output; + byte* inptr = input; + + // decode every quartet into a triplet + while (inptr < inend) { + byte c = *inptr++; + byte rank = Unsafe.Add (ref table, c); + + if (rank != 0xFF) { + previous = (previous << 8) | c; + saved = (saved << 6) | rank; + bytes++; + + if (bytes == 4) { + if ((previous & 0xFF0000) != ((byte) '=') << 16) { + *outptr++ = (byte) ((saved >> 16) & 0xFF); + if ((previous & 0xFF00) != ((byte) '=') << 8) { + *outptr++ = (byte) ((saved >> 8) & 0xFF); + if ((previous & 0xFF) != (byte) '=') + *outptr++ = (byte) (saved & 0xFF); + } + } + saved = 0; + bytes = 0; + } + } + } + + return (int) (outptr - output); + } + + /// + /// Decode the specified input into the output buffer. + /// + /// + /// Decodes the specified input into the output buffer. + /// The output buffer should be large enough to hold all the + /// decoded input. For estimating the size needed for the output buffer, + /// see . + /// + /// The number of bytes written to the output buffer. + /// A pointer to the beginning of the input buffer. + /// The length of the input buffer. + /// A pointer to the beginning of the output buffer. + public unsafe int Decode (byte* input, int length, byte* output) + { + ref byte table = ref MemoryMarshal.GetReference (base64_rank); + + return Decode (ref table, input, input + length, output); + } + + /// + /// Reset the decoder. + /// + /// + /// Resets the state of the decoder. + /// + public void Reset () + { + previous = 0; + saved = 0; + bytes = 0; + } + } +} diff --git a/src/EmailAddresses/MailKit/Encodings/IMimeDecoder.cs b/src/EmailAddresses/MailKit/Encodings/IMimeDecoder.cs new file mode 100644 index 0000000..5f7574f --- /dev/null +++ b/src/EmailAddresses/MailKit/Encodings/IMimeDecoder.cs @@ -0,0 +1,69 @@ +// +// IMimeDecoder.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +namespace MimeKit.Encodings { + /// + /// An interface for incrementally decoding content. + /// + /// + /// An interface for incrementally decoding content. + /// + internal interface IMimeDecoder + { + /// + /// Estimate the length of the output. + /// + /// + /// Estimates the number of bytes needed to decode the specified number of input bytes. + /// + /// The estimated output length. + /// The input length. + int EstimateOutputLength (int inputLength); + + /// + /// Decode the specified input into the output buffer. + /// + /// + /// Decodes the specified input into the output buffer. + /// The output buffer should be large enough to hold all the + /// decoded input. For estimating the size needed for the output buffer, + /// see . + /// + /// The number of bytes written to the output buffer. + /// A pointer to the beginning of the input buffer. + /// The length of the input buffer. + /// A pointer to the beginning of the output buffer. + unsafe int Decode (byte* input, int length, byte* output); + + /// + /// Reset the decoder. + /// + /// + /// Resets the state of the decoder. + /// + void Reset (); + } +} diff --git a/src/EmailAddresses/MailKit/Encodings/IPunycode.cs b/src/EmailAddresses/MailKit/Encodings/IPunycode.cs new file mode 100644 index 0000000..6fb88d1 --- /dev/null +++ b/src/EmailAddresses/MailKit/Encodings/IPunycode.cs @@ -0,0 +1,46 @@ +// +// IPunycode.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +namespace MimeKit.Encodings { + /// + /// An interface for encoding and decoding international domain names. + /// + /// + /// An interface for encoding and decoding international domain names. + /// + internal interface IPunycode + { + /// + /// Decode a domain name, converting it to a string of Unicode characters. + /// + /// + /// Decodes a domain name, converting it to Unicode, according to the rules defined by the IDNA standard. + /// + /// The ASCII-encoded domain name. + /// The Unicode domain name. + string Decode (string domain); + } +} diff --git a/src/EmailAddresses/MailKit/Encodings/Punycode.cs b/src/EmailAddresses/MailKit/Encodings/Punycode.cs new file mode 100644 index 0000000..97bc11d --- /dev/null +++ b/src/EmailAddresses/MailKit/Encodings/Punycode.cs @@ -0,0 +1,69 @@ +// +// Punycode.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; +using System.Globalization; + +namespace MimeKit.Encodings { + /// + /// A class for encoding and decoding international domain names. + /// + /// + /// A class for encoding and decoding international domain names. + /// + internal class Punycode : IPunycode + { + readonly IdnMapping idn; + + /// + /// Initialize a new instance of the class. + /// + /// + /// Creates a new instance of . + /// + public Punycode () + { + idn = new IdnMapping (); + } + + /// + /// Decode a domain name, converting it to a string of Unicode characters. + /// + /// + /// Decodes a domain name, converting it to Unicode, according to the rules defined by the IDNA standard. + /// + /// The ASCII-encoded domain name. + /// The Unicode domain name. + public string Decode (string ascii) + { + try { + return idn.GetUnicode (ascii); + } catch { + return ascii; + } + } + } +} diff --git a/src/EmailAddresses/MailKit/Encodings/QuotedPrintableDecoder.cs b/src/EmailAddresses/MailKit/Encodings/QuotedPrintableDecoder.cs new file mode 100644 index 0000000..384b56d --- /dev/null +++ b/src/EmailAddresses/MailKit/Encodings/QuotedPrintableDecoder.cs @@ -0,0 +1,183 @@ +// +// QuotedPrintableDecoder.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; + +using MimeKit.Utils; + +namespace MimeKit.Encodings { + /// + /// Incrementally decodes content encoded with the quoted-printable encoding. + /// + /// + /// Quoted-Printable is an encoding often used in MIME to textual content outside + /// the ASCII range in order to ensure that the text remains intact when sent + /// via 7bit transports such as SMTP. + /// + internal class QuotedPrintableDecoder : IMimeDecoder + { + enum QpDecoderState : byte { + PassThrough, + EqualSign, + SoftBreak, + DecodeByte + } + + readonly bool rfc2047; + QpDecoderState state; + byte saved; + + /// + /// Initialize a new instance of the class. + /// + /// + /// Creates a new quoted-printable decoder. + /// + /// if this decoder will be used to decode rfc2047 encoded-word tokens; otherwise, . + public QuotedPrintableDecoder (bool rfc2047) + { + this.rfc2047 = rfc2047; + } + + /// + /// Estimate the length of the output. + /// + /// + /// Estimates the number of bytes needed to decode the specified number of input bytes. + /// + /// The estimated output length. + /// The input length. + public int EstimateOutputLength (int inputLength) + { + switch (state) { + case QpDecoderState.PassThrough: return inputLength; + case QpDecoderState.EqualSign: return inputLength + 1; // add an extra byte in case the '=' character is not the start of a valid hex sequence + default: return inputLength + 2; // add an extra 2 bytes in case the =X sequence is not the start of a valid hex sequence + } + } + + /// + /// Decode the specified input into the output buffer. + /// + /// + /// Decodes the specified input into the output buffer. + /// The output buffer should be large enough to hold all the + /// decoded input. For estimating the size needed for the output buffer, + /// see . + /// + /// The number of bytes written to the output buffer. + /// A pointer to the beginning of the input buffer. + /// The length of the input buffer. + /// A pointer to the beginning of the output buffer. + public unsafe int Decode (byte* input, int length, byte* output) + { + byte* inend = input + length; + byte* outptr = output; + byte* inptr = input; + byte c; + + while (inptr < inend) { + switch (state) { + case QpDecoderState.PassThrough: + while (inptr < inend) { + c = *inptr++; + + if (c == '=') { + state = QpDecoderState.EqualSign; + break; + } else if (rfc2047 && c == '_') { + *outptr++ = (byte) ' '; + } else { + *outptr++ = c; + } + } + break; + case QpDecoderState.EqualSign: + c = *inptr++; + + if (c.IsXDigit ()) { + state = QpDecoderState.DecodeByte; + saved = c; + } else if (c == '=') { + // invalid encoded sequence - pass it through undecoded + *outptr++ = (byte) '='; + } else if (c == '\r') { + state = QpDecoderState.SoftBreak; + } else if (c == '\n') { + state = QpDecoderState.PassThrough; + } else { + // invalid encoded sequence - pass it through undecoded + state = QpDecoderState.PassThrough; + *outptr++ = (byte) '='; + *outptr++ = c; + } + break; + case QpDecoderState.SoftBreak: + state = QpDecoderState.PassThrough; + c = *inptr++; + + if (c != '\n') { + // invalid encoded sequence - pass it through undecoded + *outptr++ = (byte) '='; + *outptr++ = (byte) '\r'; + *outptr++ = c; + } + break; + case QpDecoderState.DecodeByte: + c = *inptr++; + if (c.IsXDigit ()) { + saved = saved.ToXDigit (); + c = c.ToXDigit (); + + *outptr++ = (byte) ((saved << 4) | c); + } else { + // invalid encoded sequence - pass it through undecoded + *outptr++ = (byte) '='; + *outptr++ = saved; + *outptr++ = c; + } + + state = QpDecoderState.PassThrough; + break; + } + } + + return (int) (outptr - output); + } + + /// + /// Reset the decoder. + /// + /// + /// Resets the state of the decoder. + /// + public void Reset () + { + state = QpDecoderState.PassThrough; + saved = 0; + } + } +} diff --git a/src/EmailAddresses/MailKit/GroupAddress.cs b/src/EmailAddresses/MailKit/GroupAddress.cs new file mode 100644 index 0000000..b90d74d --- /dev/null +++ b/src/EmailAddresses/MailKit/GroupAddress.cs @@ -0,0 +1,97 @@ +// +// GroupAddress.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; +using System.Linq; +using System.Text; +using System.Globalization; +using System.Collections.Generic; + +using MimeKit.Utils; + +namespace MimeKit { + /// + /// An address group, as specified by rfc0822. + /// + /// + /// Group addresses are rarely used anymore. Typically, if you see a group address, + /// it will be of the form: "undisclosed-recipients: ;". + /// + internal class GroupAddress : InternetAddress + { + /// + /// Initialize a new instance of the class. + /// + /// + /// Creates a new with the specified name and list of addresses. The + /// specified text encoding is used when encoding the name according to the rules of rfc2047. + /// + /// The character encoding to be used for encoding the name. + /// The name of the group. + /// A list of addresses. + /// + /// is . + /// + public GroupAddress (Encoding encoding, string name, IEnumerable addresses) : base (encoding, name) + { + Members = new InternetAddressList (addresses); + } + + /// + /// Initialize a new instance of the class. + /// + /// + /// Creates a new with the specified name. The specified + /// text encoding is used when encoding the name according to the rules of rfc2047. + /// + /// The character encoding to be used for encoding the name. + /// The name of the group. + /// + /// is . + /// + public GroupAddress (Encoding encoding, string name) : base (encoding, name) + { + Members = new InternetAddressList (); + } + + /// + /// Get the members of the group. + /// + /// + /// Represents the member addresses of the group. If the group address properly conforms + /// to the internet standards, every group member should be of the + /// variety. When handling group addresses constructed by third-party software, it is possible + /// for groups to contain members of the variety. + /// When constructing new messages, it is recommended that address groups not contain + /// anything other than members in order to comply with internet + /// standards. + /// + /// The list of members. + public InternetAddressList Members { + get; private set; + } + } +} diff --git a/src/EmailAddresses/MailKit/InternetAddress.cs b/src/EmailAddresses/MailKit/InternetAddress.cs new file mode 100644 index 0000000..432194c --- /dev/null +++ b/src/EmailAddresses/MailKit/InternetAddress.cs @@ -0,0 +1,703 @@ +// +// InternetAddress.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; +using System.Text; +using System.Globalization; +using System.ComponentModel; + +using MimeKit.Utils; + +namespace MimeKit { + /// + /// An abstract internet address, as specified by rfc0822. + /// + /// + /// An can be any type of address defined by the + /// original Internet Message specification. + /// There are effectively two (2) types of addresses: mailboxes and groups. + /// Mailbox addresses are what are most commonly known as email addresses and are + /// represented by the class. + /// Group addresses are themselves lists of addresses and are represented by the + /// class. While rare, it is still important to handle these + /// types of addresses. They typically only contain mailbox addresses, but may also + /// contain other group addresses. + /// + internal abstract class InternetAddress + { + const string AtomSpecials = "()<>@,;:\\\".[]"; + Encoding encoding; + string name; + + /// + /// Initialize a new instance of the class. + /// + /// + /// Initializes the and properties of the internet address. + /// + /// The character encoding to be used for encoding the name. + /// The name of the mailbox or group. + /// + /// is . + /// + protected InternetAddress (Encoding encoding, string name) + { + if (encoding is null) + throw new ArgumentNullException (nameof (encoding)); + + Encoding = encoding; + Name = name; + } + + /// + /// Get or set the character encoding to use when encoding the name of the address. + /// + /// + /// The character encoding is used to convert the property, if it is set, + /// to a stream of bytes when encoding the internet address for transport. + /// + /// The character encoding. + /// + /// is . + /// + public Encoding Encoding { + get { return encoding; } + set { + if (value is null) + throw new ArgumentNullException (nameof (value)); + + if (value == encoding) + return; + + encoding = value; + } + } + + /// + /// Get or set the display name of the address. + /// + /// + /// A name is optional and is typically set to the name of the person + /// or group that own the internet address. + /// For example, the property of the following would be "John Smith". + /// John Smith <j.smith@example.com> + /// Likewise, the property of the following would be "undisclosed-recipients". + /// undisclosed-recipients: Alice <alice@wonderland.com>, Bob <bob@the-builder.com>; + /// + /// The name of the address. + public string Name { + get { return name; } + set { + if (value == name) + return; + + name = value; + } + } + + internal static bool TryParseLocalPart (byte[] text, ref int index, int endIndex, RfcComplianceMode compliance, bool skipTrailingCfws, bool throwOnError, out string localpart) + { + using var token = new ValueStringBuilder (128); + int startIndex = index; + + localpart = null; + + do { + bool escapedAt = false; + int start = index; + + if (text[index] == (byte) '"') { + if (!ParseUtils.SkipQuoted (text, ref index, endIndex, throwOnError)) + return false; + } else if (text[index].IsAtom ()) { + if (!ParseUtils.SkipAtom (text, ref index, endIndex)) + return false; + + if (compliance == RfcComplianceMode.Looser) { + // Allow local-parts that include escaped '@' symbols. + // See https://github.com/jstedfast/MimeKit/issues/1043 for details. + while (index + 1 < endIndex && text[index] == (byte) '\\' && text[index + 1] == (byte) '@') { + // track that we've encountered an escaped @ symbol + escapedAt = true; + + // skip over the '\\' and '@' characters + index += 2; + + if (!ParseUtils.SkipAtom (text, ref index, endIndex)) + break; + } + } + } else { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Invalid local-part at offset {0}", startIndex), startIndex, index); + + return false; + } + + string word; + + try { + word = CharsetUtils.UTF8.GetString (text, start, index - start); + } catch (DecoderFallbackException ex) { + if (compliance == RfcComplianceMode.Strict) { + if (throwOnError) + throw new ParseException ("Internationalized local-part tokens may only contain UTF-8 characters.", start, start, ex); + + return false; + } + + word = CharsetUtils.Latin1.GetString (text, start, index - start); + } + + if (escapedAt) + word = word.Replace ("\\@", "%40"); + + token.Append (word); + + int cfws = index; + if (!ParseUtils.SkipCommentsAndWhiteSpace (text, ref index, endIndex, throwOnError)) + return false; + + if (index >= endIndex || text[index] != (byte) '.') { + if (!skipTrailingCfws) + index = cfws; + break; + } + + do { + token.Append ('.'); + index++; + + if (!ParseUtils.SkipCommentsAndWhiteSpace (text, ref index, endIndex, throwOnError)) + return false; + + if (index >= endIndex) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Incomplete local-part at offset {0}", startIndex), startIndex, index); + + return false; + } + } while (compliance == RfcComplianceMode.Looser && text[index] == (byte) '.'); + + if (compliance == RfcComplianceMode.Looser && (index >= endIndex || text[index] == (byte) '@')) + break; + } while (true); + + localpart = token.ToString (); + + return true; + } + + static ReadOnlySpan CommaGreaterThanOrSemiColon => ",>;"u8; + + internal static bool TryParseAddrspec (byte[] text, ref int index, int endIndex, ReadOnlySpan sentinels, RfcComplianceMode compliance, bool throwOnError, out string addrspec, out int at) + { + int startIndex = index; + + addrspec = null; + at = -1; + + if (!TryParseLocalPart (text, ref index, endIndex, compliance, true, throwOnError, out var localpart)) + return false; + + if (index >= endIndex || ParseUtils.IsSentinel (text[index], sentinels)) { + addrspec = localpart; + return true; + } + + if (text[index] != (byte) '@') { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Invalid addr-spec token at offset {0}", startIndex), startIndex, index); + + return false; + } + + index++; + if (index >= endIndex) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Incomplete addr-spec token at offset {0}", startIndex), startIndex, index); + + return false; + } + + if (!ParseUtils.SkipCommentsAndWhiteSpace (text, ref index, endIndex, throwOnError)) + return false; + + if (index >= endIndex) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Incomplete addr-spec token at offset {0}", startIndex), startIndex, index); + + return false; + } + + if (!ParseUtils.TryParseDomain (text, ref index, endIndex, sentinels, throwOnError, out var domain)) + return false; + + if (ParseUtils.IsIdnEncoded (domain)) + domain = MailboxAddress.IdnMapping.Decode (domain); + + addrspec = localpart + "@" + domain; + at = localpart.Length; + + return true; + } + + internal static bool TryParseMailbox (ParserOptions options, byte[] text, int startIndex, ref int index, int endIndex, string name, int codepage, bool throwOnError, out InternetAddress address) + { + var encoding = CharsetUtils.GetEncodingOrDefault (codepage, Encoding.UTF8); + DomainList route = null; + + address = null; + + // skip over the '<' + index++; + + // Note: check for excessive angle brackets like the example described in section 7.1.2 of rfc7103... + if (index < endIndex && text[index] == (byte) '<') { + if (options.AddressParserComplianceMode == RfcComplianceMode.Strict) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Excessive angle brackets at offset {0}", index), startIndex, index); + + return false; + } + + do { + index++; + } while (index < endIndex && text[index] == '<'); + } + + if (!ParseUtils.SkipCommentsAndWhiteSpace (text, ref index, endIndex, throwOnError)) + return false; + + if (index >= endIndex) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Incomplete mailbox at offset {0}", startIndex), startIndex, index); + + return false; + } + + if (text[index] == (byte) '@') { + // Note: we always pass 'false' as the throwOnError argument here so that we can throw a more informative exception on error + if (!DomainList.TryParse (text, ref index, endIndex, false, out route)) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Invalid route in mailbox at offset {0}", startIndex), startIndex, index); + + return false; + } + + if (index >= endIndex || text[index] != (byte) ':') { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Incomplete route in mailbox at offset {0}", startIndex), startIndex, index); + + return false; + } + + // skip over ':' + index++; + + if (!ParseUtils.SkipCommentsAndWhiteSpace (text, ref index, endIndex, throwOnError)) + return false; + + if (index >= endIndex) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Incomplete mailbox at offset {0}", startIndex), startIndex, index); + + return false; + } + } + + // Note: The only syntactically correct sentinel token here is the '>', but alas... to deal with the first example + // in section 7.1.5 of rfc7103, we need to at least handle ',' as a sentinel and might as well handle ';' as well + // in case the mailbox is within a group address. + // + // Example: + if (!TryParseAddrspec (text, ref index, endIndex, CommaGreaterThanOrSemiColon, options.AddressParserComplianceMode, throwOnError, out string addrspec, out int at)) + return false; + + if (!ParseUtils.SkipCommentsAndWhiteSpace (text, ref index, endIndex, throwOnError)) + return false; + + if (index >= endIndex || text[index] != (byte) '>') { + if (options.AddressParserComplianceMode == RfcComplianceMode.Strict) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Unexpected end of mailbox at offset {0}", startIndex), startIndex, index); + + return false; + } + } else { + // skip over the '>' + index++; + + // Note: check for excessive angle brackets like the example described in section 7.1.2 of rfc7103... + if (index < endIndex && text[index] == (byte) '>') { + if (options.AddressParserComplianceMode == RfcComplianceMode.Strict) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Excessive angle brackets at offset {0}", index), startIndex, index); + + return false; + } + + do { + index++; + } while (index < endIndex && text[index] == '>'); + } + } + + if (route != null) + address = new MailboxAddress (encoding, name, route, addrspec, at); + else + address = new MailboxAddress (encoding, name, addrspec, at); + + return true; + } + + static bool TryParseGroup (AddressParserFlags flags, ParserOptions options, byte[] text, int startIndex, ref int index, int endIndex, int groupDepth, string name, int codepage, out InternetAddress address) + { + var encoding = CharsetUtils.GetEncodingOrDefault (codepage, Encoding.UTF8); + bool throwOnError = (flags & AddressParserFlags.ThrowOnError) != 0; + + // skip over the ':' + index++; + + while (index < endIndex && (text[index] == ':' || text[index].IsBlank ())) + index++; + + if (InternetAddressList.TryParse (flags | AddressParserFlags.AllowMailboxAddress, options, text, ref index, endIndex, true, groupDepth, out var members)) + address = new GroupAddress (encoding, name, members); + else + address = new GroupAddress (encoding, name); + + if (index >= endIndex || text[index] != (byte) ';') { + if (throwOnError && options.AddressParserComplianceMode == RfcComplianceMode.Strict) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Expected to find ';' at offset {0}", index), startIndex, index); + + while (index < endIndex && text[index] != (byte) ';') + index++; + } else { + index++; + } + + return true; + } + + internal static bool TryParse (AddressParserFlags flags, ParserOptions options, byte[] text, ref int index, int endIndex, int groupDepth, out InternetAddress address) + { + bool throwOnError = (flags & AddressParserFlags.ThrowOnError) != 0; + + address = null; + + if (!ParseUtils.SkipCommentsAndWhiteSpace (text, ref index, endIndex, throwOnError)) + return false; + + if (index == endIndex) { + if (throwOnError) + throw new ParseException ("No address found.", index, index); + + return false; + } + + // keep track of the start & length of the phrase + bool trimLeadingQuote = false; + int startIndex = index; + int length = 0; + int words = 0; + + while (index < endIndex) { + bool quoted = text[index] == (byte) '"'; + + if (options.AddressParserComplianceMode == RfcComplianceMode.Strict) { + if (!ParseUtils.SkipWord (text, ref index, endIndex, throwOnError)) + break; + } else if (text[index] == (byte) '"') { + int qstringIndex = index; + + if (!ParseUtils.SkipQuoted (text, ref index, endIndex, false)) { + index = qstringIndex + 1; + + ParseUtils.SkipWhiteSpace (text, ref index, endIndex); + + if (!ParseUtils.SkipPhraseAtom (text, ref index, endIndex)) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Incomplete quoted-string token at offset {0}", qstringIndex), qstringIndex, endIndex); + + break; + } + + if (startIndex == qstringIndex) + trimLeadingQuote = true; + } + } else { + if (!ParseUtils.SkipPhraseAtom (text, ref index, endIndex)) + break; + } + + length = index - startIndex; + + do { + if (!ParseUtils.SkipCommentsAndWhiteSpace (text, ref index, endIndex, throwOnError)) + return false; + + // Note: some clients don't quote dots in the name + if (index >= endIndex || text[index] != (byte) '.') + break; + + index++; + + length = index - startIndex; + } while (true); + + words++; + + // Note: some clients don't quote commas in the name + if (options.AllowUnquotedCommasInAddresses && index < endIndex && text[index] == ',' && !quoted) { + index++; + + length = index - startIndex; + + if (!ParseUtils.SkipCommentsAndWhiteSpace (text, ref index, endIndex, throwOnError)) + return false; + } + } + + if (!ParseUtils.SkipCommentsAndWhiteSpace (text, ref index, endIndex, throwOnError)) + return false; + + // specials = "(" / ")" / "<" / ">" / "@" ; Must be in quoted- + // / "," / ";" / ":" / "\" / <"> ; string, to use + // / "." / "[" / "]" ; within a word. + + if (index >= endIndex || text[index] == (byte) ',' || text[index] == (byte) '>' || text[index] == ';') { + // we've completely gobbled up an addr-spec w/o a domain + byte sentinel = index < endIndex ? text[index] : (byte) ','; + string name; + + if ((flags & AddressParserFlags.AllowMailboxAddress) == 0) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Addr-spec token at offset {0}", startIndex), startIndex, index); + + return false; + } + + if (!options.AllowAddressesWithoutDomain) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Incomplete addr-spec token at offset {0}", startIndex), startIndex, index); + + return false; + } + + // rewind back to the beginning of the local-part + index = startIndex; + + if (!TryParseLocalPart (text, ref index, endIndex, options.AddressParserComplianceMode, false, throwOnError, out var addrspec)) + return false; + + ParseUtils.SkipWhiteSpace (text, ref index, endIndex); + + if (index < endIndex && text[index] == '(') { + int comment = index + 1; + + // Note: this can't fail because it has already been skipped in TryParseLocalPart() above. + ParseUtils.SkipComment (text, ref index, endIndex); + + name = Rfc2047.DecodePhrase (options, text, comment, (index - 1) - comment).Trim (); + + ParseUtils.SkipCommentsAndWhiteSpace (text, ref index, endIndex, throwOnError); + } else { + name = string.Empty; + } + + if (index < endIndex && text[index] == (byte) '>') { + if (options.AddressParserComplianceMode == RfcComplianceMode.Strict) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Unexpected '>' token at offset {0}", index), startIndex, index); + + return false; + } + + index++; + } + + if (index < endIndex && text[index] != sentinel) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Unexpected '{0}' token at offset {1}", (char) text[index], index), startIndex, index); + + return false; + } + + address = new MailboxAddress (Encoding.UTF8, name, addrspec, -1); + + return true; + } + + if (text[index] == (byte) ':') { + // rfc2822 group address + int nameIndex = startIndex; + int codepage = -1; + string name; + + if ((flags & AddressParserFlags.AllowGroupAddress) == 0) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Group address token at offset {0}", startIndex), startIndex, index); + + return false; + } + + if (groupDepth >= options.MaxAddressGroupDepth) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Exceeded maximum rfc822 group depth at offset {0}", startIndex), startIndex, index); + + return false; + } + + if (trimLeadingQuote) { + nameIndex++; + length--; + } + + if (length > 0) { + name = Rfc2047.DecodePhrase (options, text, nameIndex, length, out codepage); + } else { + name = string.Empty; + } + + if (codepage == -1) + codepage = 65001; + + return TryParseGroup (flags, options, text, startIndex, ref index, endIndex, groupDepth + 1, MimeUtils.Unquote (name, true), codepage, out address); + } + + if ((flags & AddressParserFlags.AllowMailboxAddress) == 0) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Mailbox address token at offset {0}", startIndex), startIndex, index); + + return false; + } + + if (text[index] == (byte) '@') { + // we're either in the middle of an addr-spec token or we completely gobbled up an addr-spec w/o a domain + string name; + + // rewind back to the beginning of the local-part + index = startIndex; + + if (!TryParseAddrspec (text, ref index, endIndex, CommaGreaterThanOrSemiColon, options.AddressParserComplianceMode, throwOnError, out var addrspec, out int at)) + return false; + + ParseUtils.SkipWhiteSpace (text, ref index, endIndex); + + if (index < endIndex && text[index] == '(') { + int comment = index; + + if (!ParseUtils.SkipComment (text, ref index, endIndex)) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Incomplete comment token at offset {0}", comment), comment, index); + + return false; + } + + comment++; + + name = Rfc2047.DecodePhrase (options, text, comment, (index - 1) - comment).Trim (); + } else { + name = string.Empty; + } + + if (!ParseUtils.SkipCommentsAndWhiteSpace (text, ref index, endIndex, throwOnError)) + return false; + + if (index >= endIndex) { + address = new MailboxAddress (Encoding.UTF8, name, addrspec, at); + return true; + } + + if (text[index] == (byte) '<') { + // We have an address like "user@example.com "; i.e. the name is an unquoted string with an '@'. + if (options.AddressParserComplianceMode == RfcComplianceMode.Strict) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Unexpected '<' token at offset {0}", index), startIndex, index); + + return false; + } + + int nameEndIndex = index; + while (nameEndIndex > startIndex && text[nameEndIndex - 1].IsWhitespace ()) + nameEndIndex--; + + length = nameEndIndex - startIndex; + + // fall through to the rfc822 angle-addr token case... + } else { + // Note: since there was no '<', there should not be a '>'... but we handle it anyway in order to + // deal with the second Unbalanced Angle Brackets example in section 7.1.3: second@example.org> + if (text[index] == (byte) '>') { + if (options.AddressParserComplianceMode == RfcComplianceMode.Strict) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Unexpected '>' token at offset {0}", index), startIndex, index); + + return false; + } + + index++; + } + + address = new MailboxAddress (Encoding.UTF8, name, addrspec, at); + + return true; + } + } + + if (text[index] == (byte) '<') { + // rfc2822 angle-addr token + int nameIndex = startIndex; + int codepage = -1; + string name; + + if (trimLeadingQuote) { + nameIndex++; + length--; + } + + if (length > 0) { + var unquoted = MimeUtils.Unquote (text, nameIndex, length, true); + + name = Rfc2047.DecodePhrase (options, unquoted, 0, unquoted.Length, out codepage); + } else { + name = string.Empty; + } + + if (codepage == -1) + codepage = 65001; + + return TryParseMailbox (options, text, startIndex, ref index, endIndex, name, codepage, throwOnError, out address); + } + + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Invalid address token at offset {0}", startIndex), startIndex, index); + + return false; + } + } +} diff --git a/src/EmailAddresses/MailKit/InternetAddressList.cs b/src/EmailAddresses/MailKit/InternetAddressList.cs new file mode 100644 index 0000000..17c3f00 --- /dev/null +++ b/src/EmailAddresses/MailKit/InternetAddressList.cs @@ -0,0 +1,170 @@ +// +// InternetAddressList.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; +using System.Text; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; + +#if ENABLE_SNM +using System.Net.Mail; +#endif + +using MimeKit.Utils; + +namespace MimeKit { + /// + /// A list of email addresses. + /// + /// + /// An may contain any number of addresses of any type + /// defined by the original Internet Message specification. + /// There are effectively two (2) types of addresses: mailboxes and groups. + /// Mailbox addresses are what are most commonly known as email addresses and are + /// represented by the class. + /// Group addresses are themselves lists of addresses and are represented by the + /// class. While rare, it is still important to handle these + /// types of addresses. They typically only contain mailbox addresses, but may also + /// contain other group addresses. + /// + internal class InternetAddressList + { + readonly List list; + + /// + /// Initialize a new instance of the class. + /// + /// + /// Creates a new containing the supplied addresses. + /// + /// An initial list of addresses. + /// + /// is . + /// + public InternetAddressList (IEnumerable addresses) + { + if (addresses is null) + throw new ArgumentNullException (nameof (addresses)); + + if (addresses is IList lst) + list = new List (lst.Count); + else + list = new List (); + + foreach (var address in addresses) { + list.Add (address); + } + } + + /// + /// Initialize a new instance of the class. + /// + /// + /// Creates a new, empty, . + /// + public InternetAddressList () + { + list = new List (); + } + + internal static bool TryParse (AddressParserFlags flags, ParserOptions options, byte[] text, ref int index, int endIndex, bool isGroup, int groupDepth, out List addresses) + { + bool throwOnError = (flags & AddressParserFlags.ThrowOnError) != 0; + var list = new List (); + + addresses = null; + + if (!ParseUtils.SkipCommentsAndWhiteSpace (text, ref index, endIndex, throwOnError)) + return false; + + if (index == endIndex) { + if (throwOnError) + throw new ParseException ("No addresses found.", index, index); + + return false; + } + + while (index < endIndex) { + if (isGroup && text[index] == (byte) ';') + break; + + if (!InternetAddress.TryParse (flags, options, text, ref index, endIndex, groupDepth, out var address)) { + if ((flags & AddressParserFlags.Internal) == 0) { + // Note: If flags contains the ThrowOnError flag, then InternetAddress.TryParse() would have thrown. + return false; + } + + // skip this address... + while (index < endIndex && text[index] != (byte) ',' && (!isGroup || text[index] != (byte) ';')) + index++; + } else { + list.Add (address); + } + + // Note: we loop here in case there are any extraneous commas + bool skippedComma = false; + + do { + if (!ParseUtils.SkipCommentsAndWhiteSpace (text, ref index, endIndex, throwOnError)) + return false; + + if (index >= endIndex) + break; + + if (isGroup && text[index] == (byte) ';') + break; + + if (text[index] != (byte) ',') { + if (skippedComma) + break; + + if (options.AddressParserComplianceMode == RfcComplianceMode.Strict) { + if (throwOnError) { + if (isGroup) + throw new ParseException ("Expected ',' between addresses or ';' to denote the end of a group of addresses.", index, index); + else + throw new ParseException ("Expected ',' between addresses.", index, index); + } + + return false; + } else { + // start of a new address? + break; + } + } + + skippedComma = true; + index++; + } while (true); + } + + addresses = list; + + return true; + } + } +} diff --git a/src/EmailAddresses/MailKit/MailboxAddress.cs b/src/EmailAddresses/MailKit/MailboxAddress.cs new file mode 100644 index 0000000..3477900 --- /dev/null +++ b/src/EmailAddresses/MailKit/MailboxAddress.cs @@ -0,0 +1,214 @@ +// +// MailboxAddress.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; +using System.Text; +using System.Globalization; +using System.Collections.Generic; + +#if ENABLE_SNM +using System.Net.Mail; +#endif + +using MimeKit.Utils; +using MimeKit.Encodings; + +namespace MimeKit { + /// + /// A mailbox address, as specified by rfc822. + /// + /// + /// Represents a mailbox address (commonly referred to as an email address) + /// for a single recipient. + /// + internal class MailboxAddress : InternetAddress + { + /// + /// Get or set the punycode implementation that should be used for encoding and decoding mailbox addresses. + /// + /// + /// Gets or sets the punycode implementation that should be used for encoding and decoding mailbox addresses. + /// + /// The punycode implementation. + public static IPunycode IdnMapping { get; set; } + + string address; + int at; + + static MailboxAddress () + { + IdnMapping = new Punycode (); + } + + internal MailboxAddress (Encoding encoding, string name, IEnumerable route, string address, int at) : base (encoding, name) + { + Route = new DomainList (route); + + this.address = address; + this.at = at; + } + + internal MailboxAddress (Encoding encoding, string name, string address, int at) : base (encoding, name) + { + Route = new DomainList (); + + this.address = address; + this.at = at; + } + + /// + /// Get the mailbox route. + /// + /// + /// A route is convention that is rarely seen in modern email systems, but is supported + /// for compatibility with email archives. + /// + /// The mailbox route. + public DomainList Route { + get; private set; + } + + /// + /// Get or set the mailbox address. + /// + /// + /// Represents the actual email address and is in the form of user@domain.com. + /// + /// The mailbox address. + /// + /// is . + /// + /// + /// is malformed. + /// + public string Address { + get { return address; } + } + + /// + /// Get the local-part of the email address. + /// + /// + /// Gets the local-part of the email address, sometimes referred to as the "user" portion of an email address. + /// + /// The local-part portion of the mailbox address. + public string LocalPart { + get { + return at != -1 ? address.Substring (0, at) : address; + } + } + + /// + /// Get the domain of the email address. + /// + /// + /// Gets the domain of the email address. + /// + /// The domain portion of the mailbox address. + public string Domain { + get { + return at != -1 ? address.Substring (at + 1) : string.Empty; + } + } + + /// + /// Get whether the address is an international address. + /// + /// + /// International addresses are addresses that contain international + /// characters in either their local-parts or their domains. + /// For more information, see section 3.2 of + /// rfc6532. + /// + /// if the address is an international address; otherwise, . + public bool IsInternational { + get { + if (string.IsNullOrEmpty (address)) + return false; + + if (ParseUtils.IsInternational (address)) + return true; + + foreach (var domain in Route) { + if (ParseUtils.IsInternational (domain)) + return true; + } + + return false; + } + } + + internal static bool TryParse (ParserOptions options, byte[] text, ref int index, int endIndex, bool throwOnError, out MailboxAddress mailbox) + { + var flags = AddressParserFlags.AllowMailboxAddress; + + if (throwOnError) + flags |= AddressParserFlags.ThrowOnError; + + if (!InternetAddress.TryParse (flags, options, text, ref index, endIndex, 0, out var address)) { + mailbox = null; + return false; + } + + mailbox = (MailboxAddress) address; + + return true; + } + + /// + /// Try to parse the given text into a new instance. + /// + /// + /// Parses a single . If the address is not a mailbox address or + /// there is more than a single mailbox address, then parsing will fail. + /// + /// if the address was successfully parsed; otherwise, . + /// The parser options to use. + /// The text. + /// The parsed mailbox address. + public static bool TryParse (ParserOptions options, string text, out MailboxAddress mailbox) + { + if (!ArgumentValidator.TryValidate (options, text)) { + mailbox = null; + return false; + } + + var buffer = Encoding.UTF8.GetBytes (text); + int endIndex = buffer.Length; + int index = 0; + + if (!TryParse (options, buffer, ref index, endIndex, false, out mailbox)) + return false; + + if (!ParseUtils.SkipCommentsAndWhiteSpace (buffer, ref index, endIndex, false) || index != endIndex) { + mailbox = null; + return false; + } + + return true; + } + } +} diff --git a/src/EmailAddresses/MailKit/ParseException.cs b/src/EmailAddresses/MailKit/ParseException.cs new file mode 100644 index 0000000..9e5e856 --- /dev/null +++ b/src/EmailAddresses/MailKit/ParseException.cs @@ -0,0 +1,150 @@ +// +// ParseException.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; + +#if SERIALIZABLE +using System.Security; +using System.Runtime.Serialization; +#endif + +namespace MimeKit { + /// + /// A Parse exception as thrown by the various Parse methods in MimeKit. + /// + /// + /// A can be thrown by any of the Parse() methods + /// in MimeKit. Each exception instance will have a + /// which marks the byte offset of the token that failed to parse as well + /// as a which marks the byte offset where the error + /// occurred. + /// +#if SERIALIZABLE + [Serializable] +#endif + internal class ParseException : FormatException + { +#if SERIALIZABLE + /// + /// Initialize a new instance of the class. + /// + /// + /// Creates a new . + /// + /// The serialization info. + /// The stream context. + /// + /// is . + /// + [Obsolete ("This API supports obsolete formatter-based serialization. It should not be called or extended by application code.")] + protected ParseException (SerializationInfo info, StreamingContext context) : base (info, context) + { + TokenIndex = info.GetInt32 ("TokenIndex"); + ErrorIndex = info.GetInt32 ("ErrorIndex"); + } +#endif + + /// + /// Initialize a new instance of the class. + /// + /// + /// Creates a new . + /// + /// The error message. + /// The byte offset of the token. + /// The byte offset of the error. + /// The inner exception. + public ParseException (string message, int tokenIndex, int errorIndex, Exception innerException) : base (message, innerException) + { + TokenIndex = tokenIndex; + ErrorIndex = errorIndex; + } + + /// + /// Initialize a new instance of the class. + /// + /// + /// Creates a new . + /// + /// The error message. + /// The byte offset of the token. + /// The byte offset of the error. + public ParseException (string message, int tokenIndex, int errorIndex) : base (message) + { + TokenIndex = tokenIndex; + ErrorIndex = errorIndex; + } + +#if SERIALIZABLE + /// + /// When overridden in a derived class, sets the + /// with information about the exception. + /// + /// + /// Sets the + /// with information about the exception. + /// + /// The serialization info. + /// The streaming context. + /// + /// is . + /// + [SecurityCritical] +#if NET8_0_OR_GREATER + [Obsolete ("This API supports obsolete formatter-based serialization. It should not be called or extended by application code.")] +#endif + public override void GetObjectData (SerializationInfo info, StreamingContext context) + { + base.GetObjectData (info, context); + + info.AddValue ("TokenIndex", TokenIndex); + info.AddValue ("ErrorIndex", ErrorIndex); + } +#endif + + /// + /// Get the byte index of the token that was malformed. + /// + /// + /// The token index is the byte offset at which the token started. + /// + /// The byte index of the token. + public int TokenIndex { + get; private set; + } + + /// + /// Get the index of the byte that caused the error. + /// + /// + /// The error index is the byte offset at which the parser encountered an error. + /// + /// The index of the byte that caused error. + public int ErrorIndex { + get; private set; + } + } +} diff --git a/src/EmailAddresses/MailKit/ParserOptions.cs b/src/EmailAddresses/MailKit/ParserOptions.cs new file mode 100644 index 0000000..7bb68e7 --- /dev/null +++ b/src/EmailAddresses/MailKit/ParserOptions.cs @@ -0,0 +1,193 @@ +// +// ParserOptions.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; +using System.Text; +using System.Reflection; +using System.Collections.Generic; + +#if ENABLE_CRYPTO +using MimeKit.Cryptography; +#endif + +using MimeKit.Utils; +using System.Diagnostics.CodeAnalysis; + +namespace MimeKit { + /// + /// Parser options as used by as well as various Parse and TryParse methods in MimeKit. + /// + /// + /// allows you to change and/or override default parsing options used by methods such + /// as and others. + /// + internal class ParserOptions + { + /// + /// The default parser options. + /// + /// + /// If a is not supplied to or other Parse and TryParse + /// methods throughout MimeKit, will be used. + /// + public static readonly ParserOptions Default = new ParserOptions (); + + /// + /// Get or set the compliance mode that should be used when parsing rfc822 addresses. + /// + /// + /// In general, you'll probably want this value to be + /// (the default) as it allows maximum interoperability with existing (broken) mail clients + /// and other mail software such as sloppily written perl scripts (aka spambots). + /// Even in mode, the address parser + /// is fairly liberal in what it accepts. Setting it to + /// just makes it try harder to deal with garbage input. + /// + /// The RFC compliance mode. + public RfcComplianceMode AddressParserComplianceMode { get; set; } + + /// + /// Get or set whether the rfc822 address parser should ignore unquoted commas in address names. + /// + /// + /// In general, you'll probably want this value to be (the default) as it allows + /// maximum interoperability with existing (broken) mail clients and other mail software such as + /// sloppily written perl scripts (aka spambots) that do not properly quote the name when it + /// contains a comma. + /// + /// if the address parser should ignore unquoted commas in address names; otherwise, . + public bool AllowUnquotedCommasInAddresses { get; set; } + + /// + /// Get or set whether the rfc822 address parser should allow addresses without a domain. + /// + /// + /// In general, you'll probably want this value to be (the default) as it allows + /// maximum interoperability with older email messages that may contain local UNIX addresses. + /// This option exists in order to allow parsing of mailbox addresses that do not have an + /// @domain component. These types of addresses are rare and were typically only used when sending + /// mail to other users on the same UNIX system. + /// + /// if the address parser should allow mailbox addresses without a domain; otherwise, . + public bool AllowAddressesWithoutDomain { get; set; } + + /// + /// Get or set the maximum address group depth the parser should accept. + /// + /// + /// This option exists in order to define the maximum recursive depth of an rfc822 group address + /// that the parser should accept before bailing out with the assumption that the address is maliciously + /// formed. If the value is set too large, then it is possible that a maliciously formed set of + /// recursive group addresses could cause a stack overflow. + /// + /// The maximum address group depth. + public int MaxAddressGroupDepth { get; set; } + + /// + /// Get or set the maximum MIME nesting depth the parser should accept. + /// + /// + /// This option exists in order to define the maximum recursive depth of MIME parts that the parser + /// should accept before treating further nesting as a leaf-node MIME part and not recursing any further. + /// If the value is set too large, then it is possible that a maliciously formed set of deeply nested + /// multipart MIME parts could cause a stack overflow. + /// + /// The maximum MIME nesting depth. + public int MaxMimeDepth { get; set; } + + /// + /// Get or set the compliance mode that should be used when parsing Content-Type and Content-Disposition parameters. + /// + /// + /// In general, you'll probably want this value to be + /// (the default) as it allows maximum interoperability with existing (broken) mail clients + /// and other mail software such as sloppily written perl scripts (aka spambots). + /// Even in mode, the parameter parser + /// is fairly liberal in what it accepts. Setting it to + /// just makes it try harder to deal with garbage input. + /// + /// The RFC compliance mode. + public RfcComplianceMode ParameterComplianceMode { get; set; } + + /// + /// Get or set the compliance mode that should be used when decoding rfc2047 encoded words. + /// + /// + /// In general, you'll probably want this value to be + /// (the default) as it allows maximum interoperability with existing (broken) mail clients + /// and other mail software such as sloppily written perl scripts (aka spambots). + /// + /// The RFC compliance mode. + public RfcComplianceMode Rfc2047ComplianceMode { get; set; } + + /// + /// Get or set a value indicating whether the Content-Length value should be + /// respected when parsing mbox streams. + /// + /// + /// For more details about why this may be useful, you can find more information + /// at + /// http://www.jwz.org/doc/content-length.html. + /// + /// if the Content-Length value should be respected; + /// otherwise, . + public bool RespectContentLength { get; set; } + + /// + /// Get or set the charset encoding to use as a fallback for 8bit headers. + /// + /// + /// and + /// + /// use this charset encoding as a fallback when decoding 8bit text into unicode. The first + /// charset encoding attempted is UTF-8, followed by this charset encoding, before finally + /// falling back to iso-8859-1. + /// + /// The charset encoding. + public Encoding CharsetEncoding { get; set; } + + /// + /// Initialize a new instance of the class. + /// + /// + /// By default, new instances of enable rfc2047 work-arounds + /// (which are needed for maximum interoperability with mail software used in the wild) + /// and do not respect the Content-Length header value. + /// + public ParserOptions () + { + AddressParserComplianceMode = RfcComplianceMode.Loose; + ParameterComplianceMode = RfcComplianceMode.Loose; + Rfc2047ComplianceMode = RfcComplianceMode.Loose; + CharsetEncoding = CharsetUtils.UTF8; + AllowUnquotedCommasInAddresses = true; + AllowAddressesWithoutDomain = true; + RespectContentLength = false; + MaxAddressGroupDepth = 3; + MaxMimeDepth = 1024; + } + } +} diff --git a/src/EmailAddresses/MailKit/RfcComplianceMode.cs b/src/EmailAddresses/MailKit/RfcComplianceMode.cs new file mode 100644 index 0000000..e1e1be0 --- /dev/null +++ b/src/EmailAddresses/MailKit/RfcComplianceMode.cs @@ -0,0 +1,50 @@ +// +// RfcComplianceMode.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +namespace MimeKit { + /// + /// An RFC compliance mode. + /// + /// + /// An RFC compliance mode. + /// + internal enum RfcComplianceMode { + /// + /// Attempt to be even more liberal in accepting broken and/or invalid formatting. + /// + Looser = -1, + + /// + /// Attempt to be more liberal accepting broken and/or invalid formatting. + /// + Loose, + + /// + /// Do not attempt to be overly liberal in accepting broken and/or invalid formatting. + /// + Strict + } +} diff --git a/src/EmailAddresses/MailKit/Utils/ArgumentValidator.cs b/src/EmailAddresses/MailKit/Utils/ArgumentValidator.cs new file mode 100644 index 0000000..7105c08 --- /dev/null +++ b/src/EmailAddresses/MailKit/Utils/ArgumentValidator.cs @@ -0,0 +1,43 @@ +// +// ArgumentValidator.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; + +namespace MimeKit.Utils { + static class ArgumentValidator + { + public static bool TryValidate (ParserOptions options, string text) + { + if (options is null) + return false; + + if (text is null) + return false; + + return true; + } + } +} diff --git a/src/EmailAddresses/MailKit/Utils/ByteArrayBuilder.cs b/src/EmailAddresses/MailKit/Utils/ByteArrayBuilder.cs new file mode 100644 index 0000000..946aa18 --- /dev/null +++ b/src/EmailAddresses/MailKit/Utils/ByteArrayBuilder.cs @@ -0,0 +1,76 @@ +// +// ByteArrayBuilder.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; +using System.Buffers; + +namespace MimeKit.Utils { + internal ref struct ByteArrayBuilder + { + byte[] buffer; + int length; + + public ByteArrayBuilder (int initialCapacity) + { + buffer = ArrayPool.Shared.Rent (initialCapacity); + length = 0; + } + + void EnsureCapacity (int capacity) + { + if (capacity > buffer.Length) { + var resized = ArrayPool.Shared.Rent (capacity); + Buffer.BlockCopy (buffer, 0, resized, 0, length); + ArrayPool.Shared.Return (buffer); + buffer = resized; + } + } + + public void Append (byte c) + { + EnsureCapacity (length + 1); + + buffer[length++] = c; + } + + public byte[] ToArray () + { + var array = new byte[length]; + + Buffer.BlockCopy (buffer, 0, array, 0, length); + + return array; + } + + public void Dispose () + { + if (buffer != null) { + ArrayPool.Shared.Return (buffer); + buffer = null; + } + } + } +} diff --git a/src/EmailAddresses/MailKit/Utils/ByteExtensions.cs b/src/EmailAddresses/MailKit/Utils/ByteExtensions.cs new file mode 100644 index 0000000..c954b59 --- /dev/null +++ b/src/EmailAddresses/MailKit/Utils/ByteExtensions.cs @@ -0,0 +1,189 @@ +// +// ByteExtensions.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; + +namespace MimeKit.Utils { + [Flags] + enum CharType : ushort { + None = 0, + IsAscii = (1 << 0), + IsAtom = (1 << 1), + IsAttrChar = (1 << 2), + IsBlank = (1 << 3), + IsControl = (1 << 4), + IsDomainSafe = (1 << 5), + IsEncodedPhraseSafe = (1 << 6), + IsEncodedWordSafe = (1 << 7), + IsQuotedPrintableSafe = (1 << 8), + IsSpace = (1 << 9), + IsSpecial = (1 << 10), + IsTokenSpecial = (1 << 11), + IsWhitespace = (1 << 12), + IsXDigit = (1 << 13), + IsPhraseAtom = (1 << 14), + IsFieldText = (1 << 15), + + IsAsciiAtom = IsAscii | IsAtom, + } + + static class ByteExtensions + { + const string AtomSafe = "!#$%&'*+-/=?^_`{|}~"; + const string AttributeSpecials = "*'%"; // attribute specials from rfc2184/rfc2231 + const string EncodedWordSpecials = "()<>@,;:\"/[]?.=_"; // rfc2047 5.1 + const string EncodedPhraseSafe = "!*+-/"; // rfc2047 5.3 (but w/o '=' and '_' since they need to be encoded, obviously) + const string Specials = "()<>[]:;@\\,.\""; // rfc5322 3.2.3 + internal const string TokenSpecials = "()<>@,;:\\\"/[]?="; // rfc2045 5.1 + const string Whitespace = " \t\r\n"; + + static readonly CharType[] table = new CharType[256]; + + static void RemoveFlags (string values, CharType bit) + { + for (int i = 0; i < values.Length; i++) + table[(byte) values[i]] &= ~bit; + } + + static void SetFlags (string values, CharType bit) + { + for (int i = 0; i < values.Length; i++) + table[values[i]] |= bit; + } + + static ByteExtensions () + { + for (int i = 0; i < 256; i++) { + if (i < 127) { + if (i < 32) + table[i] |= CharType.IsControl | CharType.IsTokenSpecial; + if (i > 32) + table[i] |= CharType.IsAttrChar; + if (i >= 32 && i != 61) // space + all printable characters *except* '=' (61) + table[i] |= CharType.IsQuotedPrintableSafe | CharType.IsEncodedWordSafe; + if ((i >= '0' && i <= '9') || (i >= 'a' && i <= 'z') || (i >= 'A' && i <= 'Z')) + table[i] |= CharType.IsEncodedPhraseSafe | CharType.IsAtom | CharType.IsPhraseAtom; + if ((i >= '0' && i <= '9') || (i >= 'a' && i <= 'f') || (i >= 'A' && i <= 'F')) + table[i] |= CharType.IsXDigit; + if (i >= 33 && i != 58) // all printable characters *except* ':' + table[i] |= CharType.IsFieldText; + if ((i >= 33 && i <= 90) || i >= 94) // all printable characters *except* '[', '\\', and ']' + table[i] |= CharType.IsDomainSafe; + + table[i] |= CharType.IsAscii; + } else { + if (i == 127) + table[i] |= CharType.IsAscii; + else + table[i] |= CharType.IsAtom | CharType.IsPhraseAtom; + + table[i] |= CharType.IsControl | CharType.IsTokenSpecial; + } + } + + table['\t'] |= CharType.IsQuotedPrintableSafe | CharType.IsBlank; + table[' '] |= CharType.IsSpace | CharType.IsBlank; + + SetFlags (Whitespace, CharType.IsWhitespace); + SetFlags (AtomSafe, CharType.IsAtom | CharType.IsPhraseAtom); + SetFlags (TokenSpecials, CharType.IsTokenSpecial); + SetFlags (Specials, CharType.IsSpecial); + RemoveFlags (Specials, CharType.IsAtom | CharType.IsPhraseAtom); + RemoveFlags (EncodedWordSpecials, CharType.IsEncodedWordSafe); + RemoveFlags (AttributeSpecials + TokenSpecials, CharType.IsAttrChar); + SetFlags (EncodedPhraseSafe, CharType.IsEncodedPhraseSafe); + + // Note: Allow '[' and ']' in the display-name of a mailbox address + table['['] |= CharType.IsPhraseAtom; + table[']'] |= CharType.IsPhraseAtom; + + // Note: Allow ')' in the display-name of a mailbox address for rfc7103 unbalanced parenthesis + table[')'] |= CharType.IsPhraseAtom; + } + + //public static bool IsAscii (this byte c) + //{ + // return (table[c] & CharType.IsAscii) != 0; + //} + + public static bool IsAsciiAtom (this byte c) + { + return (table[c] & CharType.IsAsciiAtom) == CharType.IsAsciiAtom; + } + + public static bool IsPhraseAtom (this byte c) + { + return (table[c] & CharType.IsPhraseAtom) != 0; + } + + public static bool IsAtom (this byte c) + { + return (table[c] & CharType.IsAtom) != 0; + } + + public static bool IsBlank (this byte c) + { + return (table[c] & CharType.IsBlank) != 0; + } + + public static bool IsCtrl (this byte c) + { + return (table[c] & CharType.IsControl) != 0; + } + + public static bool IsDomain (this byte c) + { + return (table[c] & CharType.IsDomainSafe) != 0; + } + + public static bool IsType (this byte c, CharType type) + { + return (table[c] & type) != 0; + } + + public static bool IsWhitespace (this byte c) + { + return (table[c] & CharType.IsWhitespace) != 0; + } + + public static bool IsXDigit (this byte c) + { + return (table[c] & CharType.IsXDigit) != 0; + } + + public static byte ToXDigit (this byte c) + { + if (c >= 0x41) { + if (c >= 0x61) + return (byte) (c - (0x61 - 0x0a)); + + return (byte) (c - (0x41 - 0x0A)); + } + + return (byte) (c - 0x30); + } + } +} diff --git a/src/EmailAddresses/MailKit/Utils/CharsetUtils.cs b/src/EmailAddresses/MailKit/Utils/CharsetUtils.cs new file mode 100644 index 0000000..f85c2db --- /dev/null +++ b/src/EmailAddresses/MailKit/Utils/CharsetUtils.cs @@ -0,0 +1,495 @@ +// +// CharsetUtils.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; +using System.IO; +using System.Text; +using System.Globalization; +using System.Collections.Generic; + +namespace MimeKit.Utils { + internal static class CharsetUtils + { + static readonly char[] DashUnderscore = new[] { '-', '_' }; + static readonly Dictionary aliases; + public static readonly Encoding Latin1; + public static readonly Encoding UTF8; + + static CharsetUtils () + { + try { + Latin1 = Encoding.GetEncoding (28591, new EncoderExceptionFallback (), new DecoderExceptionFallback ()); + } catch (NotSupportedException) { + // Note: Some ASP.NET environments running on .NET Framework >= v4.6.1 do not include the full spectrum of + // text encodings, so iso-8859-1 will not always be available unless the program includes a reference to + // the System.Text.Encoding.CodePages nuget package *and* it has been registered via the + // System.Text.Encoding.RegisterProvider method. In cases where this hasn't been done, we need some fallback + // logic that tries to handle this at least somewhat. + + // Use ASCII as a fallback. + Latin1 = Encoding.ASCII; + } + + // Note: Encoding.UTF8.GetString() replaces invalid bytes with a unicode '?' character, + // so we use our own UTF8 instance when using GetString() if we do not want it to do that. + UTF8 = Encoding.GetEncoding (65001, new EncoderExceptionFallback (), new DecoderExceptionFallback ()); + + aliases = new Dictionary (MimeUtils.OrdinalIgnoreCase); + + AddAliases (aliases, 65001, -1, "utf-8", "utf8", "unicode"); + + // ANSI_X3.4-1968 is used on some systems and should be + // treated the same as US-ASCII. + AddAliases (aliases, 20127, -1, "ansi_x3.4-1968"); + + // ANSI_X3.110-1983 is another odd-ball charset that appears + // every once in a while and seems closest to ISO-8859-1. + AddAliases (aliases, 28591, -1, "ansi_x3.110-1983", "latin1"); + + // Macintosh aliases + AddAliases (aliases, 10000, -1, "macintosh"); + AddAliases (aliases, 10079, -1, "x-mac-icelandic"); + + // Korean charsets (aliases for euc-kr) + // 'upgrade' ks_c_5601-1987 to euc-kr since it is a superset + AddAliases (aliases, 51949, -1, + "ks_c_5601-1987", + "ksc-5601-1987", + "ksc-5601_1987", + "ksc-5601", + "5601", + "ks_c_5861-1992", + "ksc-5861-1992", + "ksc-5861_1992", + "euckr-0", + "euc-kr"); + + // Chinese charsets (aliases for big5) + AddAliases (aliases, 950, -1, "big5", "big5-0", "big5-hkscs", "big5.eten-0", "big5hkscs-0"); + + // Chinese charsets (aliases for gb2312) + int gb2312 = AddAliases (aliases, 936, -1, "gb2312", "gb-2312", "gb2312-0", "gb2312-80", "gb2312.1980-0"); + + // Chinese charsets (euc-cn and gbk not supported on Mono) + // https://bugzilla.mozilla.org/show_bug.cgi?id=844082 seems to suggest falling back to gb2312. + AddAliases (aliases, 51936, gb2312, "euc-cn", "gbk-0", "x-gbk", "gbk"); + + // Chinese charsets (hz-gb-2312 not suported on Mono) + AddAliases (aliases, 52936, gb2312, "hz-gb-2312", "hz-gb2312"); + + // Chinese charsets (aliases for gb18030) + AddAliases (aliases, 54936, -1, "gb18030-0", "gb18030"); + + // Japanese charsets (aliases for euc-jp) + AddAliases (aliases, 51932, -1, "eucjp-0", "euc-jp", "ujis-0", "ujis"); + + // Japanese charsets (aliases for Shift_JIS) + AddAliases (aliases, 932, -1, "shift_jis", "jisx0208.1983-0", "jisx0212.1990-0", "pck"); + + // Note from http://msdn.microsoft.com/en-us/library/system.text.encoding.getencodings.aspx + // Encodings 50220 and 50222 are both associated with the name "iso-2022-jp", but they + // are not identical. Encoding 50220 converts half-width Katakana characters to + // full-width Katakana characters, whereas encoding 50222 uses a shift-in/shift-out + // sequence to encode half-width Katakana characters. The display name for encoding + // 50222 is "Japanese (JIS-Allow 1 byte Kana - SO/SI)" to distinguish it from encoding + // 50220, which has the display name "Japanese (JIS)". + // + // If your application requests the encoding name "iso-2022-jp", the .NET Framework + // returns encoding 50220. However, the encoding that is appropriate for your application + // will depend on the preferred treatment of the half-width Katakana characters. + AddAliases (aliases, 50220, -1, "iso-2022-jp"); + } + + static bool ProbeCharset (int codepage) + { + try { + Encoding.GetEncoding (codepage); + return true; + } catch { + return false; + } + } + + static int AddAliases (Dictionary dict, int codepage, int fallback, params string[] names) + { + int value = ProbeCharset (codepage) ? codepage : fallback; + + for (int i = 0; i < names.Length; i++) + dict.Add (names[i], value); + + return value; + } + + static bool TryParseInt32 (string text, int startIndex, int count, out int value) + { +#if NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER + return int.TryParse (text.AsSpan (startIndex, count), NumberStyles.None, CultureInfo.InvariantCulture, out value); +#else + return int.TryParse (text.Substring (startIndex, count), NumberStyles.None, CultureInfo.InvariantCulture, out value); +#endif + } + + static int ParseIsoCodePage (string charset, int startIndex) + { + if ((charset.Length - startIndex) < 5) + return -1; + + int dash = charset.IndexOfAny (DashUnderscore, startIndex); + if (dash == -1) + dash = charset.Length; + + if (!TryParseInt32 (charset, startIndex, dash - startIndex, out int iso)) + return -1; + + if (iso == 10646) + return 1201; + + if (dash + 2 > charset.Length) + return -1; + + int codepageIndex = dash + 1; + int codepage; + + switch (iso) { + case 8859: + if (!TryParseInt32 (charset, codepageIndex, charset.Length - codepageIndex, out codepage)) + return -1; + + if (codepage <= 0 || (codepage > 9 && codepage < 13) || codepage > 15) + return -1; + + codepage += 28590; + break; + case 2022: + int n = charset.Length - codepageIndex; + + if (n == 2 && string.Compare (charset, codepageIndex, "jp", 0, 2, StringComparison.OrdinalIgnoreCase) == 0) + codepage = 50220; + else if (n == 2 && string.Compare (charset, codepageIndex, "kr", 0, 2, StringComparison.OrdinalIgnoreCase) == 0) + codepage = 50225; + else + codepage = -1; + break; + default: + return -1; + } + + return codepage; + } + + internal static int ParseCodePage (string charset) + { + int codepage; + int i; + + if (charset.StartsWith ("windows", StringComparison.OrdinalIgnoreCase)) { + i = 7; + + if (i == charset.Length) + return -1; + + if (charset[i] == '-' || charset[i] == '_') { + if (i + 1 == charset.Length) + return -1; + + i++; + } + + if (i + 2 < charset.Length && charset[i] == 'c' && charset[i + 1] == 'p') + i += 2; + + if (TryParseInt32 (charset, i, charset.Length - i, out codepage)) + return codepage; + } else if (charset.StartsWith ("ibm", StringComparison.OrdinalIgnoreCase)) { + i = 3; + + if (i == charset.Length) + return -1; + + if (charset[i] == '-' || charset[i] == '_') + i++; + + if (TryParseInt32 (charset, i, charset.Length - i, out codepage)) + return codepage; + } else if (charset.StartsWith ("iso", StringComparison.OrdinalIgnoreCase)) { + i = 3; + + if (i == charset.Length) + return -1; + + if (charset[i] == '-' || charset[i] == '_') + i++; + + if ((codepage = ParseIsoCodePage (charset, i)) != -1) + return codepage; + } else if (charset.StartsWith ("cp", StringComparison.OrdinalIgnoreCase)) { + i = 2; + + if (i == charset.Length) + return -1; + + if (charset[i] == '-' || charset[i] == '_') + i++; + + if (TryParseInt32 (charset, i, charset.Length - i, out codepage)) + return codepage; + } else if (charset.Equals ("latin1", StringComparison.OrdinalIgnoreCase)) { + return 28591; + } + + return -1; + } + + public static int GetCodePage (string charset) + { + if (charset is null) + throw new ArgumentNullException (nameof (charset)); + + int codepage; + + lock (aliases) { + if (!aliases.TryGetValue (charset, out codepage)) { + Encoding encoding; + + codepage = ParseCodePage (charset); + + if (codepage == -1) { + try { + encoding = Encoding.GetEncoding (charset); + codepage = encoding.CodePage; + + if (!aliases.ContainsKey (encoding.WebName)) + aliases[encoding.WebName] = codepage; + } catch { + codepage = -1; + } + } else { + try { + encoding = Encoding.GetEncoding (codepage); + if (!aliases.ContainsKey (encoding.WebName)) + aliases[encoding.WebName] = codepage; + } catch { + codepage = -1; + } + } + + if (!aliases.ContainsKey (charset)) + aliases[charset] = codepage; + } + } + + return codepage; + } + + public static Encoding GetEncoding (int codepage, string fallback) + { + if (fallback is null) + throw new ArgumentNullException (nameof (fallback)); + + var encoderFallback = new EncoderReplacementFallback (fallback); + var decoderFallback = new DecoderReplacementFallback (fallback); + + return Encoding.GetEncoding (codepage, encoderFallback, decoderFallback); + } + + public static Encoding GetEncoding (int codepage) + { + return Encoding.GetEncoding (codepage); + } + + public static Encoding GetEncodingOrDefault (int codepage, Encoding defaultEncoding) + { + try { + return Encoding.GetEncoding (codepage); + } catch { + return defaultEncoding; + } + } + + class InvalidByteCountFallback : DecoderFallback + { + class InvalidByteCountFallbackBuffer : DecoderFallbackBuffer + { + readonly InvalidByteCountFallback fallback; + const string replacement = "?"; + bool invalid; + int current; + + public InvalidByteCountFallbackBuffer (InvalidByteCountFallback fallback) + { + this.fallback = fallback; + } + + public override bool Fallback (byte[] bytesUnknown, int index) + { + fallback.InvalidByteCount++; + invalid = true; + current = 0; + return true; + } + + public override char GetNextChar () + { + if (!invalid) + return '\0'; + + if (current == replacement.Length) + return '\0'; + + return replacement[current++]; + } + + public override bool MovePrevious () + { + if (current == 0) + return false; + + current--; + + return true; + } + + public override int Remaining { + get { return invalid ? replacement.Length - current : 0; } + } + + public override void Reset () + { + invalid = false; + current = 0; + + base.Reset (); + } + } + + public InvalidByteCountFallback () + { + Reset (); + } + + public int InvalidByteCount { + get; private set; + } + + public void Reset () + { + InvalidByteCount = 0; + } + + public override DecoderFallbackBuffer CreateFallbackBuffer () + { + return new InvalidByteCountFallbackBuffer (this); + } + + public override int MaxCharCount { + get { return 1; } + } + } + + internal static char[] ConvertToUnicode (ParserOptions options, byte[] input, int startIndex, int length, out int charCount) + { + var invalid = new InvalidByteCountFallback (); + var userCharset = options.CharsetEncoding; + int min = int.MaxValue; + int bestCharCount = 0; + Encoding encoding; + Decoder decoder; + int[] codepages; + int best = -1; + int count; + + // Note: 65001 is UTF-8 and 28591 is iso-8859-1 + if (userCharset != null && userCharset.CodePage != 65001 && userCharset.CodePage != 28591) { + codepages = new [] { 65001, userCharset.CodePage, 28591 }; + } else { + codepages = new [] { 65001, 28591 }; + } + + for (int i = 0; i < codepages.Length; i++) { + encoding = Encoding.GetEncoding (codepages[i], new EncoderReplacementFallback ("?"), invalid); + decoder = encoding.GetDecoder (); + + count = decoder.GetCharCount (input, startIndex, length, true); + if (invalid.InvalidByteCount < min) { + min = invalid.InvalidByteCount; + bestCharCount = count; + best = codepages[i]; + + if (min == 0) + break; + } + + invalid.Reset (); + } + + encoding = GetEncoding (best, "?"); + decoder = encoding.GetDecoder (); + + var output = new char[bestCharCount]; + + try { + charCount = decoder.GetChars (input, startIndex, length, output, 0, true); + } catch { + charCount = 0; + } + + return output; + } + + internal static char[] ConvertToUnicode (Encoding encoding, byte[] input, int startIndex, int length, out int charCount) + { + var decoder = encoding.GetDecoder (); + int count = decoder.GetCharCount (input, startIndex, length, true); + var output = new char[count]; + + try { + charCount = decoder.GetChars (input, startIndex, length, output, 0, true); + } catch { + charCount = 0; + } + + return output; + } + + internal static char[] ConvertToUnicode (ParserOptions options, int codepage, byte[] input, int startIndex, int length, out int charCount) + { + Encoding encoding = null; + + if (codepage != -1) { + try { + encoding = GetEncoding (codepage); + } catch (NotSupportedException) { + } catch (ArgumentException) { + } + } + + if (encoding is null) + return ConvertToUnicode (options, input, startIndex, length, out charCount); + + return ConvertToUnicode (encoding, input, startIndex, length, out charCount); + } + } +} diff --git a/src/EmailAddresses/MailKit/Utils/MimeUtils.cs b/src/EmailAddresses/MailKit/Utils/MimeUtils.cs new file mode 100644 index 0000000..9957261 --- /dev/null +++ b/src/EmailAddresses/MailKit/Utils/MimeUtils.cs @@ -0,0 +1,171 @@ +// +// MimeUtils.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; +using System.Text; +using System.Collections.Generic; +using System.Net.NetworkInformation; +using System.Security.Cryptography; + +namespace MimeKit.Utils { + /// + /// MIME utility methods. + /// + /// + /// Various utility methods that don't belong anywhere else. + /// + internal static class MimeUtils + { + static readonly char[] UnquoteChars = new[] { '\r', '\n', '\t', '\\', '"' }; + + /// + /// A string comparer that performs a case-insensitive ordinal string comparison. + /// + /// + /// A string comparer that performs a case-insensitive ordinal string comparison. + /// + public static readonly IEqualityComparer OrdinalIgnoreCase; + + static MimeUtils () + { +#if NETFRAMEWORK || NETSTANDARD2_0 + OrdinalIgnoreCase = new OptimizedOrdinalIgnoreCaseComparer (); +#else + OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase; +#endif + } + +#if !NET6_0_OR_GREATER + internal static void GetRandomBytes (byte[] buffer) + { +#if NETSTANDARD2_1 || NET5_0_OR_GREATER + RandomNumberGenerator.Fill (buffer); +#else + using (var random = RandomNumberGenerator.Create ()) + random.GetBytes (buffer); +#endif + } +#endif + + /// + /// Unquote the specified text. + /// + /// + /// Unquotes the specified text, removing any escaped backslashes within. + /// + /// The unquoted text. + /// The text to unquote. + /// if tab characters should be converted to a space; otherwise, . + /// + /// is . + /// + public static string Unquote (string text, bool convertTabsToSpaces = false) + { + if (text is null) + throw new ArgumentNullException (nameof (text)); + + int index = text.IndexOfAny (UnquoteChars); + + if (index == -1) + return text; + + var builder = new ValueStringBuilder (text.Length); + bool escaped = false; + bool quoted = false; + + for (int i = 0; i < text.Length; i++) { + switch (text[i]) { + case '\r': + case '\n': + escaped = false; + break; + case '\t': + builder.Append (convertTabsToSpaces ? ' ' : '\t'); + escaped = false; + break; + case '\\': + if (escaped) + builder.Append ('\\'); + escaped = !escaped; + break; + case '"': + if (escaped) { + builder.Append ('"'); + escaped = false; + } else { + quoted = !quoted; + } + break; + default: + builder.Append (text[i]); + escaped = false; + break; + } + } + + return builder.ToString (); + } + + internal static byte[] Unquote (byte[] text, int startIndex, int length, bool convertTabsToSpaces = false) + { + using var builder = new ByteArrayBuilder (length); + bool escaped = false; + bool quoted = false; + + for (int i = startIndex; i < startIndex + length; i++) { + switch ((char) text[i]) { + case '\r': + case '\n': + escaped = false; + break; + case '\t': + builder.Append ((byte) (convertTabsToSpaces ? ' ' : '\t')); + escaped = false; + break; + case '\\': + if (escaped) + builder.Append ((byte) '\\'); + escaped = !escaped; + break; + case '"': + if (escaped) { + builder.Append ((byte) '"'); + escaped = false; + } else { + quoted = !quoted; + } + break; + default: + builder.Append (text[i]); + escaped = false; + break; + } + } + + return builder.ToArray (); + } + } +} diff --git a/src/EmailAddresses/MailKit/Utils/ParseUtils.cs b/src/EmailAddresses/MailKit/Utils/ParseUtils.cs new file mode 100644 index 0000000..04a05df --- /dev/null +++ b/src/EmailAddresses/MailKit/Utils/ParseUtils.cs @@ -0,0 +1,304 @@ +// +// ParseUtils.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; +using System.Text; +using System.Globalization; + +namespace MimeKit.Utils { + static class ParseUtils + { + public static bool SkipWhiteSpace (byte[] text, ref int index, int endIndex) + { + int startIndex = index; + + while (index < endIndex && text[index].IsWhitespace ()) + index++; + + return index > startIndex; + } + + public static bool SkipComment (byte[] text, ref int index, int endIndex) + { + bool escaped = false; + int depth = 1; + + index++; + + while (index < endIndex && depth > 0) { + if (text[index] == (byte) '\\') { + escaped = !escaped; + } else if (!escaped) { + if (text[index] == (byte) '(') + depth++; + else if (text[index] == (byte) ')') + depth--; + } else { + escaped = false; + } + + index++; + } + + return depth == 0; + } + + public static bool SkipCommentsAndWhiteSpace (byte[] text, ref int index, int endIndex, bool throwOnError) + { + SkipWhiteSpace (text, ref index, endIndex); + + while (index < endIndex && text[index] == (byte) '(') { + int startIndex = index; + + if (!SkipComment (text, ref index, endIndex)) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Incomplete comment token at offset {0}", startIndex), startIndex, index); + + return false; + } + + SkipWhiteSpace (text, ref index, endIndex); + } + + return true; + } + + public static bool SkipQuoted (byte[] text, ref int index, int endIndex, bool throwOnError) + { + int startIndex = index; + bool escaped = false; + + // skip over leading '"' + index++; + + while (index < endIndex) { + if (text[index] == (byte) '\\') { + escaped = !escaped; + } else if (!escaped) { + if (text[index] == (byte) '"') + break; + } else { + escaped = false; + } + + index++; + } + + if (index >= endIndex) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Incomplete quoted-string token at offset {0}", startIndex), startIndex, index); + + return false; + } + + // skip over the closing '"' + index++; + + return true; + } + + public static bool SkipAtom (byte[] text, ref int index, int endIndex) + { + int start = index; + + while (index < endIndex && text[index].IsAtom ()) + index++; + + return index > start; + } + + // Note: a "phrase atom" is a more lenient atom (e.g. mailbox display-name phrase atom) + public static bool SkipPhraseAtom (byte[] text, ref int index, int endIndex) + { + int start = index; + + while (index < endIndex && text[index].IsPhraseAtom ()) + index++; + + return index > start; + } + + public static bool SkipWord (byte[] text, ref int index, int endIndex, bool throwOnError) + { + if (text[index] == (byte) '"') + return SkipQuoted (text, ref index, endIndex, throwOnError); + + if (text[index].IsAtom ()) + return SkipAtom (text, ref index, endIndex); + + return false; + } + + public static bool IsSentinel (byte c, ReadOnlySpan sentinels) + { + for (int i = 0; i < sentinels.Length; i++) { + if (c == sentinels[i]) + return true; + } + + return false; + } + + static bool TryParseDotAtom (byte[] text, ref int index, int endIndex, ReadOnlySpan sentinels, bool throwOnError, string tokenType, out string dotatom) + { + using var token = new ValueStringBuilder (128); + int startIndex = index; + int comment; + + dotatom = null; + + do { + if (!text[index].IsAtom ()) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Invalid {0} token at offset {1}", tokenType, startIndex), startIndex, index); + + return false; + } + + int start = index; + while (index < endIndex && text[index].IsAtom ()) + index++; + + try { + token.Append (CharsetUtils.UTF8.GetString (text, start, index - start)); + } catch (DecoderFallbackException ex) { + if (throwOnError) + throw new ParseException ("Internationalized domains may only contain UTF-8 characters.", start, start, ex); + + return false; + } + + comment = index; + if (!SkipCommentsAndWhiteSpace (text, ref index, endIndex, throwOnError)) + return false; + + if (index >= endIndex || text[index] != (byte) '.') { + index = comment; + break; + } + + // skip over the '.' + index++; + + if (!SkipCommentsAndWhiteSpace (text, ref index, endIndex, throwOnError)) + return false; + + // allow domains to end with a '.', but strip it off + if (index >= endIndex || IsSentinel (text[index], sentinels)) + break; + + token.Append ('.'); + } while (true); + + dotatom = token.ToString (); + + return true; + } + + static bool TryParseDomainLiteral (byte[] text, ref int index, int endIndex, bool throwOnError, out string domain) + { + using var token = new ValueStringBuilder (128); + int startIndex = index++; + + domain = null; + + token.Append ('['); + + SkipWhiteSpace (text, ref index, endIndex); + + do { + while (index < endIndex && text[index].IsDomain ()) { + token.Append ((char) text[index]); + index++; + } + + SkipWhiteSpace (text, ref index, endIndex); + + if (index >= endIndex) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Incomplete domain literal token at offset {0}", startIndex), startIndex, index); + + return false; + } + + if (text[index] == (byte) ']') + break; + + if (!text[index].IsDomain ()) { + if (throwOnError) + throw new ParseException (string.Format (CultureInfo.InvariantCulture, "Invalid domain literal token at offset {0}", startIndex), startIndex, index); + + return false; + } + } while (true); + + token.Append (']'); + index++; + + domain = token.ToString (); + + return true; + } + + public static bool TryParseDomain (byte[] text, ref int index, int endIndex, ReadOnlySpan sentinels, bool throwOnError, out string domain) + { + if (text[index] == (byte) '[') + return TryParseDomainLiteral (text, ref index, endIndex, throwOnError, out domain); + + return TryParseDotAtom (text, ref index, endIndex, sentinels, throwOnError, "domain", out domain); + } + + public static bool IsInternational (string value, int startIndex, int count) + { + int endIndex = startIndex + count; + + for (int i = startIndex; i < endIndex; i++) { + if (value[i] > 127) + return true; + } + + return false; + } + + public static bool IsInternational (string value, int startIndex) + { + return IsInternational (value, startIndex, value.Length - startIndex); + } + + public static bool IsInternational (string value) + { + return IsInternational (value, 0, value.Length); + } + + public static bool IsIdnEncoded (string value) + { + if (value.StartsWith ("xn--", StringComparison.Ordinal)) + return true; + + return value.IndexOf (".xn--", StringComparison.Ordinal) != -1; + } + } +} diff --git a/src/EmailAddresses/MailKit/Utils/Rfc2047.cs b/src/EmailAddresses/MailKit/Utils/Rfc2047.cs new file mode 100644 index 0000000..cdbc2fc --- /dev/null +++ b/src/EmailAddresses/MailKit/Utils/Rfc2047.cs @@ -0,0 +1,536 @@ +// +// Rfc2047.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2025 .NET Foundation and Contributors +// +// 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. +// + +using System; +using System.Text; +using System.Buffers; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +using MimeKit.Encodings; + +namespace MimeKit.Utils { + /// + /// Utility methods for encoding and decoding rfc2047 encoded-word tokens. + /// + /// + /// Utility methods for encoding and decoding rfc2047 encoded-word tokens. + /// + internal static class Rfc2047 + { + readonly struct Token + { + const char SevenBit = '7'; + const char EightBit = '8'; + + public readonly int StartIndex; + public readonly int Length; + public readonly char Encoding; + +#if REDUCE_TOKEN_SIZE + // Note: .NET codepages range from ~34-65001 which all fit within a ushort, but we also need '-1' + // to denote "unknown" and 0 to denote an unencoded ascii word. + readonly ushort codePage; + + public int CodePage { get { return codePage == ushort.MaxValue ? -1 : codePage; } } +#else + public readonly int CodePage; +#endif + + public bool Is8bit { get { return CodePage == 0 && Encoding == EightBit; } } + + public bool IsEncoded { get { return CodePage != 0; } } + + public Token (string charset, string culture, char encoding, int startIndex, int length) + { +#if REDUCE_TOKEN_SIZE + int cp = CharsetUtils.GetCodePage (charset); + codePage = cp < 0 ? ushort.MaxValue : (ushort) cp; +#else + CodePage = CharsetUtils.GetCodePage (charset); +#endif + StartIndex = startIndex; + Length = length; + Encoding = encoding; + } + + public Token (int startIndex, int length, bool is8bit = false) + { + Encoding = is8bit ? EightBit : SevenBit; + StartIndex = startIndex; + Length = length; +#if REDUCE_TOKEN_SIZE + codePage = 0; +#else + CodePage = 0; +#endif + } + } + + struct CodePageCount + { + public readonly int CodePage; + public int Count; + + public CodePageCount (int codepage) + { + CodePage = codepage; + Count = 1; + } + } + + interface ITokenWriter : IDisposable + { + bool IgnoreWhitespaceBetweenEncodedWords { get; } + void Write (ref ValueStringBuilder output, ref Token token); + void Flush (ref ValueStringBuilder output); + } + + class TokenDecoder : ITokenWriter + { + readonly ParserOptions options; + readonly byte[] input, scratch; + CodePageCount[] codepages; + QuotedPrintableDecoder qp; + Base64Decoder base64; + IMimeDecoder decoder; + int codepageIndex; + int scratchLength; + char encoding; + int codepage; + + public TokenDecoder (ParserOptions options, byte[] input, byte[] scratch) + { + this.options = options; + this.scratch = scratch; + this.input = input; + + codepages = ArrayPool.Shared.Rent (16); + base64 = null; + qp = null; + + decoder = null; + codepageIndex = 0; + scratchLength = 0; + encoding = '\0'; + codepage = 0; + } + + public bool IgnoreWhitespaceBetweenEncodedWords { + get { return true; } + } + + void AddCodePage (int codepage) + { + for (int i = 0; i < codepageIndex; i++) { + if (codepages[i].CodePage == codepage) { + codepages[i].Count++; + return; + } + } + + if (codepageIndex == codepages.Length) { + var resized = ArrayPool.Shared.Rent (codepages.Length * 2); + codepages.AsSpan ().CopyTo (resized); + ArrayPool.Shared.Return (codepages); + codepages = resized; + } + + codepages[codepageIndex++] = new CodePageCount (codepage); + } + + public unsafe void Write (ref ValueStringBuilder output, ref Token token) + { + if (decoder != null && (token.CodePage != codepage || char.ToUpperInvariant (token.Encoding) != encoding)) { + // We've reached the end of a series of encoded-word tokens using identical charsets & encodings. + // + // In order to work around broken mailers, we need to combine the raw decoded content of runs of + // identically encoded-word tokens before converting to unicode strings. + Flush (ref output); + } + + if (token.IsEncoded) { + // Save encoded-word state so that we can treat consecutive encoded-word payloads with identical + // charsets & encodings as one continuous block, thus allowing us to handle cases where a + // hex-encoded triplet of a quoted-printable encoded payload is split between 2 or more + // encoded-word tokens. + encoding = char.ToUpperInvariant (token.Encoding); + codepage = token.CodePage; + if (encoding == 'Q') + decoder = qp ??= new QuotedPrintableDecoder (true); + else + decoder = base64 ??= new Base64Decoder (); + + AddCodePage (codepage); + + fixed (byte* inbuf = input, outbuf = scratch) { + int n = decoder.Decode (inbuf + token.StartIndex, token.Length, outbuf + scratchLength); + scratchLength += n; + } + } else if (token.Is8bit) { + // *sigh* I hate broken mailers... + var unicode = CharsetUtils.ConvertToUnicode (options, input, token.StartIndex, token.Length, out int length); + output.Append (unicode.AsSpan (0, length)); + } else { + // pure 7bit ascii, a breath of fresh air... + int endIndex = token.StartIndex + token.Length; + for (int i = token.StartIndex; i < endIndex; i++) + output.Append ((char) input[i]); + } + } + + public void Flush (ref ValueStringBuilder output) + { + // Reset any base64/quoted-printable decoder state + decoder?.Reset (); + decoder = null; + + if (scratchLength > 0) { + // Convert any decoded encoded-word payloads into unicode and append to our 'decoded' buffer. + var unicode = CharsetUtils.ConvertToUnicode (options, codepage, scratch, 0, scratchLength, out int length); + output.Append (unicode.AsSpan (0, length)); + scratchLength = 0; + } + } + + public int GetMostCommonCodePage () + { + int codepage = Encoding.UTF8.CodePage; + int max = 0; + + for (int i = 0; i < codepageIndex; i++) { + if (codepages[i].Count > max) { + codepage = codepages[i].CodePage; + max = codepages[i].Count; + } + } + + return codepage; + } + + public void Dispose () + { + ArrayPool.Shared.Return (codepages); + } + } + + [MethodImpl (MethodImplOptions.AggressiveInlining)] + static bool IsAscii (byte c) + { + return c < 128; + } + + [MethodImpl (MethodImplOptions.AggressiveInlining)] + static bool IsAsciiAtom (byte c) + { + return c.IsAsciiAtom (); + } + + [MethodImpl (MethodImplOptions.AggressiveInlining)] + static bool IsAtom (byte c) + { + return c.IsAtom (); + } + + [MethodImpl (MethodImplOptions.AggressiveInlining)] + static bool IsBbQq (byte c) + { + return c == 'B' || c == 'b' || c == 'Q' || c == 'q'; + } + + [MethodImpl (MethodImplOptions.AggressiveInlining)] + static bool IsLwsp (byte c) + { + return c.IsWhitespace (); + } + + static unsafe bool TryGetEncodedWordToken (byte* input, byte* word, int length, out Token token) + { + token = default; + + if (length < 7) + return false; + + byte* inend = word + length - 2; + byte* inptr = word; + + // check if this could even be an encoded-word token + if (*inptr++ != '=' || *inptr++ != '?' || *inend++ != '?' || *inend++ != '=') + return false; + + inend -= 2; + + if (*inptr == '?' || *inptr == '*') { + // this would result in an empty charset + return false; + } + + string charset, culture; + + using (var buffer = new ValueStringBuilder (32)) { + // find the end of the charset name + while (*inptr != '?' && *inptr != '*') { + if (!IsAsciiAtom (*inptr)) + return false; + + buffer.Append ((char) *inptr); + inptr++; + } + + charset = buffer.ToString (); + } + + if (*inptr == '*') { + // we found a language code... + inptr++; + + using (var buffer = new ValueStringBuilder (32)) { + // find the end of the language code + while (*inptr != '?') { + if (!IsAsciiAtom (*inptr)) + return false; + + buffer.Append ((char) *inptr); + inptr++; + } + + culture = buffer.ToString (); + } + } else { + culture = null; + } + + // skip over the '?' to get to the encoding + inptr++; + + char encoding; + if (*inptr == 'B' || *inptr == 'b' || *inptr == 'Q' || *inptr == 'q') { + encoding = (char) *inptr++; + } else { + return false; + } + + if (*inptr != '?' || inptr == inend) + return false; + + // skip over the '?' to get to the payload + inptr++; + + int start = (int) (inptr - input); + int len = (int) (inend - inptr); + + token = new Token (charset, culture, encoding, start, len); + + return true; + } + + static unsafe void TokenizePhrase (ParserOptions options, ITokenWriter writer, ref ValueStringBuilder decoded, byte* inbuf, int startIndex, int length) + { + byte* text, word, inptr = inbuf + startIndex; + byte* inend = inptr + length; + var lwsp = new Token (0, 0); + bool encoded = false; + Token token; + bool ascii; + int n; + + while (inptr < inend) { + text = inptr; + while (inptr < inend && IsLwsp (*inptr)) + inptr++; + + lwsp = new Token ((int) (text - inbuf), (int) (inptr - text)); + + word = inptr; + ascii = true; + if (inptr < inend && IsAsciiAtom (*inptr)) { + if (options.Rfc2047ComplianceMode == RfcComplianceMode.Loose) { + // Make an extra effort to detect and separate encoded-word + // tokens that have been merged with other words. + bool is_rfc2047 = false; + + if (inptr + 2 < inend && *inptr == '=' && *(inptr + 1) == '?') { + inptr += 2; + + // skip past the charset (if one is even declared, sigh) + while (inptr < inend && *inptr != '?') { + ascii = ascii && IsAscii (*inptr); + inptr++; + } + + // sanity check encoding type + if (inptr + 3 >= inend || *inptr != '?' || !IsBbQq (*(inptr + 1)) || *(inptr + 2) != '?') { + ascii = true; + goto non_rfc2047; + } + + inptr += 3; + + // find the end of the rfc2047 encoded word token + while (inptr + 2 < inend && !(*inptr == '?' && *(inptr + 1) == '=')) { + ascii = ascii && IsAscii (*inptr); + inptr++; + } + + if (inptr + 2 > inend || *inptr != '?' || *(inptr + 1) != '=') { + // didn't find an end marker... + inptr = word + 2; + ascii = true; + + goto non_rfc2047; + } + + is_rfc2047 = true; + inptr += 2; + } + + non_rfc2047: + if (!is_rfc2047) { + // stop if we encounter a possible rfc2047 encoded + // token even if it's inside another word, sigh. + while (inptr < inend && IsAtom (*inptr)) { + if (inptr + 2 < inend && *inptr == '=' && *(inptr + 1) == '?') + break; + ascii = ascii && IsAscii (*inptr); + inptr++; + } + } + } else { + // encoded-word tokens are atoms + while (inptr < inend && IsAsciiAtom (*inptr)) { + //ascii = ascii && IsAscii (*inptr); + inptr++; + } + } + + n = (int) (inptr - word); + if (TryGetEncodedWordToken (inbuf, word, n, out token)) { + // rfc2047 states that you must ignore all whitespace between + // encoded-word tokens + if ((!encoded || !writer.IgnoreWhitespaceBetweenEncodedWords) && lwsp.Length > 0) { + // previous token was not encoded, so preserve whitespace + writer.Write (ref decoded, ref lwsp); + } + + writer.Write (ref decoded, ref token); + encoded = true; + } else { + // append the lwsp and atom tokens + if (lwsp.Length > 0) + writer.Write (ref decoded, ref lwsp); + + token = new Token ((int) (word - inbuf), n, !ascii); + writer.Write (ref decoded, ref token); + + encoded = false; + } + } else { + // append the lwsp token + if (lwsp.Length > 0) + writer.Write (ref decoded, ref lwsp); + + // append the non-ascii atom token + ascii = true; + while (inptr < inend && !IsLwsp (*inptr) && !IsAsciiAtom (*inptr)) { + ascii = ascii && IsAscii (*inptr); + inptr++; + } + + token = new Token ((int) (word - inbuf), (int) (inptr - word), !ascii); + writer.Write (ref decoded, ref token); + + encoded = false; + } + } + + writer.Flush (ref decoded); + } + + internal static string DecodePhrase (ParserOptions options, byte[] phrase, int startIndex, int count, out int codepage) + { + codepage = Encoding.UTF8.CodePage; + + if (count == 0) + return string.Empty; + + unsafe { + fixed (byte* inbuf = phrase) { + var scratch = count < 2048 ? ArrayPool.Shared.Rent (count) : new byte[count]; + var decoder = new TokenDecoder (options, phrase, scratch); + var decoded = new ValueStringBuilder (count); + + try { + TokenizePhrase (options, decoder, ref decoded, inbuf, startIndex, count); + codepage = decoder.GetMostCommonCodePage (); + return decoded.ToString (); + } finally { + if (count < 2048) + ArrayPool.Shared.Return (scratch); + decoder.Dispose (); + } + } + } + } + + /// + /// Decode a phrase. + /// + /// + /// Decodes the phrase(s) starting at the given index and spanning across + /// the specified number of bytes using the supplied parser options. + /// + /// The decoded phrase. + /// The parser options to use. + /// The phrase to decode. + /// The starting index. + /// The number of bytes to decode. + /// + /// is . + /// -or- + /// is . + /// + /// + /// and do not specify + /// a valid range in the byte array. + /// + public static string DecodePhrase (ParserOptions options, byte[] phrase, int startIndex, int count) + { + if (options is null) + throw new ArgumentNullException (nameof (options)); + + if (phrase is null) + throw new ArgumentNullException (nameof (phrase)); + + if (startIndex < 0 || startIndex > phrase.Length) + throw new ArgumentOutOfRangeException (nameof (startIndex)); + + if (count < 0 || startIndex + count > phrase.Length) + throw new ArgumentOutOfRangeException (nameof (count)); + + return DecodePhrase (options, phrase, startIndex, count, out _); + } + } +} diff --git a/src/EmailAddresses/MailKit/Utils/ValueStringBuilder.cs b/src/EmailAddresses/MailKit/Utils/ValueStringBuilder.cs new file mode 100644 index 0000000..4516c25 --- /dev/null +++ b/src/EmailAddresses/MailKit/Utils/ValueStringBuilder.cs @@ -0,0 +1,318 @@ +#nullable enable + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Buffers; +using System.Diagnostics; +using System.Globalization; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace MimeKit.Utils { + internal ref partial struct ValueStringBuilder + { + private char[]? _arrayToReturnToPool; + private Span _chars; + private int _pos; + + public ValueStringBuilder (int initialCapacity) + { + _arrayToReturnToPool = ArrayPool.Shared.Rent (initialCapacity); + _chars = _arrayToReturnToPool; + _pos = 0; + } + + public int Length { + get => _pos; + } + +#if UNUSED_VALUESTRINGBUILDER_API + /// + /// Get a pinnable reference to the builder. + /// Does not ensure there is a null char after + /// This overload is pattern matched in the C# 7.3+ compiler so you can omit + /// the explicit method call, and write eg "fixed (char* c = builder)" + /// + public ref char GetPinnableReference () + { + return ref MemoryMarshal.GetReference (_chars); + } + + /// + /// Get a pinnable reference to the builder. + /// + /// Ensures that the builder has a null char after + public ref char GetPinnableReference (bool terminate) + { + if (terminate) { + EnsureCapacity (Length + 1); + _chars[Length] = '\0'; + } + return ref MemoryMarshal.GetReference (_chars); + } +#endif + + public ref char this[int index] { + get { + Debug.Assert (index < _pos); + return ref _chars[index]; + } + } + + public override string ToString () + { + string s = _chars.Slice (0, _pos).ToString (); + Dispose (); + return s; + } + +#if UNUSED_VALUESTRINGBUILDER_API + /// Returns the underlying storage of the builder. + public Span RawChars => _chars; +#endif + +#if UNUSED_VALUESTRINGBUILDER_API + /// + /// Returns a span around the contents of the builder. + /// + /// Ensures that the builder has a null char after + public ReadOnlySpan AsSpan (bool terminate) + { + if (terminate) { + EnsureCapacity (Length + 1); + _chars[Length] = '\0'; + } + return _chars.Slice (0, _pos); + } +#endif + + public ReadOnlySpan AsSpan () => _chars.Slice (0, _pos); +#if UNUSED_VALUESTRINGBUILDER_API + public ReadOnlySpan AsSpan (int start) => _chars.Slice (start, _pos - start); + public ReadOnlySpan AsSpan (int start, int length) => _chars.Slice (start, length); +#endif + +#if UNUSED_VALUESTRINGBUILDER_API + public bool TryCopyTo (Span destination, out int charsWritten) + { + if (_chars.Slice (0, _pos).TryCopyTo (destination)) { + charsWritten = _pos; + Dispose (); + return true; + } else { + charsWritten = 0; + Dispose (); + return false; + } + } +#endif + +#if UNUSED_VALUESTRINGBUILDER_API + public void Insert (int index, char value, int count) + { + if (_pos > _chars.Length - count) { + Grow (count); + } + + int remaining = _pos - index; + _chars.Slice (index, remaining).CopyTo (_chars.Slice (index + count)); + _chars.Slice (index, count).Fill (value); + _pos += count; + } +#endif + + public void Insert (int index, string? s) + { + if (s is null) { + return; + } + + int count = s.Length; + + if (_pos > (_chars.Length - count)) { + Grow (count); + } + + int remaining = _pos - index; + _chars.Slice (index, remaining).CopyTo (_chars.Slice (index + count)); + s +#if !NET6_0_OR_GREATER + .AsSpan () +#endif + .CopyTo (_chars.Slice (index)); + _pos += count; + } + + [MethodImpl (MethodImplOptions.AggressiveInlining)] + public void Append (char c) + { + int pos = _pos; + if ((uint) pos < (uint) _chars.Length) { + _chars[pos] = c; + _pos = pos + 1; + } else { + GrowAndAppend (c); + } + } + + [MethodImpl (MethodImplOptions.AggressiveInlining)] + public void Append (string? s) + { + if (s is null) { + return; + } + + int pos = _pos; + if (s.Length == 1 && (uint) pos < (uint) _chars.Length) // very common case, e.g. appending strings from NumberFormatInfo like separators, percent symbols, etc. + { + _chars[pos] = s[0]; + _pos = pos + 1; + } else { + AppendSlow (s); + } + } + + private void AppendSlow (string s) + { + int pos = _pos; + if (pos > _chars.Length - s.Length) { + Grow (s.Length); + } + + s +#if !NET6_0_OR_GREATER + .AsSpan () +#endif + .CopyTo (_chars.Slice (pos)); + _pos += s.Length; + } + +#if UNUSED_VALUESTRINGBUILDER_API + public void Append (char c, int count) + { + if (_pos > _chars.Length - count) { + Grow (count); + } + + Span dst = _chars.Slice (_pos, count); + for (int i = 0; i < dst.Length; i++) { + dst[i] = c; + } + _pos += count; + } +#endif + +#if UNUSED_VALUESTRINGBUILDER_API + public unsafe void Append (char* value, int length) + { + int pos = _pos; + if (pos > _chars.Length - length) { + Grow (length); + } + + Span dst = _chars.Slice (_pos, length); + for (int i = 0; i < dst.Length; i++) { + dst[i] = *value++; + } + _pos += length; + } +#endif + + public void Append (ReadOnlySpan value) + { + int pos = _pos; + if (pos > _chars.Length - value.Length) { + Grow (value.Length); + } + + value.CopyTo (_chars.Slice (_pos)); + _pos += value.Length; + } + +#if UNUSED_VALUESTRINGBUILDER_API + [MethodImpl (MethodImplOptions.AggressiveInlining)] + public Span AppendSpan (int length) + { + int origPos = _pos; + if (origPos > _chars.Length - length) { + Grow (length); + } + + _pos = origPos + length; + return _chars.Slice (origPos, length); + } +#endif + +#if NET6_0_OR_GREATER +#if UNUSED_VALUESTRINGBUILDER_API + internal void AppendSpanFormattable (T value, string? format = null, IFormatProvider? provider = null) where T : ISpanFormattable + { + if (value.TryFormat (_chars.Slice (_pos), out int charsWritten, format, provider)) { + _pos += charsWritten; + } else { + Append (value.ToString (format, provider)); + } + } +#endif +#else + internal void AppendInvariant (T value, string? format = null) where T: IFormattable + { + Append (value.ToString (format, CultureInfo.InvariantCulture)); + } + +#if UNUSED_VALUESTRINGBUILDER_API + internal void AppendSpanFormattable (T value, string? format = null, IFormatProvider? provider = null) where T: IFormattable + { + Append (value.ToString (format, provider)); + } +#endif +#endif + + + [MethodImpl (MethodImplOptions.NoInlining)] + private void GrowAndAppend (char c) + { + Grow (1); + Append (c); + } + + /// + /// Resize the internal buffer either by doubling current buffer size or + /// by adding to + /// whichever is greater. + /// + /// + /// Number of chars requested beyond current position. + /// + [MethodImpl (MethodImplOptions.NoInlining)] + private void Grow (int additionalCapacityBeyondPos) + { + Debug.Assert (additionalCapacityBeyondPos > 0); + Debug.Assert (_pos > _chars.Length - additionalCapacityBeyondPos, "Grow called incorrectly, no resize is needed."); + + // Make sure to let Rent throw an exception if the caller has a bug and the desired capacity is negative + char[] poolArray = ArrayPool.Shared.Rent ((int) Math.Max ((uint) (_pos + additionalCapacityBeyondPos), (uint) _chars.Length * 2)); + + _chars.Slice (0, _pos).CopyTo (poolArray); + + char[]? toReturn = _arrayToReturnToPool; + _chars = _arrayToReturnToPool = poolArray; + if (toReturn != null) { + ArrayPool.Shared.Return (toReturn); + } + } + + [MethodImpl (MethodImplOptions.AggressiveInlining)] + public void Dispose () + { + char[]? toReturn = _arrayToReturnToPool; + this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again + if (toReturn != null) { + ArrayPool.Shared.Return (toReturn); + } + } + } +} From e20d4e2012ff47544395800977c5092575c43e58 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 14 Nov 2025 14:39:47 +0100 Subject: [PATCH 35/73] Add MediaTypes library. --- PosInformatique.Foundations.slnx | 4 + README.md | 1 + src/MediaTypes/CHANGELOG.md | 2 + src/MediaTypes/Icon.png | Bin 0 -> 41957 bytes src/MediaTypes/MediaTypes.csproj | 28 ++ src/MediaTypes/MimeType.cs | 202 +++++++++++++ src/MediaTypes/MimeTypeExtensions.cs | 40 +++ src/MediaTypes/MimeTypes.cs | 118 ++++++++ src/MediaTypes/README.md | 140 +++++++++ .../MediaTypes.Tests/MediaTypes.Tests.csproj | 6 + .../MimeTypeExtensionsTest.cs | 61 ++++ tests/MediaTypes.Tests/MimeTypeTest.cs | 277 ++++++++++++++++++ tests/MediaTypes.Tests/MimeTypesTest.cs | 83 ++++++ 13 files changed, 962 insertions(+) create mode 100644 src/MediaTypes/CHANGELOG.md create mode 100644 src/MediaTypes/Icon.png create mode 100644 src/MediaTypes/MediaTypes.csproj create mode 100644 src/MediaTypes/MimeType.cs create mode 100644 src/MediaTypes/MimeTypeExtensions.cs create mode 100644 src/MediaTypes/MimeTypes.cs create mode 100644 src/MediaTypes/README.md create mode 100644 tests/MediaTypes.Tests/MediaTypes.Tests.csproj create mode 100644 tests/MediaTypes.Tests/MimeTypeExtensionsTest.cs create mode 100644 tests/MediaTypes.Tests/MimeTypeTest.cs create mode 100644 tests/MediaTypes.Tests/MimeTypesTest.cs diff --git a/PosInformatique.Foundations.slnx b/PosInformatique.Foundations.slnx index 413c0bd..1e19b41 100644 --- a/PosInformatique.Foundations.slnx +++ b/PosInformatique.Foundations.slnx @@ -37,6 +37,10 @@ + + + + diff --git a/README.md b/README.md index e57f27e..1587752 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ You can install any package using the .NET CLI or NuGet Package Manager. |PosInformatique.Foundations.EmailAddresses.EntityFramework icon|[**PosInformatique.Foundations.EmailAddresses.EntityFramework**](./src/EmailAddresses.EntityFramework/README.md) | Entity Framework Core integration for the EmailAddress value object, including property configuration and value converter for seamless database persistence. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework) | |PosInformatique.Foundations.EmailAddresses.FluentValidation icon|[**PosInformatique.Foundations.EmailAddresses.FluentValidation**](./src/EmailAddresses.FluentValidation/README.md) | FluentValidation integration for the EmailAddress value object, providing dedicated validators and rules to ensure RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.FluentValidation) | |PosInformatique.Foundations.EmailAddresses.Json icon|[**PosInformatique.Foundations.EmailAddresses.Json**](./src/EmailAddresses.Json/README.md) | `System.Text.Json` converter for the EmailAddress value object, enabling seamless serialization and deserialization of RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json) | +|PosInformatique.Foundations.MediaTypes icon|[**PosInformatique.Foundations.MediaTypes**](./src/MediaTypes/README.md) | Immutable `MimeType` value object with well-known media types and helpers to map between media types and file extensions. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes) | |PosInformatique.Foundations.People icon|[**PosInformatique.Foundations.People**](./src/People/README.md) | Strongly-typed value objects for first and last names with validation and normalization. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People)](https://www.nuget.org/packages/PosInformatique.Foundations.People) | |PosInformatique.Foundations.People.DataAnnotations icon|[**PosInformatique.Foundations.People.DataAnnotations**](./src/People.DataAnnotations/README.md) | DataAnnotations attributes for `FirstName` and `LastName` value objects. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.DataAnnotations)](https://www.nuget.org/packages/PosInformatique.Foundations.People.DataAnnotations) | |PosInformatique.Foundations.People.EntityFramework icon|[**PosInformatique.Foundations.People.EntityFramework**](./src/People.EntityFramework/README.md) | Entity Framework Core integration for `FirstName` and `LastName` value objects, providing fluent property configuration and value converters. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.People.EntityFramework) | diff --git a/src/MediaTypes/CHANGELOG.md b/src/MediaTypes/CHANGELOG.md new file mode 100644 index 0000000..1102a29 --- /dev/null +++ b/src/MediaTypes/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with strongly-typed MimeType value object. diff --git a/src/MediaTypes/Icon.png b/src/MediaTypes/Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..40be9e64be283e118a358cb0755890414769607d GIT binary patch literal 41957 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 + + + true + + + Provides a lightweight and immutable MimeType value object to represent media types (MIME types) in .NET. + Includes parsing, safe parsing, well-known application/* and image/* media types, and helpers to map between file extensions and media types. + + mimetype;media-type;content-type;file-extension;valueobject;ddd;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + true + + + + + + + + + + + + + diff --git a/src/MediaTypes/MimeType.cs b/src/MediaTypes/MimeType.cs new file mode 100644 index 0000000..3a5fd5f --- /dev/null +++ b/src/MediaTypes/MimeType.cs @@ -0,0 +1,202 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.MediaTypes +{ + using System.Diagnostics.CodeAnalysis; + + /// + /// Represents an immutable media type (formerly known as MIME type), + /// composed of a type and a subtype, such as application/json or image/png. + /// + public sealed class MimeType : IEquatable, IParsable + { + private MimeType(string type, string subtype) + { + this.Type = type; + this.Subtype = subtype; + } + + /// + /// Gets the main type of the media type, for example application or image. + /// + public string Type { get; } + + /// + /// Gets the subtype of the media type, for example json or png. + /// + public string Subtype { get; } + + /// + /// Determines whether two instances are equal. + /// + /// The first media type to compare. + /// The second media type to compare. + /// if the two instances are equal; otherwise, . + public static bool operator ==(MimeType? mimeType1, MimeType? mimeType2) + { + if (mimeType1 is null) + { + return mimeType2 is null; + } + + return mimeType1.Equals(mimeType2); + } + + /// + /// Determines whether two instances are not equal. + /// + /// The first media type to compare. + /// The second media type to compare. + /// if the two instances are not equal; otherwise, . + public static bool operator !=(MimeType? mimeType1, MimeType? mimeType2) + { + return !(mimeType1 == mimeType2); + } + + /// + /// Parses the specified string to create a new instance. + /// + /// The string that contains the media type, for example "application/json". + /// A new instance representing the specified media type. + /// Thrown when the argument is . + /// Thrown when the string is not a valid media type. + public static MimeType Parse(string s) + { + ArgumentNullException.ThrowIfNull(s); + + return Parse(s, null); + } + + /// + /// Parses the specified string to create a new instance using the given format provider. + /// + /// The string that contains the media type, for example "application/json". + /// An optional format provider. This parameter is not used. + /// A new instance representing the specified media type. + /// Thrown when the argument is . + /// Thrown when the string is not a valid media type. + public static MimeType Parse(string s, IFormatProvider? provider) + { + ArgumentNullException.ThrowIfNull(s); + + if (TryParse(s, out var result)) + { + return result; + } + + throw new FormatException("Invalid MIME type format."); + } + + /// + /// Tries to parse the specified string into a instance. + /// + /// The string that contains the media type, for example "application/json". + /// When this method returns, contains the parsed if the operation succeeded; otherwise, . + /// if the string was successfully parsed; otherwise, . + public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)][NotNullWhen(true)] out MimeType? result) + { + return TryParse(s, null, out result); + } + + /// + /// Tries to parse the specified string into a instance using the given format provider. + /// + /// The string that contains the media type, for example "application/json". + /// An optional format provider. This parameter is not used. + /// When this method returns, contains the parsed if the operation succeeded; otherwise, . + /// if the string was successfully parsed; otherwise, . + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)][NotNullWhen(true)] out MimeType? result) + { + result = null; + + if (string.IsNullOrWhiteSpace(s)) + { + return false; + } + + var parts = s.Split('/'); + if (parts.Length != 2) + { + return false; + } + + var type = parts[0]; + var subtype = parts[1]; + + if (string.IsNullOrWhiteSpace(type) || string.IsNullOrWhiteSpace(subtype)) + { + return false; + } + + result = new MimeType(type, subtype); + return true; + } + + /// + /// Gets the associated with the specified file extension. + /// + /// The file extension, with or without a leading dot (for example .json or json). + /// The associated with the specified extension. + /// Thrown when the argument is . + public static MimeType FromExtension(string extension) + { + ArgumentNullException.ThrowIfNull(extension); + + return MimeTypes.FromExtension(extension); + } + + /// + /// Determines whether the current is equal to another . + /// + /// The other media type to compare with. + /// if the media type are equal; otherwise, . + public bool Equals(MimeType? other) + { + if (other is null) + { + return false; + } + + return this.Type == other.Type && this.Subtype == other.Subtype; + } + + /// + public override bool Equals(object? obj) + { + if (obj is MimeType mimeType) + { + return this.Equals(mimeType); + } + + return false; + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(this.Type, this.Subtype); + } + + /// + /// Returns the string representation of this media type in the type/subtype format. + /// + /// A string that represents this media type. + public override string ToString() + { + return $"{this.Type}/{this.Subtype}"; + } + + /// + /// Gets the default file extension associated with this media type. + /// + /// The file extension associated with this media type. An empty string if no extension is associated to the media type. + public string GetExtension() + { + return MimeTypes.GetExtension(this); + } + } +} \ No newline at end of file diff --git a/src/MediaTypes/MimeTypeExtensions.cs b/src/MediaTypes/MimeTypeExtensions.cs new file mode 100644 index 0000000..ba43209 --- /dev/null +++ b/src/MediaTypes/MimeTypeExtensions.cs @@ -0,0 +1,40 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.MediaTypes +{ + /// + /// Provides extension methods for the class. + /// + public static class MimeTypeExtensions + { + /// + /// Determines whether the specified media type represents a PDF document. + /// + /// The media type to check. + /// if the media type is application/pdf; otherwise, . + /// Thrown when the argument is . + public static bool IsPdf(this MimeType mimeType) + { + ArgumentNullException.ThrowIfNull(mimeType); + + return mimeType == MimeTypes.Application.Pdf; + } + + /// + /// Determines whether the specified media type represents an image media type. + /// + /// The media type to check. + /// if the media type is in the image/* family; otherwise, . + /// Thrown when the argument is . + public static bool IsImage(this MimeType mimeType) + { + ArgumentNullException.ThrowIfNull(mimeType); + + return mimeType.Type == "image"; + } + } +} \ No newline at end of file diff --git a/src/MediaTypes/MimeTypes.cs b/src/MediaTypes/MimeTypes.cs new file mode 100644 index 0000000..7a951ff --- /dev/null +++ b/src/MediaTypes/MimeTypes.cs @@ -0,0 +1,118 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.MediaTypes +{ + /// + /// Provides predefined media types and helper methods for resolving + /// media types from file extensions and vice versa. + /// + public static class MimeTypes + { + private static readonly Dictionary FromExtensions = new Dictionary() + { + { "pdf", Application.Pdf }, + { "docx", Application.Docx }, + { "bmp", Image.Bmp }, + { "jpg", Image.Jpeg }, + { "jpeg", Image.Jpeg }, + { "png", Image.Png }, + { "tif", Image.Tiff }, + { "tiff", Image.Tiff }, + { "webp", Image.WebP }, + }; + + private static readonly Dictionary ToExtensions = new Dictionary() + { + { Application.Pdf, "pdf" }, + { Application.Docx, "docx" }, + { Image.Bmp, "bmp" }, + { Image.Jpeg, "jpg" }, + { Image.Png, "png" }, + { Image.Tiff, "tiff" }, + { Image.WebP, "webp" }, + }; + + internal static MimeType FromExtension(string extension) + { + if (extension.StartsWith(".", StringComparison.InvariantCultureIgnoreCase)) + { + extension = extension.Substring(1); + } + + extension = extension.ToLowerInvariant(); + + if (FromExtensions.TryGetValue(extension, out var mimeType)) + { + return mimeType; + } + + return Application.OctetStream; + } + + internal static string GetExtension(MimeType mimeType) + { + if (ToExtensions.TryGetValue(mimeType, out var extensionFound)) + { + return "." + extensionFound; + } + + return string.Empty; + } + + /// + /// Common application/* media types. + /// + public static class Application + { + /// + /// Gets the media type application/octet-stream. + /// + public static MimeType OctetStream { get; } = MimeType.Parse("application/octet-stream", null); + + /// + /// Gets the media type application/pdf. + /// + public static MimeType Pdf { get; } = MimeType.Parse("application/pdf", null); + + /// + /// Gets the media type application/vnd.openxmlformats-officedocument.wordprocessingml.document. + /// + public static MimeType Docx { get; } = MimeType.Parse("application/vnd.openxmlformats-officedocument.wordprocessingml.document", null); + } + + /// + /// Common image/* media types. + /// + public static class Image + { + /// + /// Gets the media type image/bmp. + /// + public static MimeType Bmp { get; } = MimeType.Parse("image/bmp", null); + + /// + /// Gets the media type image/jpeg. + /// + public static MimeType Jpeg { get; } = MimeType.Parse("image/jpeg", null); + + /// + /// Gets the media type image/png. + /// + public static MimeType Png { get; } = MimeType.Parse("image/png", null); + + /// + /// Gets the media type image/tiff. + /// + public static MimeType Tiff { get; } = MimeType.Parse("image/tiff", null); + + /// + /// Gets the media type image/webp. + /// + public static MimeType WebP { get; } = MimeType.Parse("image/webp", null); + } + } +} \ No newline at end of file diff --git a/src/MediaTypes/README.md b/src/MediaTypes/README.md new file mode 100644 index 0000000..ebbb119 --- /dev/null +++ b/src/MediaTypes/README.md @@ -0,0 +1,140 @@ +# PosInformatique.Foundations.MediaTypes + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.MediaTypes)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes/) + +## Introduction + +[PosInformatique.Foundations.MediaTypes](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes) provides +a lightweight way to represent media types (MIME types) in .NET. +It offers an immutable `MimeType` value object, a set of well-known media types, helpers for mapping between +file extensions and media types, and a few convenience extension methods. + +## Install + +You can install the package from NuGet: + +```powershell +dotnet add package PosInformatique.Foundations.MediaTypes +``` + +## Features + +- Immutable `MimeType` value object (`type/subtype`, e.g. `application/json`, `image/png`). +- Parsing and safe parsing from `string` (`Parse` / `TryParse`). +- Resolve a `MimeType` from a file extension (with or without leading dot). +- Resolve a default file extension from a `MimeType`. +- Set of common `application/*` and `image/*` media types. +- Simple extension methods, e.g. `IsPdf()` and `IsImage()`. + +## Usage + +### Parsing media types + +```csharp +using PosInformatique.Foundations.MediaTypes; + +var json = MimeType.Parse("application/json"); +Console.WriteLine(json.Type); // "application" +Console.WriteLine(json.Subtype); // "json" + +if (MimeType.TryParse("image/png", out var png)) +{ + Console.WriteLine(png); // "image/png" +} +``` + +### Well-known media types + +```csharp +using PosInformatique.Foundations.MediaTypes; + +var pdf = MimeTypes.Application.Pdf; // application/pdf +var docx = MimeTypes.Application.Docx; // application/vnd.openxmlformats-officedocument.wordprocessingml.document +var jpeg = MimeTypes.Image.Jpeg; // image/jpeg +``` + +### From file extension to media type + +```csharp +using PosInformatique.Foundations.MediaTypes; + +var pdfFromExt = MimeType.FromExtension(".pdf"); // application/pdf +var pngFromExt = MimeType.FromExtension("png"); // image/png + +// Unknown extensions fall back to application/octet-stream +var unknown = MimeType.FromExtension(".unknown"); // application/octet-stream +``` + +### From media type to default file extension + +```csharp +using PosInformatique.Foundations.MediaTypes; + +var pdf = MimeTypes.Application.Pdf; +var pdfExtension = pdf.GetExtension(); // ".pdf" + +var webp = MimeTypes.Image.WebP; +var webpExtension = webp.GetExtension(); // ".webp" +``` + +### Extension methods + +```csharp +using PosInformatique.Foundations.MediaTypes; + +var mimeType = MimeTypes.Application.Pdf; + +if (mimeType.IsPdf()) +{ + Console.WriteLine("This is a PDF document."); +} + +var image = MimeTypes.Image.Png; +if (image.IsImage()) +{ + Console.WriteLine("This is an image type."); +} +``` + +## API overview + +### MimeType + +- Immutable value object representing `type/subtype`. +- Implements `IEquatable` and `IParsable`. +- Main members: + - `string Type { get; }` + - `string Subtype { get; }` + - `static MimeType Parse(string s)` + - `static MimeType Parse(string s, IFormatProvider? provider)` + - `static bool TryParse(string? s, out MimeType? result)` + - `static bool TryParse(string? s, IFormatProvider? provider, out MimeType? result)` + - `static MimeType FromExtension(string extension)` + - `string GetExtension()` + +### MimeTypes + +Provides common media types and mapping helpers. + +- `MimeTypes.Application` + - `MimeType OctetStream` (`application/octet-stream`) + - `MimeType Pdf` (`application/pdf`) + - `MimeType Docx` (`application/vnd.openxmlformats-officedocument.wordprocessingml.document`) + +- `MimeTypes.Image` + - `MimeType Bmp` (`image/bmp`) + - `MimeType Jpeg` (`image/jpeg`) + - `MimeType Png` (`image/png`) + - `MimeType Tiff` (`image/tiff`) + - `MimeType WebP` (`image/webp`) + +### MimeTypeExtensions + +- `bool IsPdf(this MimeType mimeType)` +- `bool IsImage(this MimeType mimeType)` + +## Links + +- [NuGet package](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/tests/MediaTypes.Tests/MediaTypes.Tests.csproj b/tests/MediaTypes.Tests/MediaTypes.Tests.csproj new file mode 100644 index 0000000..5619164 --- /dev/null +++ b/tests/MediaTypes.Tests/MediaTypes.Tests.csproj @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/MediaTypes.Tests/MimeTypeExtensionsTest.cs b/tests/MediaTypes.Tests/MimeTypeExtensionsTest.cs new file mode 100644 index 0000000..30e7a21 --- /dev/null +++ b/tests/MediaTypes.Tests/MimeTypeExtensionsTest.cs @@ -0,0 +1,61 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.MediaTypes.Tests +{ + public class MimeTypeExtensionsTest + { + [Fact] + public void IsPdf() + { + MimeTypes.Application.Docx.IsPdf().Should().BeFalse(); + MimeTypes.Application.Pdf.IsPdf().Should().BeTrue(); + MimeTypes.Application.OctetStream.IsPdf().Should().BeFalse(); + MimeTypes.Image.Bmp.IsPdf().Should().BeFalse(); + MimeTypes.Image.Jpeg.IsPdf().Should().BeFalse(); + MimeTypes.Image.Png.IsPdf().Should().BeFalse(); + MimeTypes.Image.Tiff.IsPdf().Should().BeFalse(); + MimeTypes.Image.WebP.IsPdf().Should().BeFalse(); + } + + [Fact] + public void IsPdf_WithNullArgument() + { + var act = () => + { + MimeTypeExtensions.IsPdf(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("mimeType"); + } + + [Fact] + public void IsImage() + { + MimeTypes.Application.Docx.IsImage().Should().BeFalse(); + MimeTypes.Application.Pdf.IsImage().Should().BeFalse(); + MimeTypes.Application.OctetStream.IsPdf().Should().BeFalse(); + MimeTypes.Image.Bmp.IsImage().Should().BeTrue(); + MimeTypes.Image.Jpeg.IsImage().Should().BeTrue(); + MimeTypes.Image.Png.IsImage().Should().BeTrue(); + MimeTypes.Image.Tiff.IsImage().Should().BeTrue(); + MimeTypes.Image.WebP.IsImage().Should().BeTrue(); + } + + [Fact] + public void IsImage_WithNullArgument() + { + var act = () => + { + MimeTypeExtensions.IsImage(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("mimeType"); + } + } +} \ No newline at end of file diff --git a/tests/MediaTypes.Tests/MimeTypeTest.cs b/tests/MediaTypes.Tests/MimeTypeTest.cs new file mode 100644 index 0000000..4b25b6d --- /dev/null +++ b/tests/MediaTypes.Tests/MimeTypeTest.cs @@ -0,0 +1,277 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.MediaTypes.Tests +{ + public class MimeTypeTest + { + [Theory] + [InlineData("text/plain", "text", "plain")] + [InlineData("image/jpeg", "image", "jpeg")] + public void Parse_Success(string input, string expectedType, string expectedSubtype) + { + var mimeType = MimeType.Parse(input); + + mimeType.Type.Should().Be(expectedType); + mimeType.Subtype.Should().Be(expectedSubtype); + } + + [Fact] + public void Parse_WithNullArgument() + { + var act = () => + { + MimeType.Parse(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("s"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("part1")] + [InlineData("part1/part2/part3")] + [InlineData("part1/")] + [InlineData("/part2")] + public void Parse_Failed(string input) + { + var act = () => + { + MimeType.Parse(input); + }; + + act.Should().ThrowExactly() + .WithMessage("Invalid MIME type format."); + } + + [Theory] + [InlineData("text/plain", "text", "plain")] + [InlineData("image/jpeg", "image", "jpeg")] + public void Parse_WithFormatProvider_Success(string input, string expectedType, string expectedSubtype) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var mimeType = MimeType.Parse(input, formatProvider); + + mimeType.Type.Should().Be(expectedType); + mimeType.Subtype.Should().Be(expectedSubtype); + } + + [Fact] + public void Parse_WithFormatProvider_WithNullArgument() + { + var act = () => + { + MimeType.Parse(null, default); + }; + + act.Should().ThrowExactly() + .WithParameterName("s"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("part1")] + [InlineData("part1/part2/part3")] + [InlineData("part1/")] + [InlineData("/part2")] + public void Parse_WithFormatProvider_Failed(string input) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var act = () => + { + MimeType.Parse(input, formatProvider); + }; + + act.Should().ThrowExactly() + .WithMessage("Invalid MIME type format."); + } + + [Theory] + [InlineData("text/plain", "text", "plain")] + [InlineData("image/jpeg", "image", "jpeg")] + public void TryParse_Success(string input, string expectedType, string expectedSubtype) + { + MimeType.TryParse(input, out var mimeType).Should().BeTrue(); + + mimeType.Type.Should().Be(expectedType); + mimeType.Subtype.Should().Be(expectedSubtype); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("part1")] + [InlineData("part1/part2/part3")] + [InlineData("part1/")] + [InlineData("/part2")] + public void TryParse_Failed(string input) + { + MimeType.TryParse(input, out var mimeType).Should().BeFalse(); + + mimeType.Should().BeNull(); + } + + [Theory] + [InlineData("text/plain", "text", "plain")] + [InlineData("image/jpeg", "image", "jpeg")] + public void TryParse_WithFormatProvider_Success(string input, string expectedType, string expectedSubtype) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + MimeType.TryParse(input, formatProvider, out var mimeType).Should().BeTrue(); + + mimeType.Type.Should().Be(expectedType); + mimeType.Subtype.Should().Be(expectedSubtype); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("part1")] + [InlineData("part1/part2/part3")] + [InlineData("part1/")] + [InlineData("/part2")] + public void TryParse_WithFormatProvider_Failed(string input) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + MimeType.TryParse(input, formatProvider, out var mimeType).Should().BeFalse(); + + mimeType.Should().BeNull(); + } + + [Theory] + [InlineData("text/plain", "text/plain", true)] + [InlineData("text/plain", "image/jpeg", false)] + [InlineData("text/plain", null, false)] + public void Equals_WithObject(string mimeType1String, string mimeType2String, bool expectedResult) + { + var mimeType1 = MimeType.Parse(mimeType1String); + var mimeType2 = mimeType2String != null ? MimeType.Parse(mimeType2String) : null; + + mimeType1.Equals((object)mimeType2).Should().Be(expectedResult); + } + + [Theory] + [InlineData("text/plain", "text/plain", true)] + [InlineData("text/plain", "image/jpeg", false)] + [InlineData("text/plain", null, false)] + public void Equals_WithMimeType(string mimeType1String, string mimeType2String, bool expectedResult) + { + var mimeType1 = MimeType.Parse(mimeType1String); + var mimeType2 = mimeType2String != null ? MimeType.Parse(mimeType2String) : null; + + mimeType1.Equals(mimeType2).Should().Be(expectedResult); + } + + [Theory] + [InlineData("text/plain", "text/plain", true)] + [InlineData("text/plain", "image/jpeg", false)] + [InlineData("text/plain", null, false)] + [InlineData(null, null, true)] + public void OperatorEqual(string mimeType1String, string mimeType2String, bool expectedResult) + { + var mimeType1 = mimeType1String != null ? MimeType.Parse(mimeType1String) : null; + var mimeType2 = mimeType2String != null ? MimeType.Parse(mimeType2String) : null; + + (mimeType1 == mimeType2).Should().Be(expectedResult); + } + + [Theory] + [InlineData("text/plain", "text/plain", false)] + [InlineData("text/plain", "image/jpeg", true)] + [InlineData("text/plain", null, true)] + [InlineData(null, null, false)] + public void OperatorDifferent(string mimeType1String, string mimeType2String, bool expectedResult) + { + var mimeType1 = mimeType1String != null ? MimeType.Parse(mimeType1String) : null; + var mimeType2 = mimeType2String != null ? MimeType.Parse(mimeType2String) : null; + + (mimeType1 != mimeType2).Should().Be(expectedResult); + } + + [Theory] + [InlineData("text/plain", "text/plain", true)] + [InlineData("text/plain", "image/jpeg", false)] + public void GetHashCode_Test(string mimeType1String, string mimeType2String, bool expectedEqual) + { + var mimeType1 = MimeType.Parse(mimeType1String); + var mimeType2 = mimeType2String != null ? MimeType.Parse(mimeType2String) : null; + + (mimeType1.GetHashCode() == mimeType2.GetHashCode()).Should().Be(expectedEqual); + } + + [Theory] + [InlineData("pdf", "Application.Pdf")] + [InlineData("docx", "Application.Docx")] + [InlineData("bmp", "Image.Bmp")] + [InlineData("jpg", "Image.Jpeg")] + [InlineData("jpeg", "Image.Jpeg")] + [InlineData("png", "Image.Png")] + [InlineData("tif", "Image.Tiff")] + [InlineData("tiff", "Image.Tiff")] + [InlineData("webp", "Image.WebP")] + [InlineData("unknown", "Application.OctetStream")] + public void FromExtension(string extension, string path) + { + MimeType.FromExtension(extension).Should().BeSameAs(GetMimeTypeFromPath(path)); + MimeType.FromExtension("." + extension).Should().BeSameAs(GetMimeTypeFromPath(path)); + MimeType.FromExtension(extension.ToUpperInvariant()).Should().BeSameAs(GetMimeTypeFromPath(path)); + MimeType.FromExtension("." + extension.ToUpperInvariant()).Should().BeSameAs(GetMimeTypeFromPath(path)); + } + + [Fact] + public void FromExtension_WithNullArgument() + { + var act = () => + { + MimeType.FromExtension(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("extension"); + } + + [Theory] + [InlineData("application/pdf", ".pdf")] + [InlineData("application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx")] + [InlineData("image/bmp", ".bmp")] + [InlineData("image/jpeg", ".jpg")] + [InlineData("image/png", ".png")] + [InlineData("image/tiff", ".tiff")] + [InlineData("image/webp", ".webp")] + [InlineData("other/type", "")] + public void GetExtension(string mimeType, string expectedExtensions) + { + MimeType.Parse(mimeType).GetExtension().Should().Be(expectedExtensions); + } + + [Fact] + public void ToString_Test() + { + var mimeType = MimeType.Parse("text/plain"); + + mimeType.ToString().Should().Be("text/plain"); + } + + private static MimeType GetMimeTypeFromPath(string path) + { + var properties = path.Split("."); + + var type = typeof(MimeTypes).GetNestedType(properties[0]); + var propertySubType = type.GetProperty(properties[1]); + + return (MimeType)propertySubType.GetValue(null); + } + } +} \ No newline at end of file diff --git a/tests/MediaTypes.Tests/MimeTypesTest.cs b/tests/MediaTypes.Tests/MimeTypesTest.cs new file mode 100644 index 0000000..615813b --- /dev/null +++ b/tests/MediaTypes.Tests/MimeTypesTest.cs @@ -0,0 +1,83 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.MediaTypes.Tests +{ + public class MimeTypesTest + { + [Fact] + public void Application_Docx() + { + MimeTypes.Application.Docx.Should().BeSameAs(MimeTypes.Application.Docx); + + MimeTypes.Application.Docx.Type.Should().Be("application"); + MimeTypes.Application.Docx.Subtype.Should().Be("vnd.openxmlformats-officedocument.wordprocessingml.document"); + } + + [Fact] + public void Application_OctetStream() + { + MimeTypes.Application.OctetStream.Should().BeSameAs(MimeTypes.Application.OctetStream); + + MimeTypes.Application.OctetStream.Type.Should().Be("application"); + MimeTypes.Application.OctetStream.Subtype.Should().Be("octet-stream"); + } + + [Fact] + public void Application_Pdf() + { + MimeTypes.Application.Pdf.Should().BeSameAs(MimeTypes.Application.Pdf); + + MimeTypes.Application.Pdf.Type.Should().Be("application"); + MimeTypes.Application.Pdf.Subtype.Should().Be("pdf"); + } + + [Fact] + public void Image_Bmp() + { + MimeTypes.Image.Bmp.Should().BeSameAs(MimeTypes.Image.Bmp); + + MimeTypes.Image.Bmp.Type.Should().Be("image"); + MimeTypes.Image.Bmp.Subtype.Should().Be("bmp"); + } + + [Fact] + public void Image_Jpeg() + { + MimeTypes.Image.Jpeg.Should().BeSameAs(MimeTypes.Image.Jpeg); + + MimeTypes.Image.Jpeg.Type.Should().Be("image"); + MimeTypes.Image.Jpeg.Subtype.Should().Be("jpeg"); + } + + [Fact] + public void Image_Png() + { + MimeTypes.Image.Png.Should().BeSameAs(MimeTypes.Image.Png); + + MimeTypes.Image.Png.Type.Should().Be("image"); + MimeTypes.Image.Png.Subtype.Should().Be("png"); + } + + [Fact] + public void Image_Tiff() + { + MimeTypes.Image.Tiff.Should().BeSameAs(MimeTypes.Image.Tiff); + + MimeTypes.Image.Tiff.Type.Should().Be("image"); + MimeTypes.Image.Tiff.Subtype.Should().Be("tiff"); + } + + [Fact] + public void Image_Webp() + { + MimeTypes.Image.WebP.Should().BeSameAs(MimeTypes.Image.WebP); + + MimeTypes.Image.WebP.Type.Should().Be("image"); + MimeTypes.Image.WebP.Subtype.Should().Be("webp"); + } + } +} \ No newline at end of file From ab6e9f36d530956fcfc4b6db30fc2b375f1fbf69 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 14 Nov 2025 15:03:54 +0100 Subject: [PATCH 36/73] Add the MediaTypes.Json library. --- PosInformatique.Foundations.slnx | 4 + README.md | 1 + .../EmailAddresses.Json.csproj | 6 +- src/EmailAddresses.Json/README.md | 7 +- src/MediaTypes.Json/CHANGELOG.md | 2 + src/MediaTypes.Json/MediaTypes.Json.csproj | 32 +++++ ...diaTypesJsonSerializerOptionsExtensions.cs | 35 +++++ src/MediaTypes.Json/MimeTypeJsonConverter.cs | 45 +++++++ src/MediaTypes.Json/README.md | 126 ++++++++++++++++++ .../MediaTypes.Json.Tests.csproj | 11 ++ ...ypesJsonSerializerOptionsExtensionsTest.cs | 42 ++++++ .../MimeTypeJsonConverterTest.cs | 118 ++++++++++++++++ 12 files changed, 423 insertions(+), 6 deletions(-) create mode 100644 src/MediaTypes.Json/CHANGELOG.md create mode 100644 src/MediaTypes.Json/MediaTypes.Json.csproj create mode 100644 src/MediaTypes.Json/MediaTypesJsonSerializerOptionsExtensions.cs create mode 100644 src/MediaTypes.Json/MimeTypeJsonConverter.cs create mode 100644 src/MediaTypes.Json/README.md create mode 100644 tests/MediaTypes.Json.Tests/MediaTypes.Json.Tests.csproj create mode 100644 tests/MediaTypes.Json.Tests/MediaTypesJsonSerializerOptionsExtensionsTest.cs create mode 100644 tests/MediaTypes.Json.Tests/MimeTypeJsonConverterTest.cs diff --git a/PosInformatique.Foundations.slnx b/PosInformatique.Foundations.slnx index 1e19b41..78c5ae6 100644 --- a/PosInformatique.Foundations.slnx +++ b/PosInformatique.Foundations.slnx @@ -41,6 +41,10 @@ + + + + diff --git a/README.md b/README.md index 1587752..fc56bd2 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ You can install any package using the .NET CLI or NuGet Package Manager. |PosInformatique.Foundations.EmailAddresses.FluentValidation icon|[**PosInformatique.Foundations.EmailAddresses.FluentValidation**](./src/EmailAddresses.FluentValidation/README.md) | FluentValidation integration for the EmailAddress value object, providing dedicated validators and rules to ensure RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.FluentValidation) | |PosInformatique.Foundations.EmailAddresses.Json icon|[**PosInformatique.Foundations.EmailAddresses.Json**](./src/EmailAddresses.Json/README.md) | `System.Text.Json` converter for the EmailAddress value object, enabling seamless serialization and deserialization of RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json) | |PosInformatique.Foundations.MediaTypes icon|[**PosInformatique.Foundations.MediaTypes**](./src/MediaTypes/README.md) | Immutable `MimeType` value object with well-known media types and helpers to map between media types and file extensions. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes) | +|PosInformatique.Foundations.MediaTypes.Json icon|[**PosInformatique.Foundations.MediaTypes.Json**](./src/MediaTypes.Json/README.md) | `System.Text.Json` converter for the MimeType value object, enabling seamless serialization and deserialization of MIME types within JSON documents. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.Json) | |PosInformatique.Foundations.People icon|[**PosInformatique.Foundations.People**](./src/People/README.md) | Strongly-typed value objects for first and last names with validation and normalization. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People)](https://www.nuget.org/packages/PosInformatique.Foundations.People) | |PosInformatique.Foundations.People.DataAnnotations icon|[**PosInformatique.Foundations.People.DataAnnotations**](./src/People.DataAnnotations/README.md) | DataAnnotations attributes for `FirstName` and `LastName` value objects. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.DataAnnotations)](https://www.nuget.org/packages/PosInformatique.Foundations.People.DataAnnotations) | |PosInformatique.Foundations.People.EntityFramework icon|[**PosInformatique.Foundations.People.EntityFramework**](./src/People.EntityFramework/README.md) | Entity Framework Core integration for `FirstName` and `LastName` value objects, providing fluent property configuration and value converters. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.People.EntityFramework) | diff --git a/src/EmailAddresses.Json/EmailAddresses.Json.csproj b/src/EmailAddresses.Json/EmailAddresses.Json.csproj index 01112f0..d7aa7d2 100644 --- a/src/EmailAddresses.Json/EmailAddresses.Json.csproj +++ b/src/EmailAddresses.Json/EmailAddresses.Json.csproj @@ -4,10 +4,10 @@ true - Provides a System.Text.Json converter for the EmailAddress value object. - Enables seamless serialization and deserialization of RFC 5322 compliant email addresses within JSON documents. + Provides a System.Text.Json converter for the MimeType value object. + Enables seamless serialization and deserialization of MIME types within JSON documents. - email;emailaddress;valueobject;ddd;json;rfc5322;parsing;validation;dotnet;posinformatique + mimetype;mediatype;contenttype;valueobject;ddd;json;parsing;validation;dotnet;posinformatique $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) diff --git a/src/EmailAddresses.Json/README.md b/src/EmailAddresses.Json/README.md index 53e6031..b1fc06c 100644 --- a/src/EmailAddresses.Json/README.md +++ b/src/EmailAddresses.Json/README.md @@ -1,4 +1,4 @@ -# PosInformatique.Foundations.EmailAddresses.Json +# PosInformatique.Foundations.EmailAddresses.Json [![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json/) [![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.EmailAddresses.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json/) @@ -33,7 +33,8 @@ This package depends on the base package [PosInformatique.Foundations.EmailAddre ```csharp using System.Text.Json; using System.Text.Json.Serialization; -using PosInformatique.Foundations; +using PosInformatique.Foundations.EmailAddresses; +using PosInformatique.Foundations.EmailAddresses.Json; public class UserDto { @@ -55,7 +56,7 @@ Console.WriteLine(deserialized!.Email); // "alice@company.org" ### Example 2: Use extension method without attributes ```csharp using System.Text.Json; -using PosInformatique.Foundations; +using PosInformatique.Foundations.EmailAddresses; public class CustomerDto { diff --git a/src/MediaTypes.Json/CHANGELOG.md b/src/MediaTypes.Json/CHANGELOG.md new file mode 100644 index 0000000..72721b0 --- /dev/null +++ b/src/MediaTypes.Json/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with the support JSON serialization (with System.Text.Json) for MimeType value object. \ No newline at end of file diff --git a/src/MediaTypes.Json/MediaTypes.Json.csproj b/src/MediaTypes.Json/MediaTypes.Json.csproj new file mode 100644 index 0000000..89c85e9 --- /dev/null +++ b/src/MediaTypes.Json/MediaTypes.Json.csproj @@ -0,0 +1,32 @@ + + + + true + + + Provides a lightweight and immutable MimeType value object to represent media types (MIME types) in .NET. + Includes parsing, safe parsing, well-known application/* and image/* media types, and helpers to map between file extensions and media types. + + mimetype;media-type;content-type;file-extension;valueobject;ddd;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + true + + + + + + + + + + + + + + + + + diff --git a/src/MediaTypes.Json/MediaTypesJsonSerializerOptionsExtensions.cs b/src/MediaTypes.Json/MediaTypesJsonSerializerOptionsExtensions.cs new file mode 100644 index 0000000..92bdf79 --- /dev/null +++ b/src/MediaTypes.Json/MediaTypesJsonSerializerOptionsExtensions.cs @@ -0,0 +1,35 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace System.Text.Json +{ + using PosInformatique.Foundations.MediaTypes.Json; + + /// + /// Contains extension methods to configure . + /// + public static class MediaTypesJsonSerializerOptionsExtensions + { + /// + /// Registers the to the . + /// + /// which the + /// converter will be added in the collection. + /// The instance to continue the configuration. + /// If the specified argument is . + public static JsonSerializerOptions AddMediaTypesConverters(this JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (!options.Converters.Any(c => c is MimeTypeJsonConverter)) + { + options.Converters.Add(new MimeTypeJsonConverter()); + } + + return options; + } + } +} \ No newline at end of file diff --git a/src/MediaTypes.Json/MimeTypeJsonConverter.cs b/src/MediaTypes.Json/MimeTypeJsonConverter.cs new file mode 100644 index 0000000..92ddec1 --- /dev/null +++ b/src/MediaTypes.Json/MimeTypeJsonConverter.cs @@ -0,0 +1,45 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.MediaTypes.Json +{ + using System.Text.Json; + using System.Text.Json.Serialization; + + /// + /// which allows to serialize and deserialize an + /// as a JSON string. + /// + public sealed class MimeTypeJsonConverter : JsonConverter + { + /// + public override bool HandleNull => true; + + /// + public override MimeType? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var input = reader.GetString(); + + if (input is null) + { + return null; + } + + if (!MimeType.TryParse(input, out var mimeType)) + { + throw new JsonException($"'{input}' is not a valid MIME type."); + } + + return mimeType; + } + + /// + public override void Write(Utf8JsonWriter writer, MimeType value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } + } +} \ No newline at end of file diff --git a/src/MediaTypes.Json/README.md b/src/MediaTypes.Json/README.md new file mode 100644 index 0000000..ceaec75 --- /dev/null +++ b/src/MediaTypes.Json/README.md @@ -0,0 +1,126 @@ +# PosInformatique.Foundations.MediaTypes.Json + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.Json/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.MediaTypes.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.Json/) + +## Introduction +Provides a **System.Text.Json** converter for the `MimeType` value object from +[PosInformatique.Foundations.MediaTypes](../MediaTypes/README.md). +Enables seamless serialization and deserialization of MIME types (e.g. `application/json`, `image/png`) within JSON documents. + +## Install +You can install the package from NuGet: + +```powershell +dotnet add package PosInformatique.Foundations.MediaTypes.Json +``` + +This package depends on the base package [PosInformatique.Foundations.MediaTypes](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes/). + +## Features +- Provides a `JsonConverter` for serialization and deserialization. +- Validates MIME type strings when deserializing (throws `JsonException` on invalid value). +- Handles `null` values correctly when reading JSON. +- Can be used via attributes (`[JsonConverter]`) or through a `JsonSerializerOptions` extension method. +- Ensures consistency with the base `MimeType` value object. + +## Use cases +- **Serialization**: Convert `MimeType` value objects into JSON strings. +- **Validation**: Ensure only valid MIME type strings are accepted in JSON payloads. +- **Integration**: Plug directly into `System.Text.Json` configuration. + +## Examples + +### Example 1: DTO with `[JsonConverter]` attribute + +```csharp +using System.Text.Json; +using System.Text.Json.Serialization; +using PosInformatique.Foundations.MediaTypes; +using PosInformatique.Foundations.MediaTypes.Json; + +public class MediaResourceDto +{ + [JsonConverter(typeof(MimeTypeJsonConverter))] + public MimeType? ContentType { get; set; } +} + +// Serialization +var dto = new MediaResourceDto { ContentType = MimeType.Parse("application/json") }; +var json = JsonSerializer.Serialize(dto); +// Result: {"ContentType":"application/json"} + +// Deserialization +var input = "{ \"ContentType\": \"image/png\" }"; +var deserialized = JsonSerializer.Deserialize(input); + +Console.WriteLine(deserialized!.ContentType); // "image/png" +``` + +### Example 2: Use `AddMediaTypesConverters()` without attributes + +The library provides an extension method `AddMediaTypesConverters()` on `JsonSerializerOptions` to register the `MimeTypeJsonConverter` globally. + +```csharp +using System.Text.Json; +using PosInformatique.Foundations.MediaTypes; +using PosInformatique.Foundations.MediaTypes.Json; + +public class FileMetadataDto +{ + public MimeType? ContentType { get; set; } +} + +var options = new JsonSerializerOptions() + .AddMediaTypesConverters(); // Registers MimeTypeJsonConverter + +// Serialization +var dto = new FileMetadataDto +{ + ContentType = MimeType.Parse("application/pdf") +}; + +var json = JsonSerializer.Serialize(dto, options); +// Result: {"ContentType":"application/pdf"} + +// Deserialization +var input = "{ \"ContentType\": \"text/plain\" }"; +var deserialized = JsonSerializer.Deserialize(input, options); + +Console.WriteLine(deserialized!.ContentType); // "text/plain" +``` + +### Example 3: Null and invalid values + +```csharp +using System.Text.Json; +using PosInformatique.Foundations.MediaTypes; + +public class DocumentDto +{ + public MimeType? ContentType { get; set; } +} + +var options = new JsonSerializerOptions().AddMediaTypesConverters(); + +// Null value +var jsonWithNull = "{ \"ContentType\": null }"; +var docWithNull = JsonSerializer.Deserialize(jsonWithNull, options); +// docWithNull.ContentType is null + +// Invalid MIME type -> throws JsonException +var invalidJson = "{ \"ContentType\": \"not a mime\" }"; +try +{ + var invalidDoc = JsonSerializer.Deserialize(invalidJson, options); +} +catch (JsonException ex) +{ + Console.WriteLine(ex.Message); // "'not a mime' is not a valid MIME type." +} +``` + +## Links +- [NuGet package: MediaTypes.Json](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.Json/) +- [NuGet package: MediaTypes (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/tests/MediaTypes.Json.Tests/MediaTypes.Json.Tests.csproj b/tests/MediaTypes.Json.Tests/MediaTypes.Json.Tests.csproj new file mode 100644 index 0000000..f79b6c9 --- /dev/null +++ b/tests/MediaTypes.Json.Tests/MediaTypes.Json.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/MediaTypes.Json.Tests/MediaTypesJsonSerializerOptionsExtensionsTest.cs b/tests/MediaTypes.Json.Tests/MediaTypesJsonSerializerOptionsExtensionsTest.cs new file mode 100644 index 0000000..3d900e9 --- /dev/null +++ b/tests/MediaTypes.Json.Tests/MediaTypesJsonSerializerOptionsExtensionsTest.cs @@ -0,0 +1,42 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace System.Text.Json.Tests +{ + using PosInformatique.Foundations.MediaTypes.Json; + + public class MediaTypesJsonSerializerOptionsExtensionsTest + { + [Fact] + public void AddMediaTypesConverters() + { + var options = new JsonSerializerOptions(); + + options.AddMediaTypesConverters(); + + options.Converters.Should().HaveCount(1); + options.Converters[0].Should().BeOfType(); + + // Call again to check nothing has been changed. + options.AddMediaTypesConverters(); + + options.Converters.Should().HaveCount(1); + options.Converters[0].Should().BeOfType(); + } + + [Fact] + public void AddMediaTypesConverters_WithNullArgument() + { + var act = () => + { + MediaTypesJsonSerializerOptionsExtensions.AddMediaTypesConverters(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("options"); + } + } +} \ No newline at end of file diff --git a/tests/MediaTypes.Json.Tests/MimeTypeJsonConverterTest.cs b/tests/MediaTypes.Json.Tests/MimeTypeJsonConverterTest.cs new file mode 100644 index 0000000..967c302 --- /dev/null +++ b/tests/MediaTypes.Json.Tests/MimeTypeJsonConverterTest.cs @@ -0,0 +1,118 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.MediaTypes.Json +{ + using System.Text.Json; + + public class MimeTypeJsonConverterTest + { + [Fact] + public void Serialization() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new MimeTypeJsonConverter(), + }, + }; + + var @object = new JsonClass + { + StringValue = "The string value", + MimeType = MimeType.Parse("image/jpeg"), + }; + + @object.Should().BeJsonSerializableInto( + new + { + StringValue = "The string value", + MimeType = "image/jpeg", + }, + options); + } + + [Fact] + public void Deserialization() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new MimeTypeJsonConverter(), + }, + }; + + var json = new + { + StringValue = "The string value", + MimeType = "image/jpeg", + }; + + json.Should().BeJsonDeserializableInto( + new JsonClass + { + StringValue = "The string value", + MimeType = MimeType.Parse("image/jpeg"), + }, + options); + } + + [Fact] + public void Deserialization_WithNullValue() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new MimeTypeJsonConverter(), + }, + }; + + var json = new + { + StringValue = "The string value", + MimeType = (string)null, + }; + + json.Should().BeJsonDeserializableInto( + new JsonClass + { + StringValue = "The string value", + MimeType = null, + }, + options); + } + + [Fact] + public void Deserialization_WithInvalidMimeType() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new MimeTypeJsonConverter(), + }, + }; + + var act = () => + { + JsonSerializer.Deserialize("{\"StringValue\":\"\",\"MimeType\":\"invalid-mime-type\"}", options); + }; + + act.Should().ThrowExactly() + .WithMessage("'invalid-mime-type' is not a valid MIME type."); + } + + private class JsonClass + { + public string StringValue { get; set; } + + public MimeType MimeType { get; set; } + } + } +} \ No newline at end of file From de861a2e9811903f76692cf8f9af81f41c08610e Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 14 Nov 2025 15:28:50 +0100 Subject: [PATCH 37/73] Add the MediaTypes.EntityFramework library. --- PosInformatique.Foundations.slnx | 4 + README.md | 9 +- .../EmailAddressPropertyExtensions.cs | 2 +- src/EmailAddresses.EntityFramework/README.md | 73 +++++---- src/MediaTypes.EntityFramework/CHANGELOG.md | 2 + .../MediaTypes.EntityFramework.csproj | 31 ++++ .../MimeTypePropertyExtensions.cs | 53 +++++++ src/MediaTypes.EntityFramework/README.md | 73 +++++++++ .../FirstNamePropertyExtensions.cs | 2 +- .../LastNamePropertyExtensions.cs | 2 +- .../MediaTypes.EntityFramework.Tests.csproj | 11 ++ .../MimeTypePropertyExtensionsTest.cs | 139 ++++++++++++++++++ 12 files changed, 362 insertions(+), 39 deletions(-) create mode 100644 src/MediaTypes.EntityFramework/CHANGELOG.md create mode 100644 src/MediaTypes.EntityFramework/MediaTypes.EntityFramework.csproj create mode 100644 src/MediaTypes.EntityFramework/MimeTypePropertyExtensions.cs create mode 100644 src/MediaTypes.EntityFramework/README.md create mode 100644 tests/MediaTypes.EntityFramework.Tests/MediaTypes.EntityFramework.Tests.csproj create mode 100644 tests/MediaTypes.EntityFramework.Tests/MimeTypePropertyExtensionsTest.cs diff --git a/PosInformatique.Foundations.slnx b/PosInformatique.Foundations.slnx index 78c5ae6..f953942 100644 --- a/PosInformatique.Foundations.slnx +++ b/PosInformatique.Foundations.slnx @@ -41,6 +41,10 @@ + + + + diff --git a/README.md b/README.md index fc56bd2..015c017 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,12 @@ You can install any package using the .NET CLI or NuGet Package Manager. | |Package | Description | NuGet | |--|---------|-------------|-------| |PosInformatique.Foundations.EmailAddresses icon|[**PosInformatique.Foundations.EmailAddresses**](./src/EmailAddresses/README.md) | Strongly-typed value object representing an email address with validation and normalization as RFC 5322 compliant. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses) | -|PosInformatique.Foundations.EmailAddresses.EntityFramework icon|[**PosInformatique.Foundations.EmailAddresses.EntityFramework**](./src/EmailAddresses.EntityFramework/README.md) | Entity Framework Core integration for the EmailAddress value object, including property configuration and value converter for seamless database persistence. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework) | -|PosInformatique.Foundations.EmailAddresses.FluentValidation icon|[**PosInformatique.Foundations.EmailAddresses.FluentValidation**](./src/EmailAddresses.FluentValidation/README.md) | FluentValidation integration for the EmailAddress value object, providing dedicated validators and rules to ensure RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.FluentValidation) | -|PosInformatique.Foundations.EmailAddresses.Json icon|[**PosInformatique.Foundations.EmailAddresses.Json**](./src/EmailAddresses.Json/README.md) | `System.Text.Json` converter for the EmailAddress value object, enabling seamless serialization and deserialization of RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json) | +|PosInformatique.Foundations.EmailAddresses.EntityFramework icon|[**PosInformatique.Foundations.EmailAddresses.EntityFramework**](./src/EmailAddresses.EntityFramework/README.md) | Entity Framework Core integration for the `EmailAddress` value object, including property configuration and value converter for seamless database persistence. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework) | +|PosInformatique.Foundations.EmailAddresses.FluentValidation icon|[**PosInformatique.Foundations.EmailAddresses.FluentValidation**](./src/EmailAddresses.FluentValidation/README.md) | FluentValidation integration for the `EmailAddress` value object, providing dedicated validators and rules to ensure RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.FluentValidation) | +|PosInformatique.Foundations.EmailAddresses.Json icon|[**PosInformatique.Foundations.EmailAddresses.Json**](./src/EmailAddresses.Json/README.md) | `System.Text.Json` converter for the `EmailAddress` value object, enabling seamless serialization and deserialization of RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json) | |PosInformatique.Foundations.MediaTypes icon|[**PosInformatique.Foundations.MediaTypes**](./src/MediaTypes/README.md) | Immutable `MimeType` value object with well-known media types and helpers to map between media types and file extensions. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes) | -|PosInformatique.Foundations.MediaTypes.Json icon|[**PosInformatique.Foundations.MediaTypes.Json**](./src/MediaTypes.Json/README.md) | `System.Text.Json` converter for the MimeType value object, enabling seamless serialization and deserialization of MIME types within JSON documents. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.Json) | +|PosInformatique.Foundations.MediaTypes.EntityFramework icon|[**PosInformatique.Foundations.MediaTypes.EntityFramework**](./src/MediaTypes.EntityFramework/README.md) | Entity Framework Core integration for the `MimeType` value object, including property configuration and value converter for seamless database persistence. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.EntityFramework) | +|PosInformatique.Foundations.MediaTypes.Json icon|[**PosInformatique.Foundations.MediaTypes.Json**](./src/MediaTypes.Json/README.md) | `System.Text.Json` converter for the `MimeType` value object, enabling seamless serialization and deserialization of MIME types within JSON documents. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.Json) | |PosInformatique.Foundations.People icon|[**PosInformatique.Foundations.People**](./src/People/README.md) | Strongly-typed value objects for first and last names with validation and normalization. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People)](https://www.nuget.org/packages/PosInformatique.Foundations.People) | |PosInformatique.Foundations.People.DataAnnotations icon|[**PosInformatique.Foundations.People.DataAnnotations**](./src/People.DataAnnotations/README.md) | DataAnnotations attributes for `FirstName` and `LastName` value objects. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.DataAnnotations)](https://www.nuget.org/packages/PosInformatique.Foundations.People.DataAnnotations) | |PosInformatique.Foundations.People.EntityFramework icon|[**PosInformatique.Foundations.People.EntityFramework**](./src/People.EntityFramework/README.md) | Entity Framework Core integration for `FirstName` and `LastName` value objects, providing fluent property configuration and value converters. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.People.EntityFramework) | diff --git a/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs b/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs index 289d25d..a609889 100644 --- a/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs +++ b/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.cs @@ -11,7 +11,7 @@ namespace Microsoft.EntityFrameworkCore using PosInformatique.Foundations.EmailAddresses; /// - /// Contains extension method to map a to a string column. + /// Contains extension methods to map a to a string column. /// public static class EmailAddressPropertyExtensions { diff --git a/src/EmailAddresses.EntityFramework/README.md b/src/EmailAddresses.EntityFramework/README.md index e7d1943..1964d23 100644 --- a/src/EmailAddresses.EntityFramework/README.md +++ b/src/EmailAddresses.EntityFramework/README.md @@ -1,73 +1,82 @@ -# PosInformatique.Foundations.EmailAddresses.EntityFramework +# PosInformatique.Foundations.MediaTypes.EntityFramework -[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) -[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.EntityFramework/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.MediaTypes.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.EntityFramework/) ## Introduction -Provides **Entity Framework Core** integration for the `EmailAddress` value object from -[PosInformatique.Foundations.EmailAddresses](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/). -This package enables seamless mapping of RFC 5322 compliant email addresses as strongly-typed properties in Entity Framework Core entities. -It ensures proper SQL type mapping, validation, and conversion to `VARCHAR` when persisted to the database. +Provides **Entity Framework Core** integration for the `MimeType` value object from +[PosInformatique.Foundations.MediaTypes](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes/). +This package enables seamless mapping of MIME types as strongly-typed properties in Entity Framework Core entities. + +It ensures proper SQL type mapping, validation, and conversion to `VARCHAR(128)` when persisted to the database. ## Install + You can install the package from NuGet: ```powershell -dotnet add package PosInformatique.Foundations.EmailAddresses.EntityFramework +dotnet add package PosInformatique.Foundations.MediaTypes.EntityFramework ``` -This package depends on the base package [PosInformatique.Foundations.EmailAddresses](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/). +This package depends on the base package [PosInformatique.Foundations.MediaTypes](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes/). ## Features -- Provides an extension method `IsEmailAddress()` to configure EF Core properties for `EmailAddress`. -- Maps to `VARCHAR(320)` database columns using the SQL type `EmailAddress` (you must define the SQL type `EmailAddress` mapped to `VARCHAR(320)` in your database). -- Ensures validation, normalization, and safe conversion to/from database fields. -- Built on top of the core `EmailAddress` value object. + +- Provides an extension method `IsMimeType()` to configure EF Core properties for `MimeType`. +- Maps to `VARCHAR(128)` database columns using the SQL type `MimeType` (you must define the SQL type `MimeType` mapped to `VARCHAR(128)` in your database). +- Ensures validation and safe conversion to/from database fields. +- Built on top of the core `MimeType` value object. ## Use cases -- **Entity mapping**: enforce strong typing for email addresses at the persistence layer. -- **Consistency**: ensure the same validation rules are applied in your entities and database. -- **Safety**: prevent invalid strings being stored in your database + +- **Entity mapping**: enforce strong typing for MIME types at the persistence layer. +- **Consistency**: ensure the same rules are applied in your entities and database. +- **Safety**: prevent invalid or malformed MIME type strings being stored in your database. ## Examples -> ⚠️ To use `IsEmailAddress()`, you must first define the SQL type `EmailAddress` mapped to `VARCHAR(320)` in your database. -For SQL Server, you can create it with: +> ⚠️ To use `IsMimeType()`, you must first define the SQL type `MimeType` mapped to `VARCHAR(128)` in your database. +> For SQL Server, you can create it with: ```sql -CREATE TYPE EmailAddress FROM VARCHAR(320) NOT NULL; +CREATE TYPE MimeType FROM VARCHAR(128) NOT NULL; ``` ### Example: Configure an entity + ```csharp using Microsoft.EntityFrameworkCore; -using PosInformatique.Foundations; +using PosInformatique.Foundations.MediaTypes; -public class User +public class Document { public int Id { get; set; } - public EmailAddress Email { get; set; } + + public MimeType ContentType { get; set; } } public class ApplicationDbContext : DbContext { - public DbSet Users => Set(); + public DbSet Documents => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity() - .Property(u => u.Email) - .IsEmailAddress(); + modelBuilder.Entity() + .Property(d => d.ContentType) + .IsMimeType(); } } ``` -This will configure the `Email` property of the `User` entity with: -- `VARCHAR(320)` (Non-unicode) column length -- SQL column type `EmailAddress` +This will configure the `ContentType` property of the `Document` entity with: + +- `VARCHAR(128)` (non-unicode) column length +- SQL column type `MimeType` +- Proper conversion between `MimeType` and `string` ## Links -- [NuGet package: EmailAddresses.EntityFramework](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) -- [NuGet package: EmailAddresses (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) -- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) + +- [NuGet package: MediaTypes.EntityFramework](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.EntityFramework/) +- [NuGet package: MediaTypes (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/src/MediaTypes.EntityFramework/CHANGELOG.md b/src/MediaTypes.EntityFramework/CHANGELOG.md new file mode 100644 index 0000000..69a83d2 --- /dev/null +++ b/src/MediaTypes.EntityFramework/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with the support Entity Framework persitance for MimeType value object. diff --git a/src/MediaTypes.EntityFramework/MediaTypes.EntityFramework.csproj b/src/MediaTypes.EntityFramework/MediaTypes.EntityFramework.csproj new file mode 100644 index 0000000..105d824 --- /dev/null +++ b/src/MediaTypes.EntityFramework/MediaTypes.EntityFramework.csproj @@ -0,0 +1,31 @@ + + + + true + + + Provides Entity Framework Core integration for the MimeType value object. + Enables seamless mapping of MIME types as strongly-typed properties in Entity Framework Core entities, with safe conversion to string. + + mimetype;media;mediatype;contenttype;entityframework;efcore;valueobject;validation;mapping;conversion;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/MediaTypes.EntityFramework/MimeTypePropertyExtensions.cs b/src/MediaTypes.EntityFramework/MimeTypePropertyExtensions.cs new file mode 100644 index 0000000..10c45f8 --- /dev/null +++ b/src/MediaTypes.EntityFramework/MimeTypePropertyExtensions.cs @@ -0,0 +1,53 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.EntityFrameworkCore +{ + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + using PosInformatique.Foundations.MediaTypes; + + /// + /// Contains extension methods to map a to a string column. + /// + public static class MimeTypePropertyExtensions + { + /// + /// Configures the specified to be mapped on a column with a SQL MimeType type. + /// The MimeType type must be mapped to a VARCHAR(128). + /// + /// Type of the property which must be . + /// Entity property to map in the . + /// The instance to configure the configuration of the property. + /// If the specified argument is . + /// If the specified generic type is not a . + public static PropertyBuilder IsMimeType(this PropertyBuilder property) + { + ArgumentNullException.ThrowIfNull(property); + + if (typeof(T) != typeof(MimeType)) + { + throw new ArgumentException($"The '{nameof(IsMimeType)}()' method must be called on '{nameof(MimeType)} class.", nameof(property)); + } + + return property + .IsUnicode(false) + .HasMaxLength(128) + .HasColumnType("MimeType") + .HasConversion(MimeTypeConverter.Instance); + } + + private sealed class MimeTypeConverter : ValueConverter + { + private MimeTypeConverter() + : base(mimeType => mimeType.ToString(), @string => MimeType.Parse(@string)) + { + } + + public static MimeTypeConverter Instance { get; } = new MimeTypeConverter(); + } + } +} \ No newline at end of file diff --git a/src/MediaTypes.EntityFramework/README.md b/src/MediaTypes.EntityFramework/README.md new file mode 100644 index 0000000..e7d1943 --- /dev/null +++ b/src/MediaTypes.EntityFramework/README.md @@ -0,0 +1,73 @@ +# PosInformatique.Foundations.EmailAddresses.EntityFramework + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) + +## Introduction +Provides **Entity Framework Core** integration for the `EmailAddress` value object from +[PosInformatique.Foundations.EmailAddresses](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/). +This package enables seamless mapping of RFC 5322 compliant email addresses as strongly-typed properties in Entity Framework Core entities. + +It ensures proper SQL type mapping, validation, and conversion to `VARCHAR` when persisted to the database. + +## Install +You can install the package from NuGet: + +```powershell +dotnet add package PosInformatique.Foundations.EmailAddresses.EntityFramework +``` + +This package depends on the base package [PosInformatique.Foundations.EmailAddresses](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/). + +## Features +- Provides an extension method `IsEmailAddress()` to configure EF Core properties for `EmailAddress`. +- Maps to `VARCHAR(320)` database columns using the SQL type `EmailAddress` (you must define the SQL type `EmailAddress` mapped to `VARCHAR(320)` in your database). +- Ensures validation, normalization, and safe conversion to/from database fields. +- Built on top of the core `EmailAddress` value object. + +## Use cases +- **Entity mapping**: enforce strong typing for email addresses at the persistence layer. +- **Consistency**: ensure the same validation rules are applied in your entities and database. +- **Safety**: prevent invalid strings being stored in your database + +## Examples + +> ⚠️ To use `IsEmailAddress()`, you must first define the SQL type `EmailAddress` mapped to `VARCHAR(320)` in your database. +For SQL Server, you can create it with: + +```sql +CREATE TYPE EmailAddress FROM VARCHAR(320) NOT NULL; +``` + +### Example: Configure an entity +```csharp +using Microsoft.EntityFrameworkCore; +using PosInformatique.Foundations; + +public class User +{ + public int Id { get; set; } + public EmailAddress Email { get; set; } +} + +public class ApplicationDbContext : DbContext +{ + public DbSet Users => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .Property(u => u.Email) + .IsEmailAddress(); + } +} +``` + +This will configure the `Email` property of the `User` entity with: +- `VARCHAR(320)` (Non-unicode) column length +- SQL column type `EmailAddress` + +## Links +- [NuGet package: EmailAddresses.EntityFramework](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) +- [NuGet package: EmailAddresses (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) diff --git a/src/People.EntityFramework/FirstNamePropertyExtensions.cs b/src/People.EntityFramework/FirstNamePropertyExtensions.cs index 755821d..a708c79 100644 --- a/src/People.EntityFramework/FirstNamePropertyExtensions.cs +++ b/src/People.EntityFramework/FirstNamePropertyExtensions.cs @@ -12,7 +12,7 @@ namespace Microsoft.EntityFrameworkCore using PosInformatique.Foundations.People; /// - /// Contains extension method to map a to a string column. + /// Contains extension methods to map a to a string column. /// public static class FirstNamePropertyExtensions { diff --git a/src/People.EntityFramework/LastNamePropertyExtensions.cs b/src/People.EntityFramework/LastNamePropertyExtensions.cs index 0741cce..b5685a6 100644 --- a/src/People.EntityFramework/LastNamePropertyExtensions.cs +++ b/src/People.EntityFramework/LastNamePropertyExtensions.cs @@ -12,7 +12,7 @@ namespace Microsoft.EntityFrameworkCore using PosInformatique.Foundations.People; /// - /// Contains extension method to map a to a string column. + /// Contains extension methods to map a to a string column. /// public static class LastNamePropertyExtensions { diff --git a/tests/MediaTypes.EntityFramework.Tests/MediaTypes.EntityFramework.Tests.csproj b/tests/MediaTypes.EntityFramework.Tests/MediaTypes.EntityFramework.Tests.csproj new file mode 100644 index 0000000..5038d52 --- /dev/null +++ b/tests/MediaTypes.EntityFramework.Tests/MediaTypes.EntityFramework.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/MediaTypes.EntityFramework.Tests/MimeTypePropertyExtensionsTest.cs b/tests/MediaTypes.EntityFramework.Tests/MimeTypePropertyExtensionsTest.cs new file mode 100644 index 0000000..66429fe --- /dev/null +++ b/tests/MediaTypes.EntityFramework.Tests/MimeTypePropertyExtensionsTest.cs @@ -0,0 +1,139 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.EntityFrameworkCore.Tests +{ + using PosInformatique.Foundations.MediaTypes; + + public class MimeTypePropertyExtensionsTest + { + [Fact] + public void IsMimeType() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("MimeType"); + + property.GetColumnType().Should().Be("MimeType"); + property.IsUnicode().Should().BeFalse(); + property.GetMaxLength().Should().Be(128); + + var converter = property.GetValueConverter(); + + converter.ConvertFromProvider("application/pdf").Should().Be(MimeType.Parse("application/pdf")); + converter.ConvertToProvider(null).Should().Be(null); + } + + [Fact] + public void IsMimeType_NullArgument() + { + var act = () => + { + MimeTypePropertyExtensions.IsMimeType(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("property"); + } + + [Fact] + public void IsMimeType_NotMimeTypeProperty() + { + var builder = new ModelBuilder(); + var property = builder.Entity() + .Property(e => e.Id); + + var act = () => + { + property.IsMimeType(); + }; + + act.Should().ThrowExactly() + .WithMessage("The 'IsMimeType()' method must be called on 'MimeType class. (Parameter 'property')") + .WithParameterName("property"); + } + + [Fact] + public void ConvertFromProvider() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("MimeType"); + + var converter = property.GetValueConverter(); + + converter.ConvertFromProvider("application/pdf").Should().Be(MimeTypes.Application.Pdf); + } + + [Fact] + public void ConvertFromProvider_Null() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("MimeType"); + + var converter = property.GetValueConverter(); + + converter.ConvertFromProvider(null).Should().BeNull(); + } + + [Fact] + public void ConvertToProvider() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("MimeType"); + + var converter = property.GetValueConverter(); + + converter.ConvertToProvider(MimeTypes.Application.Pdf).Should().Be("application/pdf"); + } + + [Fact] + public void ConvertToProvider_WithNull() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("MimeType"); + + var converter = property.GetValueConverter(); + + converter.ConvertToProvider(null).Should().BeNull(); + } + + private class DbContextMock : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + optionsBuilder.UseSqlServer(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + var property = modelBuilder.Entity() + .Property(e => e.MimeType); + + property.IsMimeType().Should().BeSameAs(property); + } + } + + private class EntityMock + { + public int Id { get; set; } + + public MimeType MimeType { get; set; } + } + } +} \ No newline at end of file From 9d1bfe2fc75e2c69eb2932c198001593323f61d4 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 14 Nov 2025 17:40:14 +0100 Subject: [PATCH 38/73] Add the AutoCad MIME type. --- src/MediaTypes/MimeTypeExtensions.cs | 27 +++++++++- src/MediaTypes/MimeTypes.cs | 14 +++++ src/MediaTypes/README.md | 27 ++++++---- .../MimeTypeExtensionsTest.cs | 51 +++++++++++++++---- tests/MediaTypes.Tests/MimeTypesTest.cs | 18 +++++++ 5 files changed, 116 insertions(+), 21 deletions(-) diff --git a/src/MediaTypes/MimeTypeExtensions.cs b/src/MediaTypes/MimeTypeExtensions.cs index ba43209..41d83d0 100644 --- a/src/MediaTypes/MimeTypeExtensions.cs +++ b/src/MediaTypes/MimeTypeExtensions.cs @@ -11,6 +11,29 @@ namespace PosInformatique.Foundations.MediaTypes /// public static class MimeTypeExtensions { + /// + /// Determines whether the specified media type represents an AutoCAD drawing. + /// + /// The media type to check. + /// if the media type is image/x-dxf or image/x-dwg; otherwise, . + /// Thrown when the argument is . + public static bool IsAutoCad(this MimeType mimeType) + { + ArgumentNullException.ThrowIfNull(mimeType); + + if (mimeType == MimeTypes.Image.Dxf) + { + return true; + } + + if (mimeType == MimeTypes.Image.Dwg) + { + return true; + } + + return false; + } + /// /// Determines whether the specified media type represents a PDF document. /// @@ -25,7 +48,7 @@ public static bool IsPdf(this MimeType mimeType) } /// - /// Determines whether the specified media type represents an image media type. + /// Determines whether the specified media type represents an image media type (the AutoCAD drawing are excluded). /// /// The media type to check. /// if the media type is in the image/* family; otherwise, . @@ -34,7 +57,7 @@ public static bool IsImage(this MimeType mimeType) { ArgumentNullException.ThrowIfNull(mimeType); - return mimeType.Type == "image"; + return mimeType.Type == "image" && !IsAutoCad(mimeType); } } } \ No newline at end of file diff --git a/src/MediaTypes/MimeTypes.cs b/src/MediaTypes/MimeTypes.cs index 7a951ff..26d2924 100644 --- a/src/MediaTypes/MimeTypes.cs +++ b/src/MediaTypes/MimeTypes.cs @@ -17,6 +17,8 @@ public static class MimeTypes { "pdf", Application.Pdf }, { "docx", Application.Docx }, { "bmp", Image.Bmp }, + { "dxf", Image.Dxf }, + { "dwg", Image.Dwg }, { "jpg", Image.Jpeg }, { "jpeg", Image.Jpeg }, { "png", Image.Png }, @@ -30,6 +32,8 @@ public static class MimeTypes { Application.Pdf, "pdf" }, { Application.Docx, "docx" }, { Image.Bmp, "bmp" }, + { Image.Dxf, "dxf" }, + { Image.Dwg, "dwg" }, { Image.Jpeg, "jpg" }, { Image.Png, "png" }, { Image.Tiff, "tiff" }, @@ -94,6 +98,16 @@ public static class Image /// public static MimeType Bmp { get; } = MimeType.Parse("image/bmp", null); + /// + /// Gets the media type image/x-dxf. + /// + public static MimeType Dxf { get; } = MimeType.Parse("image/x-dxf"); + + /// + /// Gets the media type image/x-dwg. + /// + public static MimeType Dwg { get; } = MimeType.Parse("image/x-dwg"); + /// /// Gets the media type image/jpeg. /// diff --git a/src/MediaTypes/README.md b/src/MediaTypes/README.md index ebbb119..1d2009b 100644 --- a/src/MediaTypes/README.md +++ b/src/MediaTypes/README.md @@ -95,6 +95,12 @@ if (image.IsImage()) { Console.WriteLine("This is an image type."); } + +var drawing = MimeTypes.Image.Dwg; +if (drawing.IsAutoCad()) +{ + Console.WriteLine("This is an AutoCAD drawing type."); +} ``` ## API overview @@ -118,21 +124,24 @@ if (image.IsImage()) Provides common media types and mapping helpers. - `MimeTypes.Application` - - `MimeType OctetStream` (`application/octet-stream`) - - `MimeType Pdf` (`application/pdf`) - - `MimeType Docx` (`application/vnd.openxmlformats-officedocument.wordprocessingml.document`) + - `OctetStream` (`application/octet-stream`) + - `Pdf` (`application/pdf`) + - `Docx` (`application/vnd.openxmlformats-officedocument.wordprocessingml.document`) - `MimeTypes.Image` - - `MimeType Bmp` (`image/bmp`) - - `MimeType Jpeg` (`image/jpeg`) - - `MimeType Png` (`image/png`) - - `MimeType Tiff` (`image/tiff`) - - `MimeType WebP` (`image/webp`) + - `Bmp` (`image/bmp`) + - `Dxf` (`image/x-dxf`) + - `Dwg` (`image/x-dwg`) + - `Jpeg` (`image/jpeg`) + - `Png` (`image/png`) + - `Tiff` (`image/tiff`) + - `WebP` (`image/webp`) ### MimeTypeExtensions -- `bool IsPdf(this MimeType mimeType)` +- `bool IsAutoCad(this MimeType mimeType)` - `bool IsImage(this MimeType mimeType)` +- `bool IsPdf(this MimeType mimeType)` ## Links diff --git a/tests/MediaTypes.Tests/MimeTypeExtensionsTest.cs b/tests/MediaTypes.Tests/MimeTypeExtensionsTest.cs index 30e7a21..ab2508d 100644 --- a/tests/MediaTypes.Tests/MimeTypeExtensionsTest.cs +++ b/tests/MediaTypes.Tests/MimeTypeExtensionsTest.cs @@ -9,24 +9,26 @@ namespace PosInformatique.Foundations.MediaTypes.Tests public class MimeTypeExtensionsTest { [Fact] - public void IsPdf() + public void IsAutoCad() { - MimeTypes.Application.Docx.IsPdf().Should().BeFalse(); - MimeTypes.Application.Pdf.IsPdf().Should().BeTrue(); + MimeTypes.Application.Docx.IsAutoCad().Should().BeFalse(); + MimeTypes.Application.Pdf.IsAutoCad().Should().BeFalse(); MimeTypes.Application.OctetStream.IsPdf().Should().BeFalse(); - MimeTypes.Image.Bmp.IsPdf().Should().BeFalse(); - MimeTypes.Image.Jpeg.IsPdf().Should().BeFalse(); - MimeTypes.Image.Png.IsPdf().Should().BeFalse(); - MimeTypes.Image.Tiff.IsPdf().Should().BeFalse(); - MimeTypes.Image.WebP.IsPdf().Should().BeFalse(); + MimeTypes.Image.Bmp.IsAutoCad().Should().BeFalse(); + MimeTypes.Image.Dwg.IsAutoCad().Should().BeTrue(); + MimeTypes.Image.Dxf.IsAutoCad().Should().BeTrue(); + MimeTypes.Image.Jpeg.IsAutoCad().Should().BeFalse(); + MimeTypes.Image.Png.IsAutoCad().Should().BeFalse(); + MimeTypes.Image.Tiff.IsAutoCad().Should().BeFalse(); + MimeTypes.Image.WebP.IsAutoCad().Should().BeFalse(); } [Fact] - public void IsPdf_WithNullArgument() + public void IsAutoCad_WithNullArgument() { var act = () => { - MimeTypeExtensions.IsPdf(null); + MimeTypeExtensions.IsAutoCad(null); }; act.Should().ThrowExactly() @@ -41,6 +43,8 @@ public void IsImage() MimeTypes.Application.OctetStream.IsPdf().Should().BeFalse(); MimeTypes.Image.Bmp.IsImage().Should().BeTrue(); MimeTypes.Image.Jpeg.IsImage().Should().BeTrue(); + MimeTypes.Image.Dwg.IsImage().Should().BeFalse(); + MimeTypes.Image.Dxf.IsImage().Should().BeFalse(); MimeTypes.Image.Png.IsImage().Should().BeTrue(); MimeTypes.Image.Tiff.IsImage().Should().BeTrue(); MimeTypes.Image.WebP.IsImage().Should().BeTrue(); @@ -57,5 +61,32 @@ public void IsImage_WithNullArgument() act.Should().ThrowExactly() .WithParameterName("mimeType"); } + + [Fact] + public void IsPdf() + { + MimeTypes.Application.Docx.IsPdf().Should().BeFalse(); + MimeTypes.Application.Pdf.IsPdf().Should().BeTrue(); + MimeTypes.Application.OctetStream.IsPdf().Should().BeFalse(); + MimeTypes.Image.Bmp.IsPdf().Should().BeFalse(); + MimeTypes.Image.Dwg.IsPdf().Should().BeFalse(); + MimeTypes.Image.Dxf.IsPdf().Should().BeFalse(); + MimeTypes.Image.Jpeg.IsPdf().Should().BeFalse(); + MimeTypes.Image.Png.IsPdf().Should().BeFalse(); + MimeTypes.Image.Tiff.IsPdf().Should().BeFalse(); + MimeTypes.Image.WebP.IsPdf().Should().BeFalse(); + } + + [Fact] + public void IsPdf_WithNullArgument() + { + var act = () => + { + MimeTypeExtensions.IsPdf(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("mimeType"); + } } } \ No newline at end of file diff --git a/tests/MediaTypes.Tests/MimeTypesTest.cs b/tests/MediaTypes.Tests/MimeTypesTest.cs index 615813b..9b8f077 100644 --- a/tests/MediaTypes.Tests/MimeTypesTest.cs +++ b/tests/MediaTypes.Tests/MimeTypesTest.cs @@ -53,6 +53,24 @@ public void Image_Jpeg() MimeTypes.Image.Jpeg.Subtype.Should().Be("jpeg"); } + [Fact] + public void Image_Dxf() + { + MimeTypes.Image.Dxf.Should().BeSameAs(MimeTypes.Image.Dxf); + + MimeTypes.Image.Dxf.Type.Should().Be("image"); + MimeTypes.Image.Dxf.Subtype.Should().Be("x-dxf"); + } + + [Fact] + public void Image_Dwg() + { + MimeTypes.Image.Dwg.Should().BeSameAs(MimeTypes.Image.Dwg); + + MimeTypes.Image.Dwg.Type.Should().Be("image"); + MimeTypes.Image.Dwg.Subtype.Should().Be("x-dwg"); + } + [Fact] public void Image_Png() { From a3386e32df8f1628e607cfa03a664070386a2af5 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 14 Nov 2025 17:50:19 +0100 Subject: [PATCH 39/73] Add emailing implementation. --- Directory.Build.props | 1 + Directory.Packages.props | 7 + PosInformatique.Foundations.slnx | 16 ++ README.md | 4 + src/Emailing.Azure/AzureEmailProvider.cs | 54 ++++ .../AzureEmailingBuilderExtensions.cs | 72 ++++++ src/Emailing.Azure/CHANGELOG.md | 2 + src/Emailing.Azure/Emailing.Azure.csproj | 32 +++ src/Emailing.Azure/README.md | 113 +++++++++ src/Emailing/CHANGELOG.md | 2 + src/Emailing/Email.cs | 41 +++ src/Emailing/EmailContact.cs | 42 ++++ src/Emailing/EmailManager.cs | 94 +++++++ src/Emailing/EmailMessage.cs | 58 +++++ src/Emailing/EmailModel.cs | 21 ++ src/Emailing/EmailRecipient.cs | 52 ++++ src/Emailing/EmailRecipientCollection.cs | 42 ++++ src/Emailing/EmailTemplate.cs | 44 ++++ src/Emailing/EmailTemplateIdentifier.cs | 29 +++ src/Emailing/Emailing.csproj | 38 +++ src/Emailing/EmailingBuilder.cs | 34 +++ src/Emailing/EmailingOptions.cs | 65 +++++ .../EmailingServiceCollectionExtensions.cs | 37 +++ src/Emailing/IEmailManager.cs | 42 ++++ src/Emailing/IEmailProvider.cs | 23 ++ src/Emailing/Icon.png | Bin 0 -> 41858 bytes src/Emailing/README.md | 234 ++++++++++++++++++ src/MediaTypes.Json/MediaTypes.Json.csproj | 1 - src/MediaTypes/MediaTypes.csproj | 1 - src/Text.Templating.Razor/CHANGELOG.md | 2 + .../IRazorTextTemplateRenderer.cs | 13 + src/Text.Templating.Razor/README.md | 162 ++++++++++++ .../RazorTextTemplate.cs | 43 ++++ .../RazorTextTemplateRenderer.cs | 43 ++++ ...xtTemplatingServiceCollectionExtensions.cs | 33 +++ .../Text.Templating.Razor.csproj | 37 +++ src/Text.Templating/CHANGELOG.md | 2 + .../ITextTemplateRenderContext.cs | 22 ++ src/Text.Templating/Icon.png | Bin 0 -> 41792 bytes src/Text.Templating/README.md | 41 +++ src/Text.Templating/Text.Templating.csproj | 28 +++ src/Text.Templating/TextTemplate.cs | 36 +++ tests/.editorconfig | 10 + tests/Directory.Build.props | 7 + .../AzureEmailProviderTest.cs | 59 +++++ .../AzureEmailingBuilderExtensionsTest.cs | 171 +++++++++++++ .../Emailing.Azure.Tests.csproj | 7 + tests/Emailing.Tests/EmailContactTest.cs | 50 ++++ tests/Emailing.Tests/EmailManagerTest.cs | 193 +++++++++++++++ tests/Emailing.Tests/EmailMessageTest.cs | 87 +++++++ .../EmailRecipientCollectionTest.cs | 75 ++++++ tests/Emailing.Tests/EmailRecipientTest.cs | 70 ++++++ .../EmailTemplateIdentifierTest.cs | 24 ++ tests/Emailing.Tests/EmailTemplateTest.cs | 55 ++++ tests/Emailing.Tests/EmailTest.cs | 43 ++++ tests/Emailing.Tests/Emailing.Tests.csproj | 11 + tests/Emailing.Tests/EmailingOptionsTest.cs | 99 ++++++++ ...EmailingServiceCollectionExtensionsTest.cs | 66 +++++ .../ComponentTest.razor | 4 + .../ComponentTest.razor.cs | 19 ++ .../RazorTextTemplateRendererTest.cs | 58 +++++ .../RazorTextTemplateTest.cs | 98 ++++++++ ...mplatingServiceCollectionExtensionsTest.cs | 39 +++ .../Text.Templating.Razor.Tests.csproj | 7 + 64 files changed, 2913 insertions(+), 2 deletions(-) create mode 100644 src/Emailing.Azure/AzureEmailProvider.cs create mode 100644 src/Emailing.Azure/AzureEmailingBuilderExtensions.cs create mode 100644 src/Emailing.Azure/CHANGELOG.md create mode 100644 src/Emailing.Azure/Emailing.Azure.csproj create mode 100644 src/Emailing.Azure/README.md create mode 100644 src/Emailing/CHANGELOG.md create mode 100644 src/Emailing/Email.cs create mode 100644 src/Emailing/EmailContact.cs create mode 100644 src/Emailing/EmailManager.cs create mode 100644 src/Emailing/EmailMessage.cs create mode 100644 src/Emailing/EmailModel.cs create mode 100644 src/Emailing/EmailRecipient.cs create mode 100644 src/Emailing/EmailRecipientCollection.cs create mode 100644 src/Emailing/EmailTemplate.cs create mode 100644 src/Emailing/EmailTemplateIdentifier.cs create mode 100644 src/Emailing/Emailing.csproj create mode 100644 src/Emailing/EmailingBuilder.cs create mode 100644 src/Emailing/EmailingOptions.cs create mode 100644 src/Emailing/EmailingServiceCollectionExtensions.cs create mode 100644 src/Emailing/IEmailManager.cs create mode 100644 src/Emailing/IEmailProvider.cs create mode 100644 src/Emailing/Icon.png create mode 100644 src/Emailing/README.md create mode 100644 src/Text.Templating.Razor/CHANGELOG.md create mode 100644 src/Text.Templating.Razor/IRazorTextTemplateRenderer.cs create mode 100644 src/Text.Templating.Razor/README.md create mode 100644 src/Text.Templating.Razor/RazorTextTemplate.cs create mode 100644 src/Text.Templating.Razor/RazorTextTemplateRenderer.cs create mode 100644 src/Text.Templating.Razor/RazorTextTemplatingServiceCollectionExtensions.cs create mode 100644 src/Text.Templating.Razor/Text.Templating.Razor.csproj create mode 100644 src/Text.Templating/CHANGELOG.md create mode 100644 src/Text.Templating/ITextTemplateRenderContext.cs create mode 100644 src/Text.Templating/Icon.png create mode 100644 src/Text.Templating/README.md create mode 100644 src/Text.Templating/Text.Templating.csproj create mode 100644 src/Text.Templating/TextTemplate.cs create mode 100644 tests/Emailing.Azure.Tests/AzureEmailProviderTest.cs create mode 100644 tests/Emailing.Azure.Tests/AzureEmailingBuilderExtensionsTest.cs create mode 100644 tests/Emailing.Azure.Tests/Emailing.Azure.Tests.csproj create mode 100644 tests/Emailing.Tests/EmailContactTest.cs create mode 100644 tests/Emailing.Tests/EmailManagerTest.cs create mode 100644 tests/Emailing.Tests/EmailMessageTest.cs create mode 100644 tests/Emailing.Tests/EmailRecipientCollectionTest.cs create mode 100644 tests/Emailing.Tests/EmailRecipientTest.cs create mode 100644 tests/Emailing.Tests/EmailTemplateIdentifierTest.cs create mode 100644 tests/Emailing.Tests/EmailTemplateTest.cs create mode 100644 tests/Emailing.Tests/EmailTest.cs create mode 100644 tests/Emailing.Tests/Emailing.Tests.csproj create mode 100644 tests/Emailing.Tests/EmailingOptionsTest.cs create mode 100644 tests/Emailing.Tests/EmailingServiceCollectionExtensionsTest.cs create mode 100644 tests/Text.Templating.Razor.Tests/ComponentTest.razor create mode 100644 tests/Text.Templating.Razor.Tests/ComponentTest.razor.cs create mode 100644 tests/Text.Templating.Razor.Tests/RazorTextTemplateRendererTest.cs create mode 100644 tests/Text.Templating.Razor.Tests/RazorTextTemplateTest.cs create mode 100644 tests/Text.Templating.Razor.Tests/RazorTextTemplatingServiceCollectionExtensionsTest.cs create mode 100644 tests/Text.Templating.Razor.Tests/Text.Templating.Razor.Tests.csproj diff --git a/Directory.Build.props b/Directory.Build.props index 0b76af0..4c60f5f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -47,6 +47,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 8b9b49c..5f5f222 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,11 +3,18 @@ true + + + + + + + diff --git a/PosInformatique.Foundations.slnx b/PosInformatique.Foundations.slnx index f953942..ee1c5d0 100644 --- a/PosInformatique.Foundations.slnx +++ b/PosInformatique.Foundations.slnx @@ -37,6 +37,14 @@ + + + + + + + + @@ -73,4 +81,12 @@ + + + + + + + + diff --git a/README.md b/README.md index 015c017..7792a4d 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ You can install any package using the .NET CLI or NuGet Package Manager. |PosInformatique.Foundations.EmailAddresses.EntityFramework icon|[**PosInformatique.Foundations.EmailAddresses.EntityFramework**](./src/EmailAddresses.EntityFramework/README.md) | Entity Framework Core integration for the `EmailAddress` value object, including property configuration and value converter for seamless database persistence. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework) | |PosInformatique.Foundations.EmailAddresses.FluentValidation icon|[**PosInformatique.Foundations.EmailAddresses.FluentValidation**](./src/EmailAddresses.FluentValidation/README.md) | FluentValidation integration for the `EmailAddress` value object, providing dedicated validators and rules to ensure RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.FluentValidation) | |PosInformatique.Foundations.EmailAddresses.Json icon|[**PosInformatique.Foundations.EmailAddresses.Json**](./src/EmailAddresses.Json/README.md) | `System.Text.Json` converter for the `EmailAddress` value object, enabling seamless serialization and deserialization of RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json) | +|PosInformatique.Foundations.Emailing icon|[**PosInformatique.Foundations.Emailing**](./src/Emailing/README.md) | Template-based emailing infrastructure for .NET that lets you register strongly-typed email templates, create emails from models, and send them through pluggable providers. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Emailing)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing) | +|PosInformatique.Foundations.Emailing.Azure icon|[**PosInformatique.Foundations.Emailing.Azure**](./src/Emailing.Azure/README.md) | `IEmailProvider` implementation for `PosInformatique.Foundations.Emailing` using **Azure Communication Service**. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Emailing.Azure)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure) | |PosInformatique.Foundations.MediaTypes icon|[**PosInformatique.Foundations.MediaTypes**](./src/MediaTypes/README.md) | Immutable `MimeType` value object with well-known media types and helpers to map between media types and file extensions. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes) | |PosInformatique.Foundations.MediaTypes.EntityFramework icon|[**PosInformatique.Foundations.MediaTypes.EntityFramework**](./src/MediaTypes.EntityFramework/README.md) | Entity Framework Core integration for the `MimeType` value object, including property configuration and value converter for seamless database persistence. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.EntityFramework) | |PosInformatique.Foundations.MediaTypes.Json icon|[**PosInformatique.Foundations.MediaTypes.Json**](./src/MediaTypes.Json/README.md) | `System.Text.Json` converter for the `MimeType` value object, enabling seamless serialization and deserialization of MIME types within JSON documents. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.Json) | @@ -36,6 +38,8 @@ You can install any package using the .NET CLI or NuGet Package Manager. |PosInformatique.Foundations.People.FluentAssertions icon|[**PosInformatique.Foundations.People.FluentAssertions**](./src/People.FluentAssertions/README.md) | [FluentAssertions](https://fluentassertions.com/) extensions for `FirstName` and `LastName` to avoid ambiguity and provide `Should().Be(string)` assertions (case-sensitive on normalized values). | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.FluentAssertions)](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentAssertions) | |PosInformatique.Foundations.People.FluentValidation icon|[**PosInformatique.Foundations.People.FluentValidation**](./src/People.FluentValidation/README.md) | [FluentValidation](https://fluentvalidation.net/) extensions for `FirstName` and `LastName` value objects. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentValidation) | |PosInformatique.Foundations.People.Json icon|[**PosInformatique.Foundations.People.Json**](./src/People.Json/README.md) | `System.Text.Json` converters for `FirstName` and `LastName`, with validation and easy registration via `AddPeopleConverters()`. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.People.Json) | +|PosInformatique.Foundations.Text.Templating icon|[**PosInformatique.Foundations.Text.Templating**](./src/Text.Templating/README.md) | Abstractions for text templating, including the `TextTemplate` base class and `ITextTemplateRenderContext` interface, to be used by concrete templating engine implementations such as Razor-based text templates. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Text.Templating)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating) | +|PosInformatique.Foundations.Text.Templating.Razor icon|[**PosInformatique.Foundations.Text.Templating.Razor**](./src/Text.Templating.Razor/README.md) | Razor-based text templating using Blazor components, allowing generation of text from Razor views with a strongly-typed Model parameter and full dependency injection integration. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Text.Templating.Razor)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor) | > Note: Each package is completely independent. You install only what you need. diff --git a/src/Emailing.Azure/AzureEmailProvider.cs b/src/Emailing.Azure/AzureEmailProvider.cs new file mode 100644 index 0000000..fb9cf2e --- /dev/null +++ b/src/Emailing.Azure/AzureEmailProvider.cs @@ -0,0 +1,54 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Azure +{ + /// + /// Implementation of the to send the e-mail using + /// Azure Communication Service. + /// + public sealed class AzureEmailProvider : IEmailProvider + { + private readonly global::Azure.Communication.Email.EmailClient client; + + /// + /// Initializes a new instance of the class + /// using the Microsoft . + /// + /// + /// used to call the Azure Communication Service API. + /// Thrown when the argument is . + public AzureEmailProvider(global::Azure.Communication.Email.EmailClient client) + { + ArgumentNullException.ThrowIfNull(client); + + this.client = client; + } + + /// + public async Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(message); + + var receipients = new global::Azure.Communication.Email.EmailRecipients() + { + To = + { + new global::Azure.Communication.Email.EmailAddress(message.To.Email, message.To.DisplayName), + }, + }; + + var content = new global::Azure.Communication.Email.EmailContent(message.Subject) + { + Html = message.HtmlContent, + }; + + var azureMessage = new global::Azure.Communication.Email.EmailMessage(message.From.Email, receipients, content); + + await this.client.SendAsync(global::Azure.WaitUntil.Started, azureMessage, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/Emailing.Azure/AzureEmailingBuilderExtensions.cs b/src/Emailing.Azure/AzureEmailingBuilderExtensions.cs new file mode 100644 index 0000000..9b50c2a --- /dev/null +++ b/src/Emailing.Azure/AzureEmailingBuilderExtensions.cs @@ -0,0 +1,72 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Azure +{ + using global::Azure.Communication.Email; + using global::Azure.Core.Extensions; + using Microsoft.Extensions.Azure; + using Microsoft.Extensions.DependencyInjection.Extensions; + + /// + /// Extension methods to configure the Azure Communication Service provider + /// for the . + /// + public static class AzureEmailingBuilderExtensions + { + /// + /// Configure the provider of to use Azure Communication Service. + /// + /// which to configure. + /// Uri to the the Azure Communication Service instance. + /// Allows to configure the used by the provider. + /// Thrown when the argument is . + /// Thrown when the argument is . + public static void UseAzureCommunicationService(this EmailingBuilder builder, Uri uri, Action>? clientBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(uri); + + builder.Services.TryAddSingleton(); + + builder.Services.AddAzureClients(builder => + { + var emailClientBuilder = builder.AddEmailClient(uri); + + if (clientBuilder is not null) + { + clientBuilder(emailClientBuilder); + } + }); + } + + /// + /// Configure the provider of to use Azure Communication Service. + /// + /// which to configure. + /// Connection string to the Azure Communication Service instance. + /// Allows to configure the used by the provider. + /// Thrown when the argument is . + /// Thrown when the argument is . + public static void UseAzureCommunicationService(this EmailingBuilder builder, string connectionString, Action>? clientBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(connectionString); + + builder.Services.TryAddSingleton(); + + builder.Services.AddAzureClients(builder => + { + var emailClientBuilder = builder.AddEmailClient(connectionString); + + if (clientBuilder is not null) + { + clientBuilder(emailClientBuilder); + } + }); + } + } +} \ No newline at end of file diff --git a/src/Emailing.Azure/CHANGELOG.md b/src/Emailing.Azure/CHANGELOG.md new file mode 100644 index 0000000..bb7782e --- /dev/null +++ b/src/Emailing.Azure/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with Azure Communication Service Emailing provider. diff --git a/src/Emailing.Azure/Emailing.Azure.csproj b/src/Emailing.Azure/Emailing.Azure.csproj new file mode 100644 index 0000000..574fa69 --- /dev/null +++ b/src/Emailing.Azure/Emailing.Azure.csproj @@ -0,0 +1,32 @@ + + + + true + + + Provides an IEmailProvider implementation for PosInformatique.Foundations.Emailing using Azure Communication Service. + Uses Azure.Communication.Email.EmailClient to send templated emails and can be configured via AddEmailing().UseAzureCommunicationService(), including EmailClient and EmailClientOptions customization. + + email;emailing;azure;azurecommunicationservice;communication;emailclient;provider;dotnet;dependencyinjection;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + + diff --git a/src/Emailing.Azure/README.md b/src/Emailing.Azure/README.md new file mode 100644 index 0000000..3e81ea7 --- /dev/null +++ b/src/Emailing.Azure/README.md @@ -0,0 +1,113 @@ +# PosInformatique.Foundations.Emailing.Azure + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.Emailing.Azure)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.Emailing.Azure)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure/) + +## Introduction + +[PosInformatique.Foundations.Emailing.Azure](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure/) provides an `IEmailProvider` +implementation for [PosInformatique.Foundations.Emailing](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing/) +based on the `EmailClient` from [Azure.Communication.Email](https://www.nuget.org/packages/Azure.Communication.Email). + +It allows you to send templated emails (created via `IEmailManager`) using **Azure Communication Service**. + +## Install + +You can install the package from [NuGet](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure/): + +```powershell +dotnet add package PosInformatique.Foundations.Emailing.Azure +``` + +## Features + +- `IEmailProvider` implementation using [Azure.Communication.Email.EmailClient](https://learn.microsoft.com/en-us/dotnet/api/azure.communication.email.emailclient?view=azure-dotnet). +- Simple configuration through `AddEmailing().UseAzureCommunicationService(...)`. +- Supports configuration via: + - Azure Communication Service **connection string**, or + - Azure Communication Service **endpoint URI**. +- Callback to configure `EmailClient` / `EmailClientOptions`: + - Authentication (managed identity, connection string, etc.). + - Retry policy, logging, diagnostics, and other Azure client options. + +## Basic configuration + +### Using connection string + +```csharp +using Microsoft.Extensions.DependencyInjection; +using PosInformatique.Foundations.EmailAddresses; +using PosInformatique.Foundations.Emailing; +using PosInformatique.Foundations.Emailing.Azure; + +var services = new ServiceCollection(); + +// Your ACS connection string +var acsConnectionString = configuration["AzureCommunicationService:ConnectionString"]; + +services + .AddEmailing(options => + { + options.SenderEmailAddress = EmailAddress.Parse("no-reply@myapp.com"); + + // Register your templates here... + // options.RegisterTemplate(EmailTemplateIdentifiers.Invitation, invitationTemplate); + }) + .UseAzureCommunicationService(acsConnectionString); +``` + +### Using endpoint URI and Azure identity (managed identity) + +You can also configure the provider using the ACS endpoint URI, and configure authentication +(for example with managed identity) and client options using the `clientBuilder` parameter. + +```csharp +using Azure.Communication.Email; +using Azure.Identity; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.DependencyInjection; +using PosInformatique.Foundations.Emailing; +using PosInformatique.Foundations.Emailing.Azure; + +var services = new ServiceCollection(); + +var acsEndpoint = new Uri(configuration["AzureCommunicationService:Endpoint"]); + +services + .AddEmailing(options => + { + options.SenderEmailAddress = EmailAddress.Parse("no-reply@myapp.com"); + + // Register your templates here... + }) + .UseAzureCommunicationService( + acsEndpoint, + clientBuilder => + { + // Configure EmailClient and EmailClientOptions here + + clientBuilder.ConfigureOptions((EmailClientOptions options) => + { + // Example: configure retry, diagnostics, etc. + options.Retry.MaxRetries = 5; + }); + + // Example: use managed identity for authentication + clientBuilder.WithCredential(new DefaultAzureCredential()); + }); +``` + +## Typical usage end-to-end + +1. Configure emailing and templates with `AddEmailing(...)`. +2. Configure the Azure provider using `UseAzureCommunicationService(...)`. +3. Inject `IEmailManager` and create an email from a template identifier. +4. Add recipients and models. +5. Call `SendAsync(...)` to send via Azure Communication Service. + +## Links + +- [NuGet package: Emailing (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing/) +- [NuGet package: Emailing.Azure](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) +- [Azure Communication Services – Email documentation](https://learn.microsoft.com/azure/communication-services/concepts/email/) \ No newline at end of file diff --git a/src/Emailing/CHANGELOG.md b/src/Emailing/CHANGELOG.md new file mode 100644 index 0000000..c76f51e --- /dev/null +++ b/src/Emailing/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with Emailing infrastructure. diff --git a/src/Emailing/Email.cs b/src/Emailing/Email.cs new file mode 100644 index 0000000..fea8761 --- /dev/null +++ b/src/Emailing/Email.cs @@ -0,0 +1,41 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + /// + /// Represents a templated e-mail to send. + /// + /// Type of data model injected to the to generate the e-mail. + public sealed class Email + where TModel : EmailModel + { + /// + /// Initializes a new instance of the class + /// with the specified . + /// + /// The e-mail template used to generate the e-mail to send. + /// Thrown when the argument is . + public Email(EmailTemplate template) + { + ArgumentNullException.ThrowIfNull(template); + + this.Template = template; + + this.Recipients = new EmailRecipientCollection(); + } + + /// + /// Gets the collection of the recipients which the e-mail have to be send. + /// + public EmailRecipientCollection Recipients { get; } + + /// + /// Gets the e-mail template used to generate the e-mail to send. + /// + public EmailTemplate Template { get; } + } +} \ No newline at end of file diff --git a/src/Emailing/EmailContact.cs b/src/Emailing/EmailContact.cs new file mode 100644 index 0000000..4fb6655 --- /dev/null +++ b/src/Emailing/EmailContact.cs @@ -0,0 +1,42 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + using PosInformatique.Foundations.EmailAddresses; + + /// + /// Represents an e-mail contact (e-mail address and a display name). + /// + public class EmailContact + { + /// + /// Initializes a new instance of the class. + /// + /// The e-mail of the contact. + /// The display name of the contact (can be empty). + /// Thrown when the argument is . + /// Thrown when the argument is . + public EmailContact(EmailAddress email, string displayName) + { + ArgumentNullException.ThrowIfNull(email); + ArgumentNullException.ThrowIfNull(displayName); + + this.Email = email; + this.DisplayName = displayName; + } + + /// + /// Gets the e-mail of the contact. + /// + public EmailAddress Email { get; } + + /// + /// Gets the display name of the contact. + /// + public string DisplayName { get; } + } +} \ No newline at end of file diff --git a/src/Emailing/EmailManager.cs b/src/Emailing/EmailManager.cs new file mode 100644 index 0000000..f506d2e --- /dev/null +++ b/src/Emailing/EmailManager.cs @@ -0,0 +1,94 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + using Microsoft.Extensions.Options; + using PosInformatique.Foundations.Text.Templating; + + internal sealed class EmailManager : IEmailManager + { + private readonly IOptions options; + + private readonly IEmailProvider provider; + + private readonly IServiceProvider serviceProvider; + + public EmailManager(IOptions options, IEmailProvider provider, IServiceProvider serviceProvider) + { + if (options.Value.SenderEmailAddress is null) + { + throw new ArgumentException("Sender email address is required.", nameof(options)); + } + + this.options = options; + this.provider = provider; + this.serviceProvider = serviceProvider; + } + + public Email Create(EmailTemplateIdentifier identifier) + where TModel : EmailModel + { + ArgumentNullException.ThrowIfNull(identifier); + + var template = this.options.Value.GetTemplate(identifier); + + if (template is null) + { + throw new ArgumentException("Unable to find a template for the specified identifier.", nameof(identifier)); + } + + return new Email(template); + } + + public async Task SendAsync(Email email, CancellationToken cancellationToken = default) + where TModel : EmailModel + { + ArgumentNullException.ThrowIfNull(email); + + var senderEmailAddress = this.options.Value.SenderEmailAddress!; + + foreach (var recipient in email.Recipients) + { + // Render the subject + using var subjectOutputWriter = new StringWriter(); + + var textTemplateRenderContext = new TextTemplateRenderContext(this.serviceProvider); + + await email.Template.Subject.RenderAsync(recipient.Model, subjectOutputWriter, textTemplateRenderContext, cancellationToken); + + var subject = subjectOutputWriter.ToString(); + + // Render the HTML content + using var htmlContentOutputWriter = new StringWriter(); + + textTemplateRenderContext = new TextTemplateRenderContext(this.serviceProvider); + + await email.Template.HtmlBody.RenderAsync(recipient.Model, htmlContentOutputWriter, textTemplateRenderContext, cancellationToken); + + var htmlContent = htmlContentOutputWriter.ToString(); + + var message = new EmailMessage( + new EmailContact(senderEmailAddress, string.Empty), + new EmailContact(recipient.Address, recipient.DisplayName), + subject, + htmlContent); + + await this.provider.SendAsync(message, cancellationToken); + } + } + + private sealed class TextTemplateRenderContext : ITextTemplateRenderContext + { + public TextTemplateRenderContext(IServiceProvider serviceProvider) + { + this.ServiceProvider = serviceProvider; + } + + public IServiceProvider ServiceProvider { get; } + } + } +} \ No newline at end of file diff --git a/src/Emailing/EmailMessage.cs b/src/Emailing/EmailMessage.cs new file mode 100644 index 0000000..d564abd --- /dev/null +++ b/src/Emailing/EmailMessage.cs @@ -0,0 +1,58 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + /// + /// Represents an e-mail generated an can be send to the . + /// + public sealed class EmailMessage + { + /// + /// Initializes a new instance of the class. + /// + /// The sender of the e-mail message. + /// The recipient of the e-mail message. + /// The subject of the e-mail message. + /// The HTML content of the e-mail message. + /// Thrown when the argument is . + /// Thrown when the argument is . + /// Thrown when the argument is . + /// Thrown when the argument is . + public EmailMessage(EmailContact from, EmailContact to, string subject, string htmlContent) + { + ArgumentNullException.ThrowIfNull(from); + ArgumentNullException.ThrowIfNull(to); + ArgumentNullException.ThrowIfNull(subject); + ArgumentNullException.ThrowIfNull(htmlContent); + + this.From = from; + this.To = to; + this.Subject = subject; + this.HtmlContent = htmlContent; + } + + /// + /// Gets the sender of the e-mail message. + /// + public EmailContact From { get; } + + /// + /// Gets the recipient of the e-mail message. + /// + public EmailContact To { get; } + + /// + /// Gets the subject of the e-mail message. + /// + public string Subject { get; } + + /// + /// Gets the HTML content of the e-mail message. + /// + public string HtmlContent { get; } + } +} \ No newline at end of file diff --git a/src/Emailing/EmailModel.cs b/src/Emailing/EmailModel.cs new file mode 100644 index 0000000..0e55a86 --- /dev/null +++ b/src/Emailing/EmailModel.cs @@ -0,0 +1,21 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + /// + /// Base class of the data model of the . + /// + public abstract class EmailModel + { + /// + /// Initializes a new instance of the class. + /// + protected EmailModel() + { + } + } +} \ No newline at end of file diff --git a/src/Emailing/EmailRecipient.cs b/src/Emailing/EmailRecipient.cs new file mode 100644 index 0000000..a4de0fa --- /dev/null +++ b/src/Emailing/EmailRecipient.cs @@ -0,0 +1,52 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + using PosInformatique.Foundations.EmailAddresses; + + /// + /// Represents a recipient of a to send. + /// + /// Data model injected to the to generate the e-mail. + public sealed class EmailRecipient + { + /// + /// Initializes a new instance of the class. + /// + /// E-mail address of the recipient. + /// Display name of the recipient (can be empty). + /// Data model to apply on the to generate the e-mail for the recipient. + /// Thrown when the argument is . + /// Thrown when the argument is . + /// Thrown when the argument is . + public EmailRecipient(EmailAddress address, string displayName, TModel model) + { + ArgumentNullException.ThrowIfNull(address); + ArgumentNullException.ThrowIfNull(displayName); + ArgumentNullException.ThrowIfNull(model); + + this.Address = address; + this.DisplayName = displayName; + this.Model = model; + } + + /// + /// Gets the e-mail address of the recipient. + /// + public EmailAddress Address { get; } + + /// + /// Gets the display name of the recipient. + /// + public string DisplayName { get; } + + /// + /// Gets the data model to apply on the to generate the e-mail for the recipient. + /// + public TModel Model { get; } + } +} \ No newline at end of file diff --git a/src/Emailing/EmailRecipientCollection.cs b/src/Emailing/EmailRecipientCollection.cs new file mode 100644 index 0000000..cba9903 --- /dev/null +++ b/src/Emailing/EmailRecipientCollection.cs @@ -0,0 +1,42 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + using PosInformatique.Foundations.EmailAddresses; + + /// + /// Represents a collection of to send + /// the . + /// + /// Data model injected to the to generate the e-mail for each recipient. + public class EmailRecipientCollection : Collection> + where TModel : EmailModel + { + /// + /// Creates and add new in the . + /// + /// E-mail address of the recipient. + /// Display name of the recipient (can be empty). + /// Data model to apply on the to generate the e-mail for the recipient. + /// The created and added. + /// Thrown when the argument is . + /// Thrown when the argument is . + /// Thrown when the argument is . + public EmailRecipient Add(EmailAddress address, string displayName, TModel model) + { + ArgumentNullException.ThrowIfNull(address); + ArgumentNullException.ThrowIfNull(displayName); + ArgumentNullException.ThrowIfNull(model); + + var recipient = new EmailRecipient(address, displayName, model); + + this.Add(recipient); + + return recipient; + } + } +} \ No newline at end of file diff --git a/src/Emailing/EmailTemplate.cs b/src/Emailing/EmailTemplate.cs new file mode 100644 index 0000000..93a9bc3 --- /dev/null +++ b/src/Emailing/EmailTemplate.cs @@ -0,0 +1,44 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + using PosInformatique.Foundations.Text.Templating; + + /// + /// Represents an e-mail template used to generate e-mail to send to the . + /// + /// Type of data model injected to the and to generate the e-mail. + public class EmailTemplate + where TModel : EmailModel + { + /// + /// Initializes a new instance of the class. + /// + /// The text template used to generate the subject of the e-mail to send. + /// The text template used to generate the HTML content of the e-mail to send. + /// Thrown when the argument is . + /// Thrown when the argument is . + public EmailTemplate(TextTemplate subject, TextTemplate htmlBody) + { + ArgumentNullException.ThrowIfNull(subject); + ArgumentNullException.ThrowIfNull(htmlBody); + + this.Subject = subject; + this.HtmlBody = htmlBody; + } + + /// + /// Gets the text template used to generate the subject of the e-mail to send. + /// + public TextTemplate Subject { get; } + + /// + /// Gets the text template used to generate the HTML content of the e-mail to send. + /// + public TextTemplate HtmlBody { get; } + } +} \ No newline at end of file diff --git a/src/Emailing/EmailTemplateIdentifier.cs b/src/Emailing/EmailTemplateIdentifier.cs new file mode 100644 index 0000000..7441fc9 --- /dev/null +++ b/src/Emailing/EmailTemplateIdentifier.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + /// + /// Represents an unique identifier. + /// + /// Data model injected to the to generate the e-mail. + public sealed class EmailTemplateIdentifier + where TModel : EmailModel + { + private EmailTemplateIdentifier() + { + } + + /// + /// Creates a new identifier. + /// + /// A new identifier. + public static EmailTemplateIdentifier Create() + { + return new EmailTemplateIdentifier(); + } + } +} \ No newline at end of file diff --git a/src/Emailing/Emailing.csproj b/src/Emailing/Emailing.csproj new file mode 100644 index 0000000..b341ee0 --- /dev/null +++ b/src/Emailing/Emailing.csproj @@ -0,0 +1,38 @@ + + + + true + + + Provides a lightweight, template-based emailing infrastructure for .NET. + Allows registering strongly-typed email templates, instantiating emails from models, and sending them through pluggable providers (e.g. Azure Communication Service). + + + email;emailing;templating;razor;scriban;dotnet;posinformatique;azure;communication;service;provider;dependencyinjection + + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Emailing/EmailingBuilder.cs b/src/Emailing/EmailingBuilder.cs new file mode 100644 index 0000000..3c6d80d --- /dev/null +++ b/src/Emailing/EmailingBuilder.cs @@ -0,0 +1,34 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + using Microsoft.Extensions.DependencyInjection; + + /// + /// Used to configure e-mailing feature. + /// + public sealed class EmailingBuilder + { + /// + /// Initializes a new instance of the class + /// to configure the e-mailing feature. + /// + /// The services being configured. + /// Thrown when the argument is . + public EmailingBuilder(IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + this.Services = services; + } + + /// + /// Gets the services being configured. + /// + public IServiceCollection Services { get; } + } +} \ No newline at end of file diff --git a/src/Emailing/EmailingOptions.cs b/src/Emailing/EmailingOptions.cs new file mode 100644 index 0000000..35cb573 --- /dev/null +++ b/src/Emailing/EmailingOptions.cs @@ -0,0 +1,65 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + using PosInformatique.Foundations.EmailAddresses; + + /// + /// Represents the e-mailing feature options. + /// + public class EmailingOptions + { + private readonly Dictionary templates; + + /// + /// Initializes a new instance of the class. + /// + public EmailingOptions() + { + this.templates = []; + } + + /// + /// Gets or sets the e-mail address of the sender used for the e-mails. + /// + public EmailAddress? SenderEmailAddress { get; set; } + + /// + /// Registers a instance with the specified . + /// + /// Type of the data model to inject in the . + /// Unique identifier of the . + /// to register. + /// Thrown when the argument is . + /// Thrown when the argument is . + /// If a has already been registered with the specified . + public void RegisterTemplate(EmailTemplateIdentifier identifier, EmailTemplate template) + where TModel : EmailModel + { + ArgumentNullException.ThrowIfNull(identifier); + ArgumentNullException.ThrowIfNull(template); + + if (this.templates.ContainsKey(identifier)) + { + throw new ArgumentException("An e-mail template with the same identifier has already been registered.", nameof(identifier)); + } + + this.templates.Add(identifier, template); + } + + internal EmailTemplate? GetTemplate(EmailTemplateIdentifier identifier) + where TModel : EmailModel + { + if (!this.templates.TryGetValue(identifier, out var templateFound)) + { + return null; + } + + return (EmailTemplate)templateFound; + } + } +} \ No newline at end of file diff --git a/src/Emailing/EmailingServiceCollectionExtensions.cs b/src/Emailing/EmailingServiceCollectionExtensions.cs new file mode 100644 index 0000000..a24d695 --- /dev/null +++ b/src/Emailing/EmailingServiceCollectionExtensions.cs @@ -0,0 +1,37 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.Extensions.DependencyInjection +{ + using Microsoft.Extensions.DependencyInjection.Extensions; + using PosInformatique.Foundations.Emailing; + + /// + /// Contains extension methods to register the e-mailing feature in the . + /// + public static class EmailingServiceCollectionExtensions + { + /// + /// Registers the e-mailing feature. + /// + /// where to register the services. + /// Options of the . + /// An instance of to continue the configuration for the e-mailing feature. + /// Thrown when the argument is . + /// Thrown when the argument is . + public static EmailingBuilder AddEmailing(this IServiceCollection services, Action options) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(options); + + services.TryAddScoped(); + + services.Configure(options); + + return new EmailingBuilder(services); + } + } +} \ No newline at end of file diff --git a/src/Emailing/IEmailManager.cs b/src/Emailing/IEmailManager.cs new file mode 100644 index 0000000..0afd0f4 --- /dev/null +++ b/src/Emailing/IEmailManager.cs @@ -0,0 +1,42 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + /// + /// Manager which allows to send e-mail using a . + /// + public interface IEmailManager + { + /// + /// Creates a new instance of the + /// with the specified . + /// The is retrieved from the + /// when calling the + /// method. + /// + /// Type of the data model to inject in the . + /// Unique identifier of the which will be use + /// to create the . + /// A new instance of which represents an e-mail based to the + /// associated to the . + /// Thrown when the argument is . + /// Thrown if no has been registered with the specified . + Email Create(EmailTemplateIdentifier identifier) + where TModel : EmailModel; + + /// + /// Sends the specified . + /// + /// Type of the data model to inject in the . + /// The e-mail template with the recipients to send. + /// which allows to cancel the send process. + /// An instance of the class which represents the asynchronous operation. + /// Thrown when the argument is . + Task SendAsync(Email email, CancellationToken cancellationToken = default) + where TModel : EmailModel; + } +} \ No newline at end of file diff --git a/src/Emailing/IEmailProvider.cs b/src/Emailing/IEmailProvider.cs new file mode 100644 index 0000000..f95f691 --- /dev/null +++ b/src/Emailing/IEmailProvider.cs @@ -0,0 +1,23 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + /// + /// Represents a provider to send an . + /// + public interface IEmailProvider + { + /// + /// Sends the specified e-mail . + /// + /// E-mail message to send. + /// which allows to cancel the send of the e-mail. + /// A instance which represents the asynchronous operation. + /// Thrown when the argument is . + Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/src/Emailing/Icon.png b/src/Emailing/Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a867ea3de18aa455a58f2961a000d7a0180ce9a5 GIT binary patch literal 41858 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%0AnIT!RALdY{fY}ZFbG_sxl!T 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 ZQaZ}qj{d7`76^FV)6&x{yK5QxzW{-1D?$JO literal 0 HcmV?d00001 diff --git a/src/Emailing/README.md b/src/Emailing/README.md new file mode 100644 index 0000000..e8c68ca --- /dev/null +++ b/src/Emailing/README.md @@ -0,0 +1,234 @@ +# PosInformatique.Foundations.Emailing + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.Emailing)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.Emailing)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing/) + +## Introduction + +`PosInformatique.Foundations.Emailing` provides a lightweight, template-based emailing infrastructure built on top of dependency injection. + +It allows you to: + +- Define strongly-typed email templates associated with data models. +- Instantiate emails from registered templates via an `IEmailManager`. +- Generate and send templated emails for each recipient through an `IEmailProvider` implementation. + +The actual transport (SMTP, Azure Communication Service, etc.) is delegated to a provider implementation. +Existing implementation are available in the following packages: +- Azure Communication Service: [PosInformatique.Foundations.Emailing.Azure](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure/). + +## Install + +You can install the package from [NuGet](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing/): + +```powershell +dotnet add package PosInformatique.Foundations.Emailing +``` + +This package also depends on: + +- [PosInformatique.Foundations.EmailAddresses](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) +- [PosInformatique.Foundations.Text.Templating](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating/) +and one of its concrete implementations (for example +[PosInformatique.Foundations.Text.Templating.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor/) or +[PosInformatique.Foundations.Text.Templating.Scriban](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Scriban/)). + +## Features + +- Registration of email templates through `AddEmailing(...)` and `EmailingOptions.RegisterTemplate(...)`. +- Strongly-typed template identifiers via `EmailTemplateIdentifier`. +- Data models for templates based on an abstract `EmailModel` base class. +- Template-based subject and HTML body using `TextTemplate` (e.g. Razor or Scriban). +- Per-recipient data injection using a `EmailModel` with `EmailRecipient`. +- Central `IEmailManager` to create and send emails. +- Pluggable `IEmailProvider` to send the final `EmailMessage` (transport-agnostic design). + +## Basic concepts + +### Email models + +Each email template is associated with a data model that derives from `EmailModel`. +This model is injected into the subject and HTML body templates when generating the email content for a recipient. + +```csharp +public sealed class InvitationEmailTemplateModel : EmailModel +{ + public string FirstName { get; set; } = string.Empty; + public string InvitationLink { get; set; } = string.Empty; +} + +public sealed class AccountDeletionEmailTemplateModel : EmailModel +{ + public string FirstName { get; set; } = string.Empty; + public DateTime DeletionDate { get; set; } +} +``` + +### Template identifiers + +Templates are registered and referenced through an `EmailTemplateIdentifier`. +It is recommended to centralize them in a static class used by your business code: + +```csharp +public static class EmailTemplateIdentifiers +{ + public static EmailTemplateIdentifier Invitation { get; } = + EmailTemplateIdentifier.Create(); + + public static EmailTemplateIdentifier AccountDeletion { get; } = + EmailTemplateIdentifier.Create(); +} +``` + +### Email templates + +An `EmailTemplate` is composed of two `TextTemplate` instances: + +- One for the subject. +- One for the HTML body. + +These `TextTemplate` come from `PosInformatique.Foundations.Text.Templating`, share the same model +instance during the e-mail generation process, and can be implemented using, for example: + +- [PosInformatique.Foundations.Text.Templating.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor/) +- [PosInformatique.Foundations.Text.Templating.Scriban](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Scriban/) + +```csharp +var invitationSubjectTemplate = new RazorTextTemplate(typeof(InvitationSubjectRazorComponent)); +var invitationHtmlBodyTemplate = new RazorTextTemplate(typeof(InvitationBodyRazorComponent)); + +var invitationTemplate = new EmailTemplate( + invitationSubjectTemplate, + invitationHtmlBodyTemplate); +``` + +## Configuration + +### Register the emailing feature + +You register the emailing feature in your `IServiceCollection` via `AddEmailing(...)`. +In the options, you must at least configure: + +- The sender email address. +- The mapping between `EmailTemplateIdentifier` and `EmailTemplate`. + +```csharp +using Microsoft.Extensions.DependencyInjection; +using PosInformatique.Foundations.EmailAddresses; +using PosInformatique.Foundations.Emailing; + +var services = new ServiceCollection(); + +services.AddEmailing(options => +{ + // Required: sender email address used for all outgoing emails + options.SenderEmailAddress = EmailAddress.Parse("no-reply@myapp.com"); + + // Register templates with their identifiers + options.RegisterTemplate(EmailTemplateIdentifiers.Invitation, invitationTemplate); + options.RegisterTemplate(EmailTemplateIdentifiers.AccountDeletion, accountDeletionTemplate); +}); +``` + +The `AddEmailing()` method returns an `EmailingBuilder` that can be used to continue configuring the emailing infrastructure +(for example, provider registration in other packages). + +### Email provider + +`IEmailProvider` is responsible for sending the final `EmailMessage`. +This package only defines the abstraction. A typical provider implementation is located in another package, such as: + +- [PosInformatique.Foundations.Emailing.Azure](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure/). + +See the [PosInformatique.Foundations.Emailing.Azure](../Emailing.Azure/README.md) documentation for an example of provider registration. + +## Usage + +### 1. Create an email from a template + +To send an email, you first ask `IEmailManager` to create an `Email` from a previously registered template identifier: + +```csharp +using PosInformatique.Foundations.Emailing; + +var emailManager = serviceProvider.GetRequiredService(); + +// Create an email based on the "Invitation" template +var invitationEmail = emailManager.Create(EmailTemplateIdentifiers.Invitation); +``` + +At this stage: + +- The `Email` is linked to the `EmailTemplate` previously registered in `EmailingOptions`. +- No recipient has been added yet. + +### 2. Add recipients and models + +You then populate the recipients collection with `EmailRecipient`. Each recipient has: + +- An `EmailAddress`. +- A display name. +- A data model instance (`TModel`) that will be injected into the template for that specific recipient. + +```csharp +using PosInformatique.Foundations.EmailAddresses; + +invitationEmail.Recipients.Add( + EmailAddress.Parse("alice@example.com"), + "Alice", + new InvitationEmailTemplateModel + { + FirstName = "Alice", + InvitationLink = "https://myapp.com/invite?code=ABC123" + }); + +invitationEmail.Recipients.Add( + EmailAddress.Parse("bob@example.com"), + "Bob", + new InvitationEmailTemplateModel + { + FirstName = "Bob", + InvitationLink = "https://myapp.com/invite?code=XYZ789" + }); +``` + +### 3. Send the email + +Once the email and its recipients are configured, you ask the `IEmailManager` to send it: + +```csharp +var cancellationToken = CancellationToken.None; + +await emailManager.SendAsync(invitationEmail, cancellationToken); +``` + +Under the hood: + +1. `IEmailManager` iterates over all recipients. +2. For each recipient, it applies the associated model to the template’s subject and HTML body. +3. It builds an `EmailMessage` with: + - The configured sender (`EmailingOptions.SenderEmailAddress`). + - The recipient address and display name. + - The generated subject and HTML content. +4. It calls `IEmailProvider.SendAsync(...)` to actually send the message. + +The provider implementation is responsible for the technical details (SMTP, Azure Communication Service, etc.). + +## Summary + +The typical flow is: + +1. Configure emailing: + - Register templates and sender via `AddEmailing(...)`. + - Register an `IEmailProvider` implementation. +2. Define and centralize template identifiers using `EmailTemplateIdentifier`. +3. Define data models by inheriting from `EmailModel`. +4. At runtime, use `IEmailManager.Create(...)` to instantiate a strongly-typed email. +5. Add recipients and models through `EmailRecipientCollection`. +6. Call `IEmailManager.SendAsync()` to generate and send emails through the `IEmailProvider`. + +## Links + +- [NuGet package: Emailing (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing/) +- [NuGet package: Emailing.Azure (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/src/MediaTypes.Json/MediaTypes.Json.csproj b/src/MediaTypes.Json/MediaTypes.Json.csproj index 89c85e9..cf6d284 100644 --- a/src/MediaTypes.Json/MediaTypes.Json.csproj +++ b/src/MediaTypes.Json/MediaTypes.Json.csproj @@ -12,7 +12,6 @@ $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) - true diff --git a/src/MediaTypes/MediaTypes.csproj b/src/MediaTypes/MediaTypes.csproj index b075b6f..0f43a8b 100644 --- a/src/MediaTypes/MediaTypes.csproj +++ b/src/MediaTypes/MediaTypes.csproj @@ -12,7 +12,6 @@ $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) - true diff --git a/src/Text.Templating.Razor/CHANGELOG.md b/src/Text.Templating.Razor/CHANGELOG.md new file mode 100644 index 0000000..2e703b8 --- /dev/null +++ b/src/Text.Templating.Razor/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with the Razor Text Templating feature. diff --git a/src/Text.Templating.Razor/IRazorTextTemplateRenderer.cs b/src/Text.Templating.Razor/IRazorTextTemplateRenderer.cs new file mode 100644 index 0000000..d217678 --- /dev/null +++ b/src/Text.Templating.Razor/IRazorTextTemplateRenderer.cs @@ -0,0 +1,13 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Text.Templating.Razor +{ + internal interface IRazorTextTemplateRenderer + { + Task RenderAsync(Type componentType, object? model, TextWriter output, CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/src/Text.Templating.Razor/README.md b/src/Text.Templating.Razor/README.md new file mode 100644 index 0000000..d9162eb --- /dev/null +++ b/src/Text.Templating.Razor/README.md @@ -0,0 +1,162 @@ +### PosInformatique.Foundations.Text.Templating.Razor + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.Text.Templating.Razor)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.Text.Templating.Razor)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor/) + +## Introduction + +This package provides a simple way to generate text from Razor components (views) outside of ASP.NET Core MVC/Blazor pages. +It is an implementation of the core [PosInformatique.Foundations.Text.Templating](../Text.Templating/README.md) library. + +You define a Razor component with a `Model` parameter, and the library renders it to a `TextWriter` by using a `RazorTextTemplate` implementation. The Razor component can also inject any service registered in the application `IServiceCollection`. + +## Install + +You can install the package from [NuGet](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor/): + +```powershell +dotnet add package PosInformatique.Foundations.Text.Templating.Razor +``` + +## Features + +- Render text from Razor components (Blazor-style components) +- Strongly-typed model passed via a `Model` parameter +- Integrates with dependency injection (`IServiceCollection` / `IServiceProvider`) +- Ability to inject any registered service directly in the Razor component with `@inject` +- Simple registration via `AddRazorTextTemplating(IServiceCollection)` + +## Basic usage + +### 1. Register the Razor text templating + +You must register the Razor text templating rendering infrastructure in your DI container: + +```csharp +var services = new ServiceCollection(); + +// Register application services +services.AddLogging(); + +// Register Razor text templating +services.AddRazorTextTemplating(); + +// Build the service provider used as context +var serviceProvider = services.BuildServiceProvider(); +``` + +### 2. Create a Razor component (view) + +Create a Razor component that will be used as a template, for example `HelloTemplate.razor`: + +```razor +@using System +@using Microsoft.Extensions.Logging + +@inherits ComponentBase + +@code { + // The model automatically injected by RazorTextTemplate + [Parameter] + public MyEmailModel? Model { get; set; } + + protected override void OnInitialized() + { + // You can use Blazor event as usual (OnInitialized, OnParametersSet, etc.) + this.Model.Now = DateTime.UtcNow; + } +} + +Hello this.Model.UserName ! +Today is this.Model.Now:U +``` + +Key points: + +- The model is received via a `Model` parameter (it is automatically set by the library). +- You can inject any service registered in `IServiceCollection` using `@inject` (or `[Inject]` attribute in code-behind). + +### 3. Use `RazorTextTemplate.RenderAsync()` + +You can now create a `RazorTextTemplate` instance, build a rendering context that exposes an `IServiceProvider`, and call `RenderAsync()`: + +```csharp +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using PosInformatique.Foundations.Text.Templating; +using PosInformatique.Foundations.Text.Templating.Razor; + +// Example of a simple ITextTemplateRenderContext implementation +public class TextTemplateRenderContext : ITextTemplateRenderContext +{ + public TextTemplateRenderContext(IServiceProvider serviceProvider) + { + this.ServiceProvider = serviceProvider; + } + + public IServiceProvider ServiceProvider { get; } +} + +public static class RazorTemplateSample +{ + public static async Task GenerateAsync() + { + var services = new ServiceCollection(); + services.AddRazorTextTemplating(); + + var serviceProvider = services.BuildServiceProvider(); + + // Create the Razor text template that uses the HelloTemplate component + var template = new RazorTextTemplate(typeof(HelloTemplate)); + + // Build the context that provides IServiceProvider + var context = new TextTemplateRenderContext(serviceProvider); + + using var writer = new StringWriter(); + + // Render the template with a string model + var model = new MyEmailModel { UserName = "John" }; + await template.RenderAsync(model, writer, context, CancellationToken.None); + + var result = writer.ToString(); + Console.WriteLine(result); + } +} +``` + +### 4. Injecting other services in the Razor view + +Any service registered in your `IServiceCollection` and available through `IServiceProvider` can be injected in the Razor component. + +Example: + +```csharp +@using MyApp.Services +@inherits ComponentBase + +@code { + [Parameter] + public MyEmailModel? Model { get; set; } + + [Inject] + public IDateTimeProvider DateTimeProvider { get; set; } = default! + + [Inject] + public IMyFormatter Formatter { get; set; } = default! +} + +Hello @Model?.Name, + +Current time: @this.DateTimeProvider.UtcNow +Formatted data: @this.Formatter.Format(Model) +``` + +As long as `IDateTimeProvider` and `IMyFormatter` are registered in the `IServiceCollection`, they are available during template rendering. + +## Links + +- [NuGet package: Text.Templating (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating/) +- [NuGet package: Text.Templating.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/src/Text.Templating.Razor/RazorTextTemplate.cs b/src/Text.Templating.Razor/RazorTextTemplate.cs new file mode 100644 index 0000000..9df079e --- /dev/null +++ b/src/Text.Templating.Razor/RazorTextTemplate.cs @@ -0,0 +1,43 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Text.Templating.Razor +{ + using Microsoft.Extensions.DependencyInjection; + + /// + /// Implementation of the which generates using a Razor component. + /// + /// Type of the data model to inject to the Razor component. + public class RazorTextTemplate : TextTemplate + { + private readonly Type componentType; + + /// + /// Initializes a new instance of the class. + /// + /// Type of the Razor component which will be use to generate the text. + /// Thrown when the argument is . + public RazorTextTemplate(Type componentType) + { + ArgumentNullException.ThrowIfNull(componentType); + + this.componentType = componentType; + } + + /// + public override async Task RenderAsync(TModel model, TextWriter output, ITextTemplateRenderContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(context); + + var razorRenderer = context.ServiceProvider.GetRequiredService(); + + await razorRenderer.RenderAsync(this.componentType, model, output, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/Text.Templating.Razor/RazorTextTemplateRenderer.cs b/src/Text.Templating.Razor/RazorTextTemplateRenderer.cs new file mode 100644 index 0000000..e204a4c --- /dev/null +++ b/src/Text.Templating.Razor/RazorTextTemplateRenderer.cs @@ -0,0 +1,43 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Text.Templating.Razor +{ + using Microsoft.AspNetCore.Components; + using Microsoft.AspNetCore.Components.Web; + using Microsoft.Extensions.Logging; + + internal sealed class RazorTextTemplateRenderer : IRazorTextTemplateRenderer + { + private readonly IServiceProvider serviceProvider; + + private readonly ILoggerFactory loggerFactory; + + public RazorTextTemplateRenderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory) + { + this.serviceProvider = serviceProvider; + this.loggerFactory = loggerFactory; + } + + public async Task RenderAsync(Type componentType, object? model, TextWriter output, CancellationToken cancellationToken = default) + { + await using var htmlRenderer = new HtmlRenderer(this.serviceProvider, this.loggerFactory); + + await htmlRenderer.Dispatcher.InvokeAsync(async () => + { + var values = new Dictionary + { + { "Model", model }, + }; + + var parameters = ParameterView.FromDictionary(values); + var result = await htmlRenderer.RenderComponentAsync(componentType, parameters); + + result.WriteHtmlTo(output); + }); + } + } +} \ No newline at end of file diff --git a/src/Text.Templating.Razor/RazorTextTemplatingServiceCollectionExtensions.cs b/src/Text.Templating.Razor/RazorTextTemplatingServiceCollectionExtensions.cs new file mode 100644 index 0000000..c94b2e8 --- /dev/null +++ b/src/Text.Templating.Razor/RazorTextTemplatingServiceCollectionExtensions.cs @@ -0,0 +1,33 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.Extensions.DependencyInjection +{ + using Microsoft.Extensions.DependencyInjection.Extensions; + using PosInformatique.Foundations.Text.Templating.Razor; + + /// + /// Contains extension methods to register the Razor text templating feature in the . + /// + public static class RazorTextTemplatingServiceCollectionExtensions + { + /// + /// Registers the Razor text templating engine in the specified . + /// + /// where the text templating engine will be registered. + /// The instance ton continue the configuration. + /// Thrown when the argument is . + public static IServiceCollection AddRazorTextTemplating(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddLogging(); + services.TryAddScoped(); + + return services; + } + } +} \ No newline at end of file diff --git a/src/Text.Templating.Razor/Text.Templating.Razor.csproj b/src/Text.Templating.Razor/Text.Templating.Razor.csproj new file mode 100644 index 0000000..a45e092 --- /dev/null +++ b/src/Text.Templating.Razor/Text.Templating.Razor.csproj @@ -0,0 +1,37 @@ + + + + true + + + Provides Razor-based text templating using Blazor components. + Allows generating text from Razor components with a strongly-typed Model parameter + and full integration with the application's dependency injection (IServiceProvider). + + razor;text;templating;blazor;component;template;rendering;dependencyinjection;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Text.Templating/CHANGELOG.md b/src/Text.Templating/CHANGELOG.md new file mode 100644 index 0000000..1f093b1 --- /dev/null +++ b/src/Text.Templating/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with base text templating infrastructure. diff --git a/src/Text.Templating/ITextTemplateRenderContext.cs b/src/Text.Templating/ITextTemplateRenderContext.cs new file mode 100644 index 0000000..418fdd8 --- /dev/null +++ b/src/Text.Templating/ITextTemplateRenderContext.cs @@ -0,0 +1,22 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Text.Templating +{ + /// + /// Represents a context used during the generation of text + /// with a when the + /// is called. + /// + public interface ITextTemplateRenderContext + { + /// + /// Gets the which allows to retrieve additional services + /// during the text generation. + /// + IServiceProvider ServiceProvider { get; } + } +} \ No newline at end of file diff --git a/src/Text.Templating/Icon.png b/src/Text.Templating/Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..62a86e5aeef7bcb54d3c5d2b4c298aa08631d48a GIT binary patch literal 41792 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 base class and the `ITextTemplateRenderContext` interface, which can be implemented by concrete template engines. + +Currently only the following text engine implementation are provided in [PosInformatique.Foundations](https://github.com/PosInformatique/PosInformatique.Foundations): +- [PosInformatique.Foundations.Text.Templating.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor/) + +## Install + +You can install the package from [NuGet](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating/): + +```powershell +dotnet add package PosInformatique.Foundations.Text.Templating +``` + +## Features + +- Abstraction to represent a text template with a strongly-typed model: `TextTemplate` +- Asynchronous rendering API through `RenderAsync` +- Pluggable rendering context via `ITextTemplateRenderContext` to access services during template execution +- Engine-agnostic design: can be used with different template engines (Razor, etc.) + +## Usage + +This package only provides the abstraction (base classes and interfaces). +To actually render templates using Razor components, use one of the dedicated implementation package: + +- [PosInformatique.Foundations.Text.Templating.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor/) + +## Links + +- [NuGet package (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating/) +- [NuGet package (Text.Templating.Razor)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/src/Text.Templating/Text.Templating.csproj b/src/Text.Templating/Text.Templating.csproj new file mode 100644 index 0000000..d225dff --- /dev/null +++ b/src/Text.Templating/Text.Templating.csproj @@ -0,0 +1,28 @@ + + + + true + + + Provides foundational abstractions for text templating, including the TextTemplate<TModel> base class + and ITextTemplateRenderContext interface, to be used by templating engine implementations + such as Razor-based text templates. + + text;templating;template;foundation;razor;abstraction;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Text.Templating/TextTemplate.cs b/src/Text.Templating/TextTemplate.cs new file mode 100644 index 0000000..62917d6 --- /dev/null +++ b/src/Text.Templating/TextTemplate.cs @@ -0,0 +1,36 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Text.Templating +{ + /// + /// Base classe which represents a text template. + /// + /// Type of data model to inject to the template to generate the final text. + public abstract class TextTemplate + { + /// + /// Initializes a new instance of the class. + /// + protected TextTemplate() + { + } + + /// + /// Generates the text using the to the current template. The result + /// of the generated text is obtained in the writer. + /// + /// Data model to inject to the template to generate the final text. + /// which contains the generated text. + /// which allows to retrieve additional services for text generation. + /// which allows to cancel the generation of text. + /// A instance which represents the asynchronous operation. + /// Thrown when the argument is . + /// Thrown when the argument is . + /// Thrown when the argument is . + public abstract Task RenderAsync(TModel model, TextWriter output, ITextTemplateRenderContext context, CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/tests/.editorconfig b/tests/.editorconfig index 642b698..5c54ef5 100644 --- a/tests/.editorconfig +++ b/tests/.editorconfig @@ -1,5 +1,10 @@ [*.cs] +#### Code Analysis #### + +# CA1806: Do not ignore method results +dotnet_diagnostic.CA1806.severity = none + #### Sonar Analyzers #### # S1144: Unused private types or members should be removed @@ -18,3 +23,8 @@ dotnet_diagnostic.SA1312.severity = none # SA1600: Elements should be documented dotnet_diagnostic.SA1600.severity = none + +#### Visual Studio #### + +# IDE0017: Simplify object initialization +dotnet_diagnostic.IDE0017.severity = none \ No newline at end of file diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 7c10ae7..5949799 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -40,4 +40,11 @@ + + + + <_Parameter1>DynamicProxyGenAssembly2 + + + diff --git a/tests/Emailing.Azure.Tests/AzureEmailProviderTest.cs b/tests/Emailing.Azure.Tests/AzureEmailProviderTest.cs new file mode 100644 index 0000000..daed9c6 --- /dev/null +++ b/tests/Emailing.Azure.Tests/AzureEmailProviderTest.cs @@ -0,0 +1,59 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Azure.Tests +{ + using PosInformatique.Foundations.EmailAddresses; + + public class AzureEmailProviderTest + { + [Fact] + public void Constructor_WithClientArgumentNull() + { + var act = () => + { + _ = new AzureEmailProvider(null!); + }; + + act.Should().ThrowExactly() + .WithParameterName("client"); + } + + [Fact] + public async Task SendSync() + { + var cancellationToken = new CancellationTokenSource().Token; + + var from = new EmailContact(EmailAddress.Parse("sender@domain.com"), "Ignored"); + var to = new EmailContact(EmailAddress.Parse("recipient@domain.com"), "The recipient"); + + var message = new EmailMessage(from, to, "The subject", "The HTML content"); + + var azureClient = new Mock(MockBehavior.Strict); + azureClient.Setup(c => c.SendAsync(global::Azure.WaitUntil.Started, It.IsAny(), cancellationToken)) + .Callback((global::Azure.WaitUntil _, global::Azure.Communication.Email.EmailMessage m, CancellationToken _) => + { + m.Attachments.Should().BeEmpty(); + m.Content.Html.Should().Be("The HTML content"); + m.Content.PlainText.Should().BeNull(); + m.Content.Subject.Should().Be("The subject"); + m.SenderAddress.Should().Be("sender@domain.com"); + m.Recipients.BCC.Should().BeEmpty(); + m.Recipients.CC.Should().BeEmpty(); + m.Recipients.To.Should().HaveCount(1); + m.Recipients.To[0].Address.Should().Be("recipient@domain.com"); + m.Recipients.To[0].DisplayName.Should().Be("The recipient"); + }) + .ReturnsAsync(new global::Azure.Communication.Email.EmailSendOperation("The id", azureClient.Object)); + + var provider = new AzureEmailProvider(azureClient.Object); + + await provider.SendAsync(message, cancellationToken); + + azureClient.VerifyAll(); + } + } +} \ No newline at end of file diff --git a/tests/Emailing.Azure.Tests/AzureEmailingBuilderExtensionsTest.cs b/tests/Emailing.Azure.Tests/AzureEmailingBuilderExtensionsTest.cs new file mode 100644 index 0000000..85690bc --- /dev/null +++ b/tests/Emailing.Azure.Tests/AzureEmailingBuilderExtensionsTest.cs @@ -0,0 +1,171 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Azure.Tests +{ + using System.Runtime.CompilerServices; + using global::Azure.Communication.Email; + using Microsoft.Extensions.DependencyInjection; + + public class AzureEmailingBuilderExtensionsTest + { + [Fact] + public void UseAzureCommunicationService_WithConnectionString() + { + var serviceCollection = new ServiceCollection(); + var builder = new EmailingBuilder(serviceCollection); + + builder.UseAzureCommunicationService("endpoint=https://my-acs-resource.communication.azure.com/;accesskey=2x3Yz=="); + + var sp = builder.Services.BuildServiceProvider(); + + var provider = sp.GetRequiredService(); + provider.Should().BeOfType(); + + sp.GetRequiredService().Should().BeSameAs(provider); + + var azureClient = sp.GetRequiredService(); + var client = AzureEmailProviderAccessor.GetClientField((AzureEmailProvider)provider); + + client.Should().BeSameAs(azureClient); + } + + [Fact] + public void UseAzureCommunicationService_WithConnectionString_WithClientBuilder() + { + var serviceCollection = new ServiceCollection(); + var builder = new EmailingBuilder(serviceCollection); + + var clientBuilderCalled = false; + + builder.UseAzureCommunicationService("endpoint=https://my-acs-resource.communication.azure.com/;accesskey=2x3Yz==", clientBuilder => + { + clientBuilderCalled = true; + }); + + var sp = builder.Services.BuildServiceProvider(); + + var provider = sp.GetRequiredService(); + provider.Should().BeOfType(); + + sp.GetRequiredService().Should().BeSameAs(provider); + + clientBuilderCalled.Should().BeTrue(); + + var azureClient = sp.GetRequiredService(); + var client = AzureEmailProviderAccessor.GetClientField((AzureEmailProvider)provider); + + client.Should().BeSameAs(azureClient); + } + + [Fact] + public void UseAzureCommunicationService_WithConnectionString_WithNullBuilder() + { + var act = () => + { + AzureEmailingBuilderExtensions.UseAzureCommunicationService(null, (string)default); + }; + + act.Should().ThrowExactly() + .WithParameterName("builder"); + } + + [Fact] + public void UseAzureCommunicationService_WithConnectionString_WithNullConnectionString() + { + var builder = new EmailingBuilder(Mock.Of(MockBehavior.Strict)); + + var act = () => + { + AzureEmailingBuilderExtensions.UseAzureCommunicationService(builder, (string)null); + }; + + act.Should().ThrowExactly() + .WithParameterName("connectionString"); + } + + [Fact] + public void UseAzureCommunicationService_WithUri() + { + var serviceCollection = new ServiceCollection(); + var builder = new EmailingBuilder(serviceCollection); + + builder.UseAzureCommunicationService(new Uri("https://my-acs-resource.communication.azure.com/")); + + var sp = builder.Services.BuildServiceProvider(); + + var provider = sp.GetRequiredService(); + provider.Should().BeOfType(); + + sp.GetRequiredService().Should().BeSameAs(provider); + + var azureClient = sp.GetRequiredService(); + var client = AzureEmailProviderAccessor.GetClientField((AzureEmailProvider)provider); + + client.Should().BeSameAs(azureClient); + } + + [Fact] + public void UseAzureCommunicationService_WithUri_WithClientBuilder() + { + var serviceCollection = new ServiceCollection(); + var builder = new EmailingBuilder(serviceCollection); + + var clientBuilderCalled = false; + + builder.UseAzureCommunicationService(new Uri("https://my-acs-resource.communication.azure.com/"), clientBuilder => + { + clientBuilderCalled = true; + }); + + var sp = builder.Services.BuildServiceProvider(); + + var provider = sp.GetRequiredService(); + provider.Should().BeOfType(); + + sp.GetRequiredService().Should().BeSameAs(provider); + + clientBuilderCalled.Should().BeTrue(); + + var azureClient = sp.GetRequiredService(); + var client = AzureEmailProviderAccessor.GetClientField((AzureEmailProvider)provider); + + client.Should().BeSameAs(azureClient); + } + + [Fact] + public void UseAzureCommunicationService_WithUri_WithNullBuilder() + { + var act = () => + { + AzureEmailingBuilderExtensions.UseAzureCommunicationService(null, (Uri)default); + }; + + act.Should().ThrowExactly() + .WithParameterName("builder"); + } + + [Fact] + public void UseAzureCommunicationService_WithUri_WithNullUri() + { + var builder = new EmailingBuilder(Mock.Of(MockBehavior.Strict)); + + var act = () => + { + AzureEmailingBuilderExtensions.UseAzureCommunicationService(builder, (Uri)null); + }; + + act.Should().ThrowExactly() + .WithParameterName("uri"); + } + + public static class AzureEmailProviderAccessor + { + [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "client")] + public static extern ref EmailClient GetClientField(AzureEmailProvider instance); + } + } +} \ No newline at end of file diff --git a/tests/Emailing.Azure.Tests/Emailing.Azure.Tests.csproj b/tests/Emailing.Azure.Tests/Emailing.Azure.Tests.csproj new file mode 100644 index 0000000..3cce086 --- /dev/null +++ b/tests/Emailing.Azure.Tests/Emailing.Azure.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/Emailing.Tests/EmailContactTest.cs b/tests/Emailing.Tests/EmailContactTest.cs new file mode 100644 index 0000000..b548125 --- /dev/null +++ b/tests/Emailing.Tests/EmailContactTest.cs @@ -0,0 +1,50 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Tests +{ + using PosInformatique.Foundations.EmailAddresses; + + public class EmailContactTest + { + [Fact] + public void Constructor() + { + var email = EmailAddress.Parse("user@domain.com"); + + var contact = new EmailContact(email, "The display name"); + + contact.Email.Should().BeSameAs(email); + contact.DisplayName.Should().Be("The display name"); + } + + [Fact] + public void Constructor_WithNullEmail() + { + var act = () => + { + new EmailContact(null, default); + }; + + act.Should().ThrowExactly() + .WithParameterName("email"); + } + + [Fact] + public void Constructor_WithNullDisplayName() + { + var email = EmailAddress.Parse("user@domain.com"); + + var act = () => + { + new EmailContact(email, null); + }; + + act.Should().ThrowExactly() + .WithParameterName("displayName"); + } + } +} \ No newline at end of file diff --git a/tests/Emailing.Tests/EmailManagerTest.cs b/tests/Emailing.Tests/EmailManagerTest.cs new file mode 100644 index 0000000..72ba007 --- /dev/null +++ b/tests/Emailing.Tests/EmailManagerTest.cs @@ -0,0 +1,193 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Tests +{ + using Microsoft.Extensions.Options; + using PosInformatique.Foundations.EmailAddresses; + using PosInformatique.Foundations.Text.Templating; + + public class EmailManagerTest + { + [Fact] + public void Constructeur_WithNoSenderEmailAddress() + { + var options = new EmailingOptions(); + + options.SenderEmailAddress = null; + + Action act = () => + { + new EmailManager(Options.Create(options), default, default); + }; + + act.Should().ThrowExactly() + .WithMessage("Sender email address is required. (Parameter 'options')") + .WithParameterName("options"); + } + + [Fact] + public void Create() + { + var identifier = EmailTemplateIdentifier.Create(); + + var template = new EmailTemplate(Mock.Of>(MockBehavior.Strict), Mock.Of>(MockBehavior.Strict)); + + var options = new EmailingOptions(); + options.RegisterTemplate(identifier, template); + options.SenderEmailAddress = EmailAddress.Parse("sender@domain.com"); + + var manager = new EmailManager(Options.Create(options), default, default); + + var email = manager.Create(identifier); + + email.Recipients.Should().BeEmpty(); + email.Template.Should().BeSameAs(template); + } + + [Fact] + public void Create_WithNoRegisteredTemplate() + { + var identifier = EmailTemplateIdentifier.Create(); + + var options = new EmailingOptions(); + options.SenderEmailAddress = EmailAddress.Parse("sender@domain.com"); + + var manager = new EmailManager(Options.Create(options), default, default); + + manager.Invoking(m => m.Create(identifier)) + .Should().ThrowExactly() + .WithMessage("Unable to find a template for the specified identifier. (Parameter 'identifier')") + .WithParameterName("identifier"); + } + + [Fact] + public void Create_WithNullIdentifier() + { + var options = new EmailingOptions(); + options.SenderEmailAddress = EmailAddress.Parse("sender@domain.com"); + + var manager = new EmailManager(Options.Create(options), default, default); + + manager.Invoking(m => m.Create(null)) + .Should().ThrowExactly() + .WithParameterName("identifier"); + } + + [Fact] + public async Task SendAsync() + { + var cancellationToken = new CancellationTokenSource().Token; + + var serviceProvider = Mock.Of(MockBehavior.Strict); + + var model1 = new Model(); + var model2 = new Model(); + + var subject = new Mock>(MockBehavior.Strict); + subject.Setup(s => s.RenderAsync(model1, It.IsAny(), It.IsAny(), cancellationToken)) + .Callback((Model _, TextWriter writer, ITextTemplateRenderContext context, CancellationToken _) => + { + context.ServiceProvider.Should().BeSameAs(serviceProvider); + + writer.Write("Subject 1"); + }) + .Returns(Task.CompletedTask); + subject.Setup(s => s.RenderAsync(model2, It.IsAny(), It.IsAny(), cancellationToken)) + .Callback((Model _, TextWriter writer, ITextTemplateRenderContext context, CancellationToken _) => + { + context.ServiceProvider.Should().BeSameAs(serviceProvider); + + writer.Write("Subject 2"); + }) + .Returns(Task.CompletedTask); + + var htmlBody = new Mock>(MockBehavior.Strict); + htmlBody.Setup(s => s.RenderAsync(model1, It.IsAny(), It.IsAny(), cancellationToken)) + .Callback((Model _, TextWriter writer, ITextTemplateRenderContext context, CancellationToken _) => + { + context.ServiceProvider.Should().BeSameAs(serviceProvider); + + writer.Write("HTML Content 1"); + }) + .Returns(Task.CompletedTask); + htmlBody.Setup(s => s.RenderAsync(model2, It.IsAny(), It.IsAny(), cancellationToken)) + .Callback((Model _, TextWriter writer, ITextTemplateRenderContext context, CancellationToken _) => + { + context.ServiceProvider.Should().BeSameAs(serviceProvider); + + writer.Write("HTML Content 2"); + }) + .Returns(Task.CompletedTask); + + var template = new EmailTemplate(subject.Object, htmlBody.Object); + + var emailAddressRecipient1 = EmailAddress.Parse("email1@domain.com"); + var emailAddressRecipient2 = EmailAddress.Parse("email2@domain.com"); + + var email = new Email(template) + { + Recipients = + { + new EmailRecipient(emailAddressRecipient1, "The display name 1", model1), + new EmailRecipient(emailAddressRecipient2, "The display name 2", model2), + }, + }; + + var sender = EmailAddress.Parse("sender@domain.com"); + + var options = new EmailingOptions(); + options.SenderEmailAddress = sender; + + var provider = new Mock(MockBehavior.Strict); + provider.Setup(p => p.SendAsync(It.Is(m => m.To.Email == emailAddressRecipient1), cancellationToken)) + .Callback((EmailMessage m, CancellationToken _) => + { + m.From.Email.Should().BeSameAs(sender); + m.From.DisplayName.Should().BeEmpty(); + m.Subject.Should().Be("Subject 1"); + m.HtmlContent.Should().Be("HTML Content 1"); + m.To.DisplayName.Should().Be("The display name 1"); + }) + .Returns(Task.CompletedTask); + provider.Setup(p => p.SendAsync(It.Is(m => m.To.Email == emailAddressRecipient2), cancellationToken)) + .Callback((EmailMessage m, CancellationToken _) => + { + m.From.Email.Should().BeSameAs(sender); + m.From.DisplayName.Should().BeEmpty(); + m.Subject.Should().Be("Subject 2"); + m.HtmlContent.Should().Be("HTML Content 2"); + m.To.DisplayName.Should().Be("The display name 2"); + }) + .Returns(Task.CompletedTask); + + var manager = new EmailManager(Options.Create(options), provider.Object, serviceProvider); + + await manager.SendAsync(email, cancellationToken); + + htmlBody.VerifyAll(); + provider.VerifyAll(); + subject.VerifyAll(); + } + + [Fact] + public void SendAsync_WithNullIdentifier() + { + var options = new EmailingOptions(); + options.SenderEmailAddress = EmailAddress.Parse("sender@domain.com"); + + var manager = new EmailManager(Options.Create(options), default, default); + + manager.Invoking(m => m.SendAsync(null, default)) + .Should().ThrowExactlyAsync() + .WithParameterName("template"); + } + + internal sealed class Model : EmailModel + { + } + } +} \ No newline at end of file diff --git a/tests/Emailing.Tests/EmailMessageTest.cs b/tests/Emailing.Tests/EmailMessageTest.cs new file mode 100644 index 0000000..5c7a209 --- /dev/null +++ b/tests/Emailing.Tests/EmailMessageTest.cs @@ -0,0 +1,87 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Tests +{ + using PosInformatique.Foundations.EmailAddresses; + + public class EmailMessageTest + { + [Fact] + public void Constructor() + { + var from = new EmailContact(EmailAddress.Parse("from@domain.com"), "From"); + var to = new EmailContact(EmailAddress.Parse("to@domain.com"), "To"); + + var emailMessage = new EmailMessage( + from, + to, + "The subject", + "HTML content"); + + emailMessage.From.Should().Be(from); + emailMessage.HtmlContent.Should().Be("HTML content"); + emailMessage.Subject.Should().Be("The subject"); + emailMessage.To.Should().Be(to); + } + + [Fact] + public void Constructor_WithNullFrom() + { + var act = () => + { + new EmailMessage(null, default, default, default); + }; + + act.Should().ThrowExactly() + .WithParameterName("from"); + } + + [Fact] + public void Constructor_WithNullTo() + { + var from = new EmailContact(EmailAddress.Parse("from@domain.com"), "From"); + + var act = () => + { + new EmailMessage(from, null, default, default); + }; + + act.Should().ThrowExactly() + .WithParameterName("to"); + } + + [Fact] + public void Constructor_WithNullSubject() + { + var from = new EmailContact(EmailAddress.Parse("from@domain.com"), "From"); + var to = new EmailContact(EmailAddress.Parse("to@domain.com"), "To"); + + var act = () => + { + new EmailMessage(from, to, null, default); + }; + + act.Should().ThrowExactly() + .WithParameterName("subject"); + } + + [Fact] + public void Constructor_WithNullHtmlContent() + { + var from = new EmailContact(EmailAddress.Parse("from@domain.com"), "From"); + var to = new EmailContact(EmailAddress.Parse("to@domain.com"), "To"); + + var act = () => + { + new EmailMessage(from, to, "The subject", null); + }; + + act.Should().ThrowExactly() + .WithParameterName("htmlContent"); + } + } +} \ No newline at end of file diff --git a/tests/Emailing.Tests/EmailRecipientCollectionTest.cs b/tests/Emailing.Tests/EmailRecipientCollectionTest.cs new file mode 100644 index 0000000..ce58ec4 --- /dev/null +++ b/tests/Emailing.Tests/EmailRecipientCollectionTest.cs @@ -0,0 +1,75 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Tests +{ + using PosInformatique.Foundations.EmailAddresses; + + public class EmailRecipientCollectionTest + { + [Fact] + public void Constructor() + { + var collection = new EmailRecipientCollection(); + + collection.Should().BeEmpty(); + } + + [Fact] + public void Add() + { + var model = new Model(); + + var collection = new EmailRecipientCollection(); + + var result = collection.Add(EmailAddress.Parse("name@domain.com"), "The display name", model); + + collection.Should().Equal(result); + + result.Address.Should().Be(EmailAddress.Parse("name@domain.com")); + result.DisplayName.Should().Be("The display name"); + result.Model.Should().BeSameAs(model); + } + + [Fact] + public void Add_WithNullAddress() + { + var collection = new EmailRecipientCollection(); + + collection.Invoking(c => c.Add(null, default, default)) + .Should().ThrowExactly() + .WithParameterName("address"); + } + + [Fact] + public void Add_WithNullDisplayName() + { + var address = EmailAddress.Parse("email@domain.com"); + + var collection = new EmailRecipientCollection(); + + collection.Invoking(c => c.Add(address, null, default)) + .Should().ThrowExactly() + .WithParameterName("displayName"); + } + + [Fact] + public void Add_WithNullModel() + { + var address = EmailAddress.Parse("email@domain.com"); + + var collection = new EmailRecipientCollection(); + + collection.Invoking(c => c.Add(address, "The display name", null)) + .Should().ThrowExactly() + .WithParameterName("model"); + } + + private sealed class Model : EmailModel + { + } + } +} \ No newline at end of file diff --git a/tests/Emailing.Tests/EmailRecipientTest.cs b/tests/Emailing.Tests/EmailRecipientTest.cs new file mode 100644 index 0000000..26d3872 --- /dev/null +++ b/tests/Emailing.Tests/EmailRecipientTest.cs @@ -0,0 +1,70 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Tests +{ + using PosInformatique.Foundations.EmailAddresses; + + public class EmailRecipientTest + { + [Fact] + public void Constructor() + { + var addressEmail = EmailAddress.Parse("email@domain.com"); + var model = new Model(); + + var emailRecipient = new EmailRecipient(addressEmail, "The display name", model); + + emailRecipient.Address.Should().BeSameAs(addressEmail); + emailRecipient.Model.Should().BeSameAs(model); + emailRecipient.DisplayName.Should().Be("The display name"); + } + + [Fact] + public void Constructor_WithNullAddress() + { + var act = () => + { + new EmailRecipient(null, default, default); + }; + + act.Should().ThrowExactly() + .WithParameterName("address"); + } + + [Fact] + public void Constructor_WithNullDisplayName() + { + var address = EmailAddress.Parse("email@domain.com"); + + var act = () => + { + new EmailRecipient(address, null, default); + }; + + act.Should().ThrowExactly() + .WithParameterName("displayName"); + } + + [Fact] + public void Constructor_WithNullModel() + { + var address = EmailAddress.Parse("email@domain.com"); + + var act = () => + { + new EmailRecipient(address, "The display name", null); + }; + + act.Should().ThrowExactly() + .WithParameterName("model"); + } + + private class Model : EmailModel + { + } + } +} \ No newline at end of file diff --git a/tests/Emailing.Tests/EmailTemplateIdentifierTest.cs b/tests/Emailing.Tests/EmailTemplateIdentifierTest.cs new file mode 100644 index 0000000..ecec028 --- /dev/null +++ b/tests/Emailing.Tests/EmailTemplateIdentifierTest.cs @@ -0,0 +1,24 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Tests +{ + public class EmailTemplateIdentifierTest + { + [Fact] + public void Constructor() + { + var identifier = EmailTemplateIdentifier.Create(); + var otherIdentifier = EmailTemplateIdentifier.Create(); + + identifier.Should().NotBeSameAs(otherIdentifier); + } + + private sealed class Model : EmailModel + { + } + } +} \ No newline at end of file diff --git a/tests/Emailing.Tests/EmailTemplateTest.cs b/tests/Emailing.Tests/EmailTemplateTest.cs new file mode 100644 index 0000000..26882e8 --- /dev/null +++ b/tests/Emailing.Tests/EmailTemplateTest.cs @@ -0,0 +1,55 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Tests +{ + using PosInformatique.Foundations.Text.Templating; + + public class EmailTemplateTest + { + [Fact] + public void Constructor() + { + var subject = Mock.Of>(MockBehavior.Strict); + var htmlBody = Mock.Of>(MockBehavior.Strict); + + var template = new EmailTemplate(subject, htmlBody); + + template.HtmlBody.Should().BeSameAs(htmlBody); + template.Subject.Should().BeSameAs(subject); + } + + [Fact] + public void Constructor_WithNullSubject() + { + var act = () => + { + new EmailTemplate(null, default); + }; + + act.Should().ThrowExactly() + .WithParameterName("subject"); + } + + [Fact] + public void Constructor_WithNullHtmlBody() + { + var subject = Mock.Of>(MockBehavior.Strict); + + var act = () => + { + new EmailTemplate(subject, null); + }; + + act.Should().ThrowExactly() + .WithParameterName("htmlBody"); + } + + internal sealed class Model : EmailModel + { + } + } +} \ No newline at end of file diff --git a/tests/Emailing.Tests/EmailTest.cs b/tests/Emailing.Tests/EmailTest.cs new file mode 100644 index 0000000..1f8029c --- /dev/null +++ b/tests/Emailing.Tests/EmailTest.cs @@ -0,0 +1,43 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Tests +{ + using PosInformatique.Foundations.Text.Templating; + + public class EmailTest + { + [Fact] + public void Constructor() + { + var subject = Mock.Of>(MockBehavior.Strict); + var htmlContent = Mock.Of>(MockBehavior.Strict); + + var template = new EmailTemplate(subject, htmlContent); + + var email = new Email(template); + + email.Recipients.Should().BeEmpty(); + email.Template.Should().BeSameAs(template); + } + + [Fact] + public void Constructor_WithNullTemplate() + { + var act = () => + { + new Email(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("template"); + } + + internal sealed class Model : EmailModel + { + } + } +} \ No newline at end of file diff --git a/tests/Emailing.Tests/Emailing.Tests.csproj b/tests/Emailing.Tests/Emailing.Tests.csproj new file mode 100644 index 0000000..516d7c6 --- /dev/null +++ b/tests/Emailing.Tests/Emailing.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/Emailing.Tests/EmailingOptionsTest.cs b/tests/Emailing.Tests/EmailingOptionsTest.cs new file mode 100644 index 0000000..6b4d52e --- /dev/null +++ b/tests/Emailing.Tests/EmailingOptionsTest.cs @@ -0,0 +1,99 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Tests +{ + using PosInformatique.Foundations.EmailAddresses; + using PosInformatique.Foundations.Text.Templating; + + public class EmailingOptionsTest + { + [Fact] + public void Constructor() + { + var options = new EmailingOptions(); + + options.SenderEmailAddress.Should().BeNull(); + } + + [Fact] + public void SenderEmailAddress_ValueChanged() + { + var options = new EmailingOptions(); + + options.SenderEmailAddress = EmailAddress.Parse("user@domain.com"); + + options.SenderEmailAddress.Should().Be(EmailAddress.Parse("user@domain.com")); + } + + [Fact] + public void RegisterTemplate() + { + var identifier = EmailTemplateIdentifier.Create(); + var template = new EmailTemplate(Mock.Of>(MockBehavior.Strict), Mock.Of>(MockBehavior.Strict)); + + var options = new EmailingOptions(); + + options.RegisterTemplate(identifier, template); + + options.GetTemplate(identifier).Should().BeSameAs(template); + } + + [Fact] + public void RegisterTemplate_NullIdentifier() + { + var options = new EmailingOptions(); + + options.Invoking(o => o.RegisterTemplate(null, default)) + .Should().ThrowExactly() + .WithParameterName("identifier"); + } + + [Fact] + public void RegisterTemplate_AlreadyRegistered() + { + var identifier = EmailTemplateIdentifier.Create(); + + var template = new EmailTemplate(Mock.Of>(MockBehavior.Strict), Mock.Of>(MockBehavior.Strict)); + var otherTemplate = new EmailTemplate(Mock.Of>(MockBehavior.Strict), Mock.Of>(MockBehavior.Strict)); + + var options = new EmailingOptions(); + + options.RegisterTemplate(identifier, template); + + options.Invoking(opt => opt.RegisterTemplate(identifier, otherTemplate)) + .Should().ThrowExactly() + .WithMessage("An e-mail template with the same identifier has already been registered. (Parameter 'identifier')") + .WithParameterName("identifier"); + } + + [Fact] + public void RegisterTemplate_NullTemplate() + { + var identifier = EmailTemplateIdentifier.Create(); + + var options = new EmailingOptions(); + + options.Invoking(o => o.RegisterTemplate(identifier, null)) + .Should().ThrowExactly() + .WithParameterName("template"); + } + + [Fact] + public void GetTemplate_NotRegistered() + { + var identifier = EmailTemplateIdentifier.Create(); + + var options = new EmailingOptions(); + + options.GetTemplate(identifier).Should().BeNull(); + } + + public sealed class Model : EmailModel + { + } + } +} \ No newline at end of file diff --git a/tests/Emailing.Tests/EmailingServiceCollectionExtensionsTest.cs b/tests/Emailing.Tests/EmailingServiceCollectionExtensionsTest.cs new file mode 100644 index 0000000..4295081 --- /dev/null +++ b/tests/Emailing.Tests/EmailingServiceCollectionExtensionsTest.cs @@ -0,0 +1,66 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.Extensions.DependencyInjection.Tests +{ + using Microsoft.Extensions.Options; + using PosInformatique.Foundations.EmailAddresses; + using PosInformatique.Foundations.Emailing; + + public class EmailingServiceCollectionExtensionsTest + { + [Fact] + public void AddEmailing() + { + var provider = Mock.Of(MockBehavior.Strict); + + var services = new ServiceCollection(); + services.AddSingleton(provider); + + EmailingServiceCollectionExtensions.AddEmailing( + services, + opt => + { + opt.SenderEmailAddress = EmailAddress.Parse("sender@domain.com"); + }) + .Services.Should().BeSameAs(services); + + var sp = services.BuildServiceProvider(); + + var manager = sp.GetRequiredService(); + + manager.Should().BeOfType(); + sp.GetRequiredService().Should().BeSameAs(manager); + + var options = sp.GetRequiredService>(); + options.Value.SenderEmailAddress.Should().Be(EmailAddress.Parse("sender@domain.com")); + } + + [Fact] + public void AddEmailing_WithNullServices() + { + var act = () => + { + EmailingServiceCollectionExtensions.AddEmailing(null, default); + }; + + act.Should().ThrowExactly() + .WithParameterName("services"); + } + + [Fact] + public void AddEmailing_WithOptions() + { + var act = () => + { + EmailingServiceCollectionExtensions.AddEmailing(Mock.Of(MockBehavior.Strict), default); + }; + + act.Should().ThrowExactly() + .WithParameterName("options"); + } + } +} \ No newline at end of file diff --git a/tests/Text.Templating.Razor.Tests/ComponentTest.razor b/tests/Text.Templating.Razor.Tests/ComponentTest.razor new file mode 100644 index 0000000..9c8ef4f --- /dev/null +++ b/tests/Text.Templating.Razor.Tests/ComponentTest.razor @@ -0,0 +1,4 @@ +@namespace PosInformatique.Foundations.Text.Templating.Razor.Tests + +The model name : @this.Model.Name +The service data : @this.Service.GetData() \ No newline at end of file diff --git a/tests/Text.Templating.Razor.Tests/ComponentTest.razor.cs b/tests/Text.Templating.Razor.Tests/ComponentTest.razor.cs new file mode 100644 index 0000000..2e97447 --- /dev/null +++ b/tests/Text.Templating.Razor.Tests/ComponentTest.razor.cs @@ -0,0 +1,19 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Text.Templating.Razor.Tests +{ + using Microsoft.AspNetCore.Components; + + public partial class ComponentTest : ComponentBase + { + [Inject] + public RazorTextTemplateRendererTest.IService Service { get; set; } + + [Parameter] + public RazorTextTemplateRendererTest.ModelTest Model { get; set; } + } +} diff --git a/tests/Text.Templating.Razor.Tests/RazorTextTemplateRendererTest.cs b/tests/Text.Templating.Razor.Tests/RazorTextTemplateRendererTest.cs new file mode 100644 index 0000000..985ccac --- /dev/null +++ b/tests/Text.Templating.Razor.Tests/RazorTextTemplateRendererTest.cs @@ -0,0 +1,58 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Text.Templating.Razor.Tests +{ + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + + public class RazorTextTemplateRendererTest + { + public interface IService + { + string GetData(); + } + + [Fact] + public async Task RenderAsync() + { + var cancellationToken = new CancellationTokenSource().Token; + + var serviceCollection = new ServiceCollection(); + serviceCollection.AddLogging(); + serviceCollection.AddSingleton(); + + var serviceProvider = serviceCollection.BuildServiceProvider(); + + var renderer = new RazorTextTemplateRenderer( + serviceProvider, + serviceProvider.GetRequiredService()); + + var model = new ModelTest() + { + Name = "The name", + }; + + var output = new StringWriter(); + + await renderer.RenderAsync(typeof(ComponentTest), model, output, cancellationToken); + + output.ToString().Should().Be(@" +The model name : The name +The service data : The data !"); + } + + public class ModelTest + { + public string Name { get; set; } + } + + public class Service : IService + { + public string GetData() => "The data !"; + } + } +} \ No newline at end of file diff --git a/tests/Text.Templating.Razor.Tests/RazorTextTemplateTest.cs b/tests/Text.Templating.Razor.Tests/RazorTextTemplateTest.cs new file mode 100644 index 0000000..ee6e59d --- /dev/null +++ b/tests/Text.Templating.Razor.Tests/RazorTextTemplateTest.cs @@ -0,0 +1,98 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Text.Templating.Razor.Tests +{ + public class RazorTextTemplateTest + { + [Fact] + public void Constructor_WithComponentTypeArgumentNull() + { + var act = () => + { + new RazorTextTemplate(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("componentType"); + } + + [Fact] + public async Task RenderAsync() + { + var cancellationToken = new CancellationTokenSource().Token; + + var model = new Model(); + + var renderer = new Mock(MockBehavior.Strict); + renderer.Setup(r => r.RenderAsync(typeof(string), model, It.IsAny(), cancellationToken)) + .Callback((Type _, object _, TextWriter writer, CancellationToken _) => + { + writer.Write("The output"); + }) + .Returns(Task.CompletedTask); + + var serviceProvider = new Mock(MockBehavior.Strict); + serviceProvider.Setup(sp => sp.GetService(typeof(IRazorTextTemplateRenderer))) + .Returns(renderer.Object); + + var context = new Mock(MockBehavior.Strict); + context.Setup(c => c.ServiceProvider) + .Returns(serviceProvider.Object); + + var output = new StringWriter(); + + var template = new RazorTextTemplate(typeof(string)); + + await template.RenderAsync(model, output, context.Object, cancellationToken); + + output.ToString().Should().Be("The output"); + + context.VerifyAll(); + renderer.VerifyAll(); + serviceProvider.VerifyAll(); + } + + [Fact] + public void RenderAsync_WithModelArgumentNull() + { + var template = new RazorTextTemplate(typeof(string)); + + template.Invoking(t => t.RenderAsync(null, default, default, default)) + .Should().ThrowExactlyAsync() + .WithParameterName("model"); + } + + [Fact] + public void RenderAsync_WithOutputArgumentNull() + { + var model = new Model(); + + var template = new RazorTextTemplate(typeof(string)); + + template.Invoking(t => t.RenderAsync(model, default, default, default)) + .Should().ThrowExactlyAsync() + .WithParameterName("output"); + } + + [Fact] + public void RenderAsync_WithContextArgumentNull() + { + var model = new Model(); + var output = new StringWriter(); + + var template = new RazorTextTemplate(typeof(string)); + + template.Invoking(t => t.RenderAsync(model, output, null, default)) + .Should().ThrowExactlyAsync() + .WithParameterName("context"); + } + + private sealed class Model + { + } + } +} \ No newline at end of file diff --git a/tests/Text.Templating.Razor.Tests/RazorTextTemplatingServiceCollectionExtensionsTest.cs b/tests/Text.Templating.Razor.Tests/RazorTextTemplatingServiceCollectionExtensionsTest.cs new file mode 100644 index 0000000..dc49a68 --- /dev/null +++ b/tests/Text.Templating.Razor.Tests/RazorTextTemplatingServiceCollectionExtensionsTest.cs @@ -0,0 +1,39 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.Extensions.DependencyInjection.Tests +{ + using Microsoft.Extensions.Logging; + using PosInformatique.Foundations.Text.Templating.Razor; + + public class RazorTextTemplatingServiceCollectionExtensionsTest + { + [Fact] + public void AddRazorTextTemplating() + { + var serviceCollection = new ServiceCollection(); + + serviceCollection.AddRazorTextTemplating().Should().BeSameAs(serviceCollection); + + var sp = serviceCollection.BuildServiceProvider(); + + sp.GetRequiredService().Should().BeOfType(); + sp.GetRequiredService>().Should().NotBeNull(); + } + + [Fact] + public void AddRazorTextTemplating_WithServicesArgumentNull() + { + var act = () => + { + RazorTextTemplatingServiceCollectionExtensions.AddRazorTextTemplating(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("services"); + } + } +} \ No newline at end of file diff --git a/tests/Text.Templating.Razor.Tests/Text.Templating.Razor.Tests.csproj b/tests/Text.Templating.Razor.Tests/Text.Templating.Razor.Tests.csproj new file mode 100644 index 0000000..edea75c --- /dev/null +++ b/tests/Text.Templating.Razor.Tests/Text.Templating.Razor.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + From 15db20ca8ad1bd16b29a50e723048c7e2f05e8be Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sat, 15 Nov 2025 16:21:21 +0100 Subject: [PATCH 40/73] Fix warnings. --- .../IRazorTextTemplateRenderer.cs | 11 +++++++++++ stylecop.json | 2 +- tests/.editorconfig | 6 ++++++ .../ComponentTest.razor.cs | 2 +- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Text.Templating.Razor/IRazorTextTemplateRenderer.cs b/src/Text.Templating.Razor/IRazorTextTemplateRenderer.cs index d217678..016a229 100644 --- a/src/Text.Templating.Razor/IRazorTextTemplateRenderer.cs +++ b/src/Text.Templating.Razor/IRazorTextTemplateRenderer.cs @@ -6,8 +6,19 @@ namespace PosInformatique.Foundations.Text.Templating.Razor { + /// + /// Used internaly by the to render a Razor component to a text output. + /// internal interface IRazorTextTemplateRenderer { + /// + /// Generates the text output of a Razor component. + /// + /// Type of the Razor component which will be use to generate the text. + /// Model to inject in the Razor component. + /// Output where the text will be generated. + /// used to cancel the generation process. + /// An instance of the which represents the asynchronous operation. Task RenderAsync(Type componentType, object? model, TextWriter output, CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/stylecop.json b/stylecop.json index b747ad1..fca1ad8 100644 --- a/stylecop.json +++ b/stylecop.json @@ -1,4 +1,4 @@ -{ +{ "settings": { "documentationRules": { "companyName": "P.O.S Informatique", diff --git a/tests/.editorconfig b/tests/.editorconfig index 5c54ef5..96ca6d5 100644 --- a/tests/.editorconfig +++ b/tests/.editorconfig @@ -10,6 +10,9 @@ dotnet_diagnostic.CA1806.severity = none # S1144: Unused private types or members should be removed dotnet_diagnostic.S1144.severity = none +# S2094: Classes should not be empty +dotnet_diagnostic.S2094.severity = none + # S3459: Unassigned members should be removed dotnet_diagnostic.S3459.severity = none @@ -24,6 +27,9 @@ dotnet_diagnostic.SA1312.severity = none # SA1600: Elements should be documented dotnet_diagnostic.SA1600.severity = none +# SA1601: Partial elements should be documented +dotnet_diagnostic.SA1601.severity = none + #### Visual Studio #### # IDE0017: Simplify object initialization diff --git a/tests/Text.Templating.Razor.Tests/ComponentTest.razor.cs b/tests/Text.Templating.Razor.Tests/ComponentTest.razor.cs index 2e97447..904f357 100644 --- a/tests/Text.Templating.Razor.Tests/ComponentTest.razor.cs +++ b/tests/Text.Templating.Razor.Tests/ComponentTest.razor.cs @@ -16,4 +16,4 @@ public partial class ComponentTest : ComponentBase [Parameter] public RazorTextTemplateRendererTest.ModelTest Model { get; set; } } -} +} \ No newline at end of file From e085f037af444269d94d3262272a6a089099efdc Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sat, 15 Nov 2025 16:47:27 +0100 Subject: [PATCH 41/73] Add the Emailing.Templates.Razor package. --- PosInformatique.Foundations.slnx | 5 + README.md | 1 + src/Emailing.Templates.Razor/CHANGELOG.md | 2 + .../Emailing.Templates.Razor.csproj | 28 +++ src/Emailing.Templates.Razor/README.md | 201 ++++++++++++++++++ .../RazorEmailTemplate.cs | 33 +++ .../RazorEmailTemplateBody.cs | 31 +++ .../RazorEmailTemplateSubject.cs | 31 +++ src/Text.Templating.Razor/README.md | 1 + .../Emailing.Templates.Razor.Tests.csproj | 7 + .../RazorEmailTemplateTest.cs | 34 +++ 11 files changed, 374 insertions(+) create mode 100644 src/Emailing.Templates.Razor/CHANGELOG.md create mode 100644 src/Emailing.Templates.Razor/Emailing.Templates.Razor.csproj create mode 100644 src/Emailing.Templates.Razor/README.md create mode 100644 src/Emailing.Templates.Razor/RazorEmailTemplate.cs create mode 100644 src/Emailing.Templates.Razor/RazorEmailTemplateBody.cs create mode 100644 src/Emailing.Templates.Razor/RazorEmailTemplateSubject.cs create mode 100644 tests/Emailing.Templates.Razor.Tests/Emailing.Templates.Razor.Tests.csproj create mode 100644 tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateTest.cs diff --git a/PosInformatique.Foundations.slnx b/PosInformatique.Foundations.slnx index ee1c5d0..61bbb34 100644 --- a/PosInformatique.Foundations.slnx +++ b/PosInformatique.Foundations.slnx @@ -45,6 +45,11 @@ + + + + + diff --git a/README.md b/README.md index 7792a4d..8de8fb5 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ You can install any package using the .NET CLI or NuGet Package Manager. |PosInformatique.Foundations.EmailAddresses.Json icon|[**PosInformatique.Foundations.EmailAddresses.Json**](./src/EmailAddresses.Json/README.md) | `System.Text.Json` converter for the `EmailAddress` value object, enabling seamless serialization and deserialization of RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json) | |PosInformatique.Foundations.Emailing icon|[**PosInformatique.Foundations.Emailing**](./src/Emailing/README.md) | Template-based emailing infrastructure for .NET that lets you register strongly-typed email templates, create emails from models, and send them through pluggable providers. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Emailing)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing) | |PosInformatique.Foundations.Emailing.Azure icon|[**PosInformatique.Foundations.Emailing.Azure**](./src/Emailing.Azure/README.md) | `IEmailProvider` implementation for `PosInformatique.Foundations.Emailing` using **Azure Communication Service**. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Emailing.Azure)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure) | +|PosInformatique.Foundations.Emailing.Templates.Razor icon|[**PosInformatique.Foundations.Emailing.Templates.Razor**](./src/Emailing.Templates.Razor/README.md) | Helpers to build EmailTemplate instances from Razor components for subject and HTML body, supporting strongly-typed models and reusable layouts. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Emailing.Templates.Razor)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Templates.Razor) | |PosInformatique.Foundations.MediaTypes icon|[**PosInformatique.Foundations.MediaTypes**](./src/MediaTypes/README.md) | Immutable `MimeType` value object with well-known media types and helpers to map between media types and file extensions. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes) | |PosInformatique.Foundations.MediaTypes.EntityFramework icon|[**PosInformatique.Foundations.MediaTypes.EntityFramework**](./src/MediaTypes.EntityFramework/README.md) | Entity Framework Core integration for the `MimeType` value object, including property configuration and value converter for seamless database persistence. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.EntityFramework) | |PosInformatique.Foundations.MediaTypes.Json icon|[**PosInformatique.Foundations.MediaTypes.Json**](./src/MediaTypes.Json/README.md) | `System.Text.Json` converter for the `MimeType` value object, enabling seamless serialization and deserialization of MIME types within JSON documents. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.Json) | diff --git a/src/Emailing.Templates.Razor/CHANGELOG.md b/src/Emailing.Templates.Razor/CHANGELOG.md new file mode 100644 index 0000000..b6d4aad --- /dev/null +++ b/src/Emailing.Templates.Razor/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with Emailing template based on Razor view. diff --git a/src/Emailing.Templates.Razor/Emailing.Templates.Razor.csproj b/src/Emailing.Templates.Razor/Emailing.Templates.Razor.csproj new file mode 100644 index 0000000..d54f45f --- /dev/null +++ b/src/Emailing.Templates.Razor/Emailing.Templates.Razor.csproj @@ -0,0 +1,28 @@ + + + + true + + + Provides helpers to create EmailTemplate instances using Razor components for subject and HTML body. + Built on top of PosInformatique.Foundations.Text.Templating.Razor, it supports strongly-typed models and Razor layout features for reusable email designs. + + email;emailing;razor;blazor;templating;component;layout;dotnet;posinformatique;emailtemplate + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + diff --git a/src/Emailing.Templates.Razor/README.md b/src/Emailing.Templates.Razor/README.md new file mode 100644 index 0000000..686aa1b --- /dev/null +++ b/src/Emailing.Templates.Razor/README.md @@ -0,0 +1,201 @@ +# PosInformatique.Foundations.Emailing.Templates.Razor + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.Emailing.Templates.Razor)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Templates.Razor/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.Emailing.Templates.Razor)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Templates.Razor/) + +## Introduction + +[PosInformatique.Foundations.Emailing.Templates.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Templates.Razor/) +provides helpers to create `EmailTemplate` instances using Razor components as text templates for the subject and HTML body. + +It is built on top of: + +- [PosInformatique.Foundations.Emailing](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Templates.Razor/) +- [PosInformatique.Foundations.Text.Templates.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Templates.Razor/) + +This allows you to design your email content with Blazor-style Razor components, benefiting from layout reuse, strongly-typed models, and familiar Razor syntax. + +## Install + +You can install the package from [NuGet](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Templates.Razor/): + +```powershell +dotnet add package PosInformatique.Foundations.Emailing.Templates.Razor +``` + +You also need a Blazor-compatible environment for compiling/executing Razor components. + +## Features + +- `RazorEmailTemplate.Create()` to build `EmailTemplate` from Razor components. +- Base classes for Razor components: + - `RazorEmailTemplateSubject` for email subject. + - `RazorEmailTemplateBody` for email HTML body. +- Strongly-typed model support via the `Model` parameter. +- Supports Razor layout features for the email body (reuse consistent layout across multiple templates). + +## Creating Razor components for subject and body + +### 1. Define the email model + +Example model used by the templates: + +```csharp +using PosInformatique.Foundations.Emailing; + +public sealed class InvitationEmailTemplateModel : EmailModel +{ + public string FirstName { get; set; } = string.Empty; + public string InvitationLink { get; set; } = string.Empty; +} +``` + +### 2. Subject component + +Create a Razor component for the subject that inherits from `RazorEmailTemplateSubject` and uses the `Model` parameter. + +`InvitationEmailSubject.razor`: + +```razor +@using PosInformatique.Foundations.Emailing +@using PosInformatique.Foundations.Emailing.Templates.Razor +@inherits RazorEmailTemplateSubject + +Invitation for @Model.FirstName +``` + +This component: + +- Renders a single line of text. +- Uses the strongly-typed `Model` to build the subject. + +### 3. Body component with layout + +You can define a layout component that centralizes common HTML structure (header, footer, styles, etc.), +then reuse it across different email bodies. + +`EmailLayout.razor` (layout component): + +```razor +@inherits LayoutComponentBase + + + + + + @Title + + + + + + +``` + +Now create the specific body component for your invitation email. + +`InvitationEmailBody.razor`: + +```razor +@using PosInformatique.Foundations.Emailing +@using PosInformatique.Foundations.Emailing.Templates.Razor +@inherits RazorEmailTemplateBody +@layout EmailLayout + +@{ + Title = $"Invitation for {Model.FirstName}"; +} + +

Hello @Model.FirstName,

+ +

+ You have been invited to join our platform. + Please click the link below to accept your invitation: +

+ +

+ @Model.InvitationLink +

+ +

+ If you did not expect this email, you can safely ignore it. +

+``` + +Key points: + +- The body component inherits from `RazorEmailTemplateBody`. +- It uses `@layout EmailLayout` to reuse the common HTML structure. +- It uses the `Model` property to inject data into the HTML. + +## Creating an EmailTemplate from Razor components + +Use the static helper `RazorEmailTemplate.Create()` to build an `EmailTemplate` instance. + +```csharp +using PosInformatique.Foundations.Emailing; +using PosInformatique.Foundations.Emailing.Templates.Razor; + +var invitationTemplate = RazorEmailTemplate.Create< + InvitationEmailSubject, + InvitationEmailBody>(); +``` + +You can then register this template in `EmailingOptions`: + +```csharp +options.RegisterTemplate(EmailTemplateIdentifiers.Invitation, invitationTemplate); +``` + +After that, the rest of the flow is the same as with any other `EmailTemplate`: + +- Use `IEmailManager.Create(EmailTemplateIdentifiers.Invitation)` to create the email. +- Add recipients and models. +- Call `SendAsync(...)` to send. + +## Links + +- [NuGet package: Emailing (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing/) +- [NuGet package: Emailing.Templates.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Templates.Razor/) +- [NuGet package: Text.Templating.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/src/Emailing.Templates.Razor/RazorEmailTemplate.cs b/src/Emailing.Templates.Razor/RazorEmailTemplate.cs new file mode 100644 index 0000000..8d1b25a --- /dev/null +++ b/src/Emailing.Templates.Razor/RazorEmailTemplate.cs @@ -0,0 +1,33 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Templates.Razor +{ + using PosInformatique.Foundations.Text.Templating.Razor; + + /// + /// Used to create an using Razor text templating for the subject and body. + /// + /// Type of the model used to generate the subject and body of the e-mail. + public static class RazorEmailTemplate + where TModel : EmailModel + { + /// + /// Creates an using the specified Razor components as text templating for the subject and body. + /// + /// Type of the Razor component used to generate the content of the e-mail subject. + /// Type of the Razor component used to generate the content of the e-mail body. + /// An instance using the specified Razor components as text templating for the subject and body. + public static EmailTemplate Create() + where TSubjectComponent : RazorEmailTemplateSubject + where TBodyComponent : RazorEmailTemplateBody + { + return new EmailTemplate( + new RazorTextTemplate(typeof(TSubjectComponent)), + new RazorTextTemplate(typeof(TBodyComponent))); + } + } +} \ No newline at end of file diff --git a/src/Emailing.Templates.Razor/RazorEmailTemplateBody.cs b/src/Emailing.Templates.Razor/RazorEmailTemplateBody.cs new file mode 100644 index 0000000..0c2dc04 --- /dev/null +++ b/src/Emailing.Templates.Razor/RazorEmailTemplateBody.cs @@ -0,0 +1,31 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Templates.Razor +{ + using Microsoft.AspNetCore.Components; + + /// + /// Base class of a Razor component which is used to generate the body of an email. + /// + /// Type of the used to generate the body of the e-mail. + public abstract class RazorEmailTemplateBody : ComponentBase + where TModel : EmailModel + { + /// + /// Initializes a new instance of the class. + /// + protected RazorEmailTemplateBody() + { + } + + /// + /// Gets or sets the model used to generate the body of the e-mail. + /// + [Parameter] + public TModel Model { get; set; } = default!; + } +} \ No newline at end of file diff --git a/src/Emailing.Templates.Razor/RazorEmailTemplateSubject.cs b/src/Emailing.Templates.Razor/RazorEmailTemplateSubject.cs new file mode 100644 index 0000000..f43a187 --- /dev/null +++ b/src/Emailing.Templates.Razor/RazorEmailTemplateSubject.cs @@ -0,0 +1,31 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Templates.Razor +{ + using Microsoft.AspNetCore.Components; + + /// + /// Base class of a Razor component which is used to generate the subject of an email. + /// + /// Type of the used to generate the subject of the e-mail. + public abstract class RazorEmailTemplateSubject : ComponentBase + where TModel : EmailModel + { + /// + /// Initializes a new instance of the class. + /// + protected RazorEmailTemplateSubject() + { + } + + /// + /// Gets or sets the model used to generate the body of the e-mail. + /// + [Parameter] + public TModel Model { get; set; } = default!; + } +} \ No newline at end of file diff --git a/src/Text.Templating.Razor/README.md b/src/Text.Templating.Razor/README.md index d9162eb..533c3f5 100644 --- a/src/Text.Templating.Razor/README.md +++ b/src/Text.Templating.Razor/README.md @@ -157,6 +157,7 @@ As long as `IDateTimeProvider` and `IMyFormatter` are registered in the `IServic ## Links +- [NuGet package: Emailing.Templates.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Templates.Razor/) - [NuGet package: Text.Templating (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating/) - [NuGet package: Text.Templating.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating/) - [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/tests/Emailing.Templates.Razor.Tests/Emailing.Templates.Razor.Tests.csproj b/tests/Emailing.Templates.Razor.Tests/Emailing.Templates.Razor.Tests.csproj new file mode 100644 index 0000000..753906a --- /dev/null +++ b/tests/Emailing.Templates.Razor.Tests/Emailing.Templates.Razor.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateTest.cs b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateTest.cs new file mode 100644 index 0000000..3b3d8c5 --- /dev/null +++ b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateTest.cs @@ -0,0 +1,34 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Templates.Razor.Tests +{ + using PosInformatique.Foundations.Text.Templating.Razor; + + public class RazorEmailTemplateTest + { + [Fact] + public void Create() + { + var template = RazorEmailTemplate.Create(); + + template.HtmlBody.Should().BeOfType>(); + template.Subject.Should().BeOfType>(); + } + + private sealed class Model : EmailModel + { + } + + private sealed class SubjectComponent : RazorEmailTemplateSubject + { + } + + private sealed class BodyComponent : RazorEmailTemplateBody + { + } + } +} \ No newline at end of file From 9948dedb9240c3373d59120868cbcb1928f3c4d7 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 07:18:28 +0100 Subject: [PATCH 42/73] Add PhoneNumbers implementation. --- Directory.Packages.props | 1 + PosInformatique.Foundations.slnx | 4 + README.md | 1 + src/EmailAddresses/EmailAddress.cs | 2 +- src/PhoneNumbers/CHANGELOG.md | 2 + src/PhoneNumbers/Icon.png | Bin 0 -> 42991 bytes src/PhoneNumbers/PhoneNumber.cs | 282 ++++++++++++++++ src/PhoneNumbers/PhoneNumbers.csproj | 31 ++ src/PhoneNumbers/README.md | 158 +++++++++ tests/PhoneNumbers.Tests/PhoneNumberTest.cs | 314 ++++++++++++++++++ .../PhoneNumbers.Tests/PhoneNumberTestData.cs | 28 ++ .../PhoneNumbers.Tests.csproj | 7 + 12 files changed, 829 insertions(+), 1 deletion(-) create mode 100644 src/PhoneNumbers/CHANGELOG.md create mode 100644 src/PhoneNumbers/Icon.png create mode 100644 src/PhoneNumbers/PhoneNumber.cs create mode 100644 src/PhoneNumbers/PhoneNumbers.csproj create mode 100644 src/PhoneNumbers/README.md create mode 100644 tests/PhoneNumbers.Tests/PhoneNumberTest.cs create mode 100644 tests/PhoneNumbers.Tests/PhoneNumberTestData.cs create mode 100644 tests/PhoneNumbers.Tests/PhoneNumbers.Tests.csproj diff --git a/Directory.Packages.props b/Directory.Packages.props index 5f5f222..d78b76e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,6 +7,7 @@ + diff --git a/PosInformatique.Foundations.slnx b/PosInformatique.Foundations.slnx index 61bbb34..3dcfd74 100644 --- a/PosInformatique.Foundations.slnx +++ b/PosInformatique.Foundations.slnx @@ -86,6 +86,10 @@
+ + + + diff --git a/README.md b/README.md index 8de8fb5..d00cbd7 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ You can install any package using the .NET CLI or NuGet Package Manager. |PosInformatique.Foundations.People.FluentAssertions icon|[**PosInformatique.Foundations.People.FluentAssertions**](./src/People.FluentAssertions/README.md) | [FluentAssertions](https://fluentassertions.com/) extensions for `FirstName` and `LastName` to avoid ambiguity and provide `Should().Be(string)` assertions (case-sensitive on normalized values). | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.FluentAssertions)](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentAssertions) | |PosInformatique.Foundations.People.FluentValidation icon|[**PosInformatique.Foundations.People.FluentValidation**](./src/People.FluentValidation/README.md) | [FluentValidation](https://fluentvalidation.net/) extensions for `FirstName` and `LastName` value objects. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentValidation) | |PosInformatique.Foundations.People.Json icon|[**PosInformatique.Foundations.People.Json**](./src/People.Json/README.md) | `System.Text.Json` converters for `FirstName` and `LastName`, with validation and easy registration via `AddPeopleConverters()`. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.People.Json) | +|PosInformatique.Foundations.PhoneNumbers icon|[**PosInformatique.Foundations.PhoneNumbers**](./src/PhoneNumbers/README.md) | Strongly-typed value object representing a phone number in E.164 format, with parsing (including region-aware local numbers), validation, comparison, and formatting helpers. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.PhoneNumbers)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers) | |PosInformatique.Foundations.Text.Templating icon|[**PosInformatique.Foundations.Text.Templating**](./src/Text.Templating/README.md) | Abstractions for text templating, including the `TextTemplate` base class and `ITextTemplateRenderContext` interface, to be used by concrete templating engine implementations such as Razor-based text templates. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Text.Templating)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating) | |PosInformatique.Foundations.Text.Templating.Razor icon|[**PosInformatique.Foundations.Text.Templating.Razor**](./src/Text.Templating.Razor/README.md) | Razor-based text templating using Blazor components, allowing generation of text from Razor views with a strongly-typed Model parameter and full dependency injection integration. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Text.Templating.Razor)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor) | diff --git a/src/EmailAddresses/EmailAddress.cs b/src/EmailAddresses/EmailAddress.cs index e5c009b..28e50c1 100644 --- a/src/EmailAddresses/EmailAddress.cs +++ b/src/EmailAddresses/EmailAddress.cs @@ -76,8 +76,8 @@ public static implicit operator string(EmailAddress emailAddress) /// /// The string to convert to an email address. /// An instance. - /// Thrown when the string is not a valid email address. /// Thrown when the argument is . + /// Thrown when the string is not a valid email address. public static implicit operator EmailAddress(string emailAddress) { ArgumentNullException.ThrowIfNull(emailAddress); diff --git a/src/PhoneNumbers/CHANGELOG.md b/src/PhoneNumbers/CHANGELOG.md new file mode 100644 index 0000000..b41e0e8 --- /dev/null +++ b/src/PhoneNumbers/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with strongly-typed PhoneNumber value object. diff --git a/src/PhoneNumbers/Icon.png b/src/PhoneNumbers/Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a39aa58dad91be766354b356af678cccb24fec69 GIT binary patch literal 42991 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 O;JKov!Vfv~u>S(k;&{IR literal 0 HcmV?d00001 diff --git a/src/PhoneNumbers/PhoneNumber.cs b/src/PhoneNumbers/PhoneNumber.cs new file mode 100644 index 0000000..bea0d01 --- /dev/null +++ b/src/PhoneNumbers/PhoneNumber.cs @@ -0,0 +1,282 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.PhoneNumbers +{ + using System.Diagnostics.CodeAnalysis; + using global::PhoneNumbers; + + /// + /// Represents a valid international phone number in E.164 format. Any attempt to create an invalid phone number is rejected. + /// + /// + /// This class provides several features: + /// + /// + /// Implements so that instances can be compared and used seamlessly + /// in generic scenarios such as collections, equality checks, or dictionaries. + /// + /// + /// Implements and to enable generic + /// conversion to and from string representations, making it easy to integrate with components + /// that rely on string formatting and parsing. + /// + /// + /// + public sealed class PhoneNumber : IEquatable, IFormattable, IParsable + { + private const PhoneNumberFormat DefaultPhoneNumberFormat = PhoneNumberFormat.E164; + + private static readonly PhoneNumberUtil PhoneNumbersUtil = PhoneNumberUtil.GetInstance(); + + private readonly global::PhoneNumbers.PhoneNumber wrappedInstance; + + private PhoneNumber(string phoneNumber, string? defaultRegion) + { + this.wrappedInstance = PhoneNumbersUtil.Parse(phoneNumber, defaultRegion); + } + + /// + /// Implicitly converts a to a in E.164 format. + /// + /// The phone number to convert. + /// The string representation of the phone number in E.164 format. + /// Thrown when the argument is . + public static implicit operator string(PhoneNumber phoneNumber) + { + ArgumentNullException.ThrowIfNull(phoneNumber); + + return phoneNumber.ToString(DefaultPhoneNumberFormat); + } + + /// + /// Implicitly converts a to a . + /// + /// The string to convert to a phone number. + /// A instance. + /// Thrown when the argument is . + /// Thrown when the string is not a valid E.164 phone number. + public static implicit operator PhoneNumber(string phoneNumber) + { + ArgumentNullException.ThrowIfNull(phoneNumber); + + return Parse(phoneNumber); + } + + /// + /// Determines whether two instances are equal. + /// + /// The first phone number to compare. + /// The second phone number to compare. + /// if the phone numbers are equal; otherwise, . + public static bool operator ==(PhoneNumber? left, PhoneNumber? right) + { + return Equals(left, right); + } + + /// + /// Determines whether two instances are not equal. + /// + /// The first phone number to compare. + /// The second phone number to compare. + /// if the phone numbers are not equal; otherwise, . + public static bool operator !=(PhoneNumber? left, PhoneNumber? right) + { + return !(left == right); + } + + /// + /// Parses a string representation of a phone number. + /// + /// The phone number string to parse. It can be an E.164 number or a local phone number. + /// + /// The region of the phone number to parse when is a local phone number. + /// This must be specified using an ISO 3166-1 alpha-2 country code (for example "US", "FR", ...). + /// This parameter is ignored when is already in E.164 format. + /// + /// A instance. + /// Thrown when the argument is . + /// Thrown when the specified value is not a valid phone number in E.164 format. + public static PhoneNumber Parse(string s, string? defaultRegion = null) + { + ArgumentNullException.ThrowIfNull(s); + + var (result, exception) = ParseInternal(s, defaultRegion); + + if (exception is not null) + { + throw exception; + } + + return result!; + } + + /// + /// Parses a string representation of a phone number. + /// + /// The phone number string to parse. It can be an E.164 number or a local phone number. + /// The format provider (not used in this implementation). + /// A instance. + /// Thrown when the argument is . + /// Thrown when the specified value is not a valid phone number in E.164 format. + static PhoneNumber IParsable.Parse(string s, IFormatProvider? provider) + { + ArgumentNullException.ThrowIfNull(s); + + return Parse(s, null); + } + + /// + /// Tries to parse a string representation of a phone number. + /// + /// The phone number string to parse. It can be an E.164 number or a local phone number. + /// + /// When this method returns, contains the parsed if the parsing succeeded, + /// or if it failed. + /// + /// + /// The region of the phone number to parse when is a local phone number. + /// This must be specified using an ISO 3166-1 alpha-2 country code (for example "US", "FR", ...). + /// This parameter is ignored when is already in E.164 format. + /// + /// if the parsing succeeded; otherwise, . + public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)][NotNullWhen(true)] out PhoneNumber? phoneNumber, string? defaultRegion = null) + { + phoneNumber = ParseInternal(s, defaultRegion).Number; + + return phoneNumber is not null; + } + + /// + /// Tries to parse a string representation of a phone number. + /// + /// The phone number string to parse. It can be an E.164 number or a local phone number. + /// The format provider (not used in this implementation). + /// + /// When this method returns, contains the parsed if the parsing succeeded, + /// or if it failed. + /// + /// if the parsing succeeded; otherwise, . + static bool IParsable.TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)][NotNullWhen(true)] out PhoneNumber? result) + { + return TryParse(s, out result, null); + } + + /// + /// Determines if the specified is a valid phone number in E.164 format. + /// + /// The phone number string to test. + /// + /// if the is a valid phone number in E.164 format; + /// otherwise, . + /// + public static bool IsValid(string phoneNumber) + { + return TryParse(phoneNumber, out var _); + } + + /// + public override bool Equals(object? obj) + { + if (obj is not PhoneNumber number) + { + return false; + } + + return this.Equals(number); + } + + /// + /// Determines whether the current is equal to another . + /// + /// The other phone number to compare with. + /// if the phone numbers are equal; otherwise, . + public bool Equals(PhoneNumber? other) + { + if (other is null) + { + return false; + } + + if (!this.wrappedInstance.Equals(other.wrappedInstance)) + { + return false; + } + + return true; + } + + /// + public override int GetHashCode() + { + return this.wrappedInstance.GetHashCode(); + } + + /// + /// Returns the string representation of the in E.164 format. + /// + /// The string representation of the in E.164 format. + public override string ToString() + { + return this.ToString(DefaultPhoneNumberFormat); + } + + /// + string IFormattable.ToString(string? format, IFormatProvider? formatProvider) + { + return this.ToString(); + } + + /// + /// Returns the international representation of the . + /// + /// The international representation of the phone number. + public string ToInternationalString() + { + return this.ToString(PhoneNumberFormat.INTERNATIONAL); + } + + /// + /// Returns the national representation of the . + /// + /// The national representation of the phone number. + public string ToNationalString() + { + return this.ToString(PhoneNumberFormat.NATIONAL); + } + + private static (PhoneNumber? Number, Exception? Exception) ParseInternal(string? s, string? defaultRegion) + { + if (s is null) + { + return (null, null); + } + + PhoneNumber phoneNumber; + + try + { + phoneNumber = new PhoneNumber(s, defaultRegion); + } + catch (NumberParseException e) + { + return (null, new FormatException($"The specified phone number '{s}' is not a valid E164 phone number.", e)); + } + + if (!PhoneNumbersUtil.IsValidNumber(phoneNumber.wrappedInstance)) + { + return (null, new FormatException($"The specified phone number '{s}' is not a valid E164 phone number.")); + } + + return (phoneNumber, null); + } + + private string ToString(PhoneNumberFormat numberFormat) + { + return PhoneNumbersUtil.Format(this.wrappedInstance, numberFormat); + } + } +} \ No newline at end of file diff --git a/src/PhoneNumbers/PhoneNumbers.csproj b/src/PhoneNumbers/PhoneNumbers.csproj new file mode 100644 index 0000000..e25983c --- /dev/null +++ b/src/PhoneNumbers/PhoneNumbers.csproj @@ -0,0 +1,31 @@ + + + + true + + + Provides a strongly-typed PhoneNumber value object that represents phone numbers in the E.164 format. + Includes parsing (with optional region for local numbers), validation, comparison, and formatting helpers for international and national representations. + + phone;phonenumber;valueobject;ddd;e164;parsing;validation;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + diff --git a/src/PhoneNumbers/README.md b/src/PhoneNumbers/README.md new file mode 100644 index 0000000..191a916 --- /dev/null +++ b/src/PhoneNumbers/README.md @@ -0,0 +1,158 @@ +# PosInformatique.Foundations.PhoneNumbers + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.PhoneNumbers)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.PhoneNumbers)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers/) + +## Introduction + +This package provides a strongly-typed `PhoneNumber` value object that represents a phone number in **E.164** format. + +It centralizes validation, parsing, comparison and formatting logic for phone numbers, and ensures that only valid international phone numbers can be instantiated. + +It is recommended to use E.164 format everywhere in your code. When parsing a **local** phone number (not already in E.164), you must explicitly specify the **region** to avoid ambiguity. + +This library uses the [libphonenumber-csharp](https://www.nuget.org/packages/libphonenumber-csharp) library under the hood for parsing and formatting. + +## Install + +You can install the package from [NuGet](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers/): + +```powershell +dotnet add package PosInformatique.Foundations.PhoneNumbers +``` + +## Features + +- Strongly-typed phone number value object based on **E.164** format +- Validation and parsing of international and local phone numbers +- Always stored and returned in **E.164** format by default +- Implements `IEquatable` for value-based equality +- Implements `IFormattable` and `IParsable` for easy integration with .NET APIs (parsing/formatting) +- Implicit conversion between `string` and `PhoneNumber` +- Helpers to format in **international** and **national** formats + +## Use cases + +- **Validation**: prevent invalid phone numbers from being stored in domain entities. +- **Type safety**: avoid handling raw strings for phone numbers across the application. +- **Standardization**: use E.164 format as the single source of truth everywhere. +- **Integration**: use `IParsable` / `IFormattable` in generic components (bindings, configuration, serialization, etc.). + +## Examples + +### Create and validate phone numbers + +```csharp +using PosInformatique.Foundations.PhoneNumbers; + +// Implicit conversion from string (expects a valid E.164 number) +PhoneNumber phone = "+33123456789"; + +// To string (E.164 by default) +Console.WriteLine(phone); // "+33123456789" + +// Validation +var valid = PhoneNumber.IsValid("+33123456789"); // true +var invalid = PhoneNumber.IsValid("1234"); // false +``` + +### Parsing E.164 phone numbers + +```csharp +var phone = PhoneNumber.Parse("+14155552671"); +Console.WriteLine(phone); // "+14155552671" +``` + +### Parsing local numbers with an explicit region + +When you parse a local phone number (not starting with "+"), you must specify the region (ISO 3166-1 alpha-2, e.g. "FR", "US", ...). + +```csharp +// Local French number, region must be specified +var frenchPhone = PhoneNumber.Parse("01 23 45 67 89", defaultRegion: "FR"); +Console.WriteLine(frenchPhone); // E.164: "+33123456789" + +// TryParse with region +if (PhoneNumber.TryParse("06 12 34 56 78", out var mobile, defaultRegion: "FR")) +{ + Console.WriteLine(mobile); // "+33612345678" +} +``` + +It is recommended to always work with E.164 numbers in your code (storage, comparison, APIs), +and only handle local formats at the boundaries (UI, input parsing) by specifying the region explicitly. + +### Formatting: E.164, international and national + +```csharp +var phone = PhoneNumber.Parse("+14155552671"); + +// Default ToString() = E.164 +Console.WriteLine(phone.ToString()); // "+14155552671" + +// International format +Console.WriteLine(phone.ToInternationalString()); // "+1 415-555-2671" (example output) + +// National format +Console.WriteLine(phone.ToNationalString()); // "(415) 555-2671" (example output) +``` + +### Using implicit conversions + +```csharp +// string -> PhoneNumber (implicit) +PhoneNumber phone = "+447911123456"; + +// PhoneNumber -> string (implicit, E.164) +string phoneString = phone; + +Console.WriteLine(phoneString); // "+447911123456" +``` + +### Using `IParsable` generically + +Because `PhoneNumber` implements `IParsable`, it can be used in generic parsing scenarios. + +```csharp +// Generic parsing using IParsable +static T ParseValue(string value) + where T : IParsable +{ + var result = T.Parse(value, provider: null); + return result; +} + +var phone = ParseValue("+33123456789"); +Console.WriteLine(phone); // "+33123456789" +``` + +### Using `IFormattable` generically + +`PhoneNumber` also implements `IFormattable`, so it can be formatted via APIs that rely on `IFormattable`. + +```csharp +static string FormatValue(IFormattable value) +{ + // format and provider are ignored in this implementation, + // but this allows generic handling of different value objects. + return value.ToString(format: null, formatProvider: null); +} + +var phone = PhoneNumber.Parse("+33123456789"); +var formatted = FormatValue(phone); + +Console.WriteLine(formatted); // "+33123456789" +``` + +## Recommendations + +- Always store and exchange phone numbers in **E.164 format** (e.g. in your database, APIs, events). +- Only accept local phone numbers at the boundaries (UI, import, etc.), and **always** specify the `defaultRegion` when parsing: + - `PhoneNumber.Parse(localNumber, defaultRegion: "FR")` + - `PhoneNumber.TryParse(localNumber, out var number, defaultRegion: "FR")` +- Avoid keeping raw strings. Use the `PhoneNumber` value object everywhere to centralize validation and formatting. + +## Links + +- [NuGet package](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/tests/PhoneNumbers.Tests/PhoneNumberTest.cs b/tests/PhoneNumbers.Tests/PhoneNumberTest.cs new file mode 100644 index 0000000..a21fc10 --- /dev/null +++ b/tests/PhoneNumbers.Tests/PhoneNumberTest.cs @@ -0,0 +1,314 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.PhoneNumbers.Tests +{ + public class PhoneNumberTest + { + [Theory] + [MemberData(nameof(PhoneNumberTestData.ValidPhoneNumbers), MemberType = typeof(PhoneNumberTestData))] + public void Parse(string phoneNumber, string expectedPhoneNumber) + { + var number = PhoneNumber.Parse(phoneNumber); + + number.ToString().Should().Be(expectedPhoneNumber); + number.As().ToString(null, null).Should().Be(expectedPhoneNumber); + } + + [Theory] + [MemberData(nameof(PhoneNumberTestData.ValidPhoneNumbers), MemberType = typeof(PhoneNumberTestData))] + [InlineData("0102030405", "+33102030405")] + public void Parse_WithDefaultRegion(string phoneNumber, string expectedPhoneNumber) + { + var number = PhoneNumber.Parse(phoneNumber, "FR"); + + number.ToString().Should().Be(expectedPhoneNumber); + number.As().ToString(null, null).Should().Be(expectedPhoneNumber); + } + + [Theory] + [MemberData(nameof(PhoneNumberTestData.InvalidPhoneNumbers), MemberType = typeof(PhoneNumberTestData))] + public void Parse_InvalidPhoneNumber(string invalidPhoneNumber) + { + new Action(() => PhoneNumber.Parse(invalidPhoneNumber)) + .Should().ThrowExactly() + .WithMessage($"The specified phone number '{invalidPhoneNumber}' is not a valid E164 phone number."); + } + + [Theory] + [InlineData("invalid phone number")] + public void Parse_InvalidPhoneNumberWithInnerException(string invalidPhoneNumber) + { + new Action(() => PhoneNumber.Parse(invalidPhoneNumber)) + .Should().ThrowExactly() + .WithMessage($"The specified phone number '{invalidPhoneNumber}' is not a valid E164 phone number.") + .WithInnerExceptionExactly() + .WithMessage("The string supplied did not seem to be a phone number."); + } + + [Fact] + public void Parse_WithNullArgument() + { + var act = () => + { + PhoneNumber.Parse(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("s"); + } + + [Theory] + [MemberData(nameof(PhoneNumberTestData.ValidPhoneNumbers), MemberType = typeof(PhoneNumberTestData))] + public void Parse_IParsable(string phoneNumber, string expectedPhoneNumber) + { + var number = CallParse(phoneNumber, null); + + number.ToString().Should().Be(expectedPhoneNumber); + number.As().ToString(null, null).Should().Be(expectedPhoneNumber); + } + + [Theory] + [MemberData(nameof(PhoneNumberTestData.InvalidPhoneNumbers), MemberType = typeof(PhoneNumberTestData))] + public void Parse_IParsable_InvalidPhoneNumber(string invalidPhoneNumber) + { + new Action(() => CallParse(invalidPhoneNumber, null)) + .Should().ThrowExactly() + .WithMessage($"The specified phone number '{invalidPhoneNumber}' is not a valid E164 phone number."); + } + + [Theory] + [InlineData("invalid phone number")] + public void Parse_IParsable_InvalidPhoneNumberWithInnerException(string invalidPhoneNumber) + { + new Action(() => CallParse(invalidPhoneNumber, null)) + .Should().ThrowExactly() + .WithMessage($"The specified phone number '{invalidPhoneNumber}' is not a valid E164 phone number.") + .WithInnerExceptionExactly() + .WithMessage("The string supplied did not seem to be a phone number."); + } + + [Fact] + public void Parse_IParsable_WithNullArgument() + { + var act = () => + { + CallParse(null, default); + }; + + act.Should().ThrowExactly() + .WithParameterName("s"); + } + + [Theory] + [MemberData(nameof(PhoneNumberTestData.ValidPhoneNumbers), MemberType = typeof(PhoneNumberTestData))] + public void TryParse(string phoneNumber, string expectedValue) + { + var result = PhoneNumber.TryParse(phoneNumber, out var number); + + result.Should().BeTrue(); + number.ToString().Should().Be(expectedValue); + number.As().ToString(null, null).Should().Be(expectedValue); + } + + [Theory] + [MemberData(nameof(PhoneNumberTestData.ValidPhoneNumbers), MemberType = typeof(PhoneNumberTestData))] + [InlineData("0102030405", "+33102030405")] + public void TryParse_WithDefaultRegion(string phoneNumber, string expectedPhoneNumber) + { + var result = PhoneNumber.TryParse(phoneNumber, out var number, "FR"); + + result.Should().BeTrue(); + number.ToString().Should().Be(expectedPhoneNumber); + number.As().ToString(null, null).Should().Be(expectedPhoneNumber); + } + + [Theory] + [MemberData(nameof(PhoneNumberTestData.InvalidPhoneNumbers), MemberType = typeof(PhoneNumberTestData))] + public void TryParse_InvalidPhoneNumber(string invalidPhoneNumber) + { + var result = PhoneNumber.TryParse(invalidPhoneNumber, out var number); + + result.Should().BeFalse(); + number.Should().BeNull(); + } + + [Theory] + [MemberData(nameof(PhoneNumberTestData.ValidPhoneNumbers), MemberType = typeof(PhoneNumberTestData))] + public void TryParse_IParsable(string phoneNumber, string expectedValue) + { + var result = CallTryParse(phoneNumber, null, out var number); + + result.Should().BeTrue(); + number.ToString().Should().Be(expectedValue); + number.As().ToString(null, null).Should().Be(expectedValue); + } + + [Theory] + [MemberData(nameof(PhoneNumberTestData.InvalidPhoneNumbers), MemberType = typeof(PhoneNumberTestData))] + public void TryParse_IParsable_InvalidPhoneNumber(string invalidPhoneNumber) + { + var result = CallTryParse(invalidPhoneNumber, null, out var number); + + result.Should().BeFalse(); + number.Should().BeNull(); + } + + [Theory] + [MemberData(nameof(PhoneNumberTestData.ValidPhoneNumbers), MemberType = typeof(PhoneNumberTestData))] +#pragma warning disable IDE0079 // Remove unnecessary suppression +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + public void IsValid_Valid(string phoneNumber, string _) +#pragma warning restore SA1313 // Parameter names should begin with lower-case letter +#pragma warning restore IDE0079 // Remove unnecessary suppression + { + PhoneNumber.IsValid(phoneNumber).Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(PhoneNumberTestData.InvalidPhoneNumbers), MemberType = typeof(PhoneNumberTestData))] + public void IsValid_Invalid(string invalidPhoneNumber) + { + PhoneNumber.IsValid(invalidPhoneNumber).Should().BeFalse(); + } + + [Fact] + public void Equals_WithPhoneNumber() + { + var number1 = PhoneNumber.Parse("+33111111111"); + var number2 = PhoneNumber.Parse("+33333333333"); + var number3 = PhoneNumber.Parse("+33111111111"); + + number1.Equals(number2).Should().BeFalse(); + number1.Equals(null).Should().BeFalse(); + number1.Equals(number3).Should().BeTrue(); + } + + [Fact] + public void Equals_WithObject() + { + var number1 = PhoneNumber.Parse("+33111111111"); + var number2 = PhoneNumber.Parse("+33333333333"); + var number3 = PhoneNumber.Parse("+33111111111"); + + number1.Equals((object)number2).Should().BeFalse(); + number1.Equals((object)number3).Should().BeTrue(); + + object stringValue = "The string"; + number1.Equals(stringValue).Should().BeFalse(); + } + + [Fact] + public void GetHashCode_Test() + { + var number = PhoneNumber.Parse("+33111111111"); + var wrappedTypeInstance = global::PhoneNumbers.PhoneNumberUtil.GetInstance().Parse("+33111111111", "FR"); + number.GetHashCode().Should().Be(wrappedTypeInstance.GetHashCode()); + } + + [Fact] + public void Operator_Equals() + { + var number1 = PhoneNumber.Parse("+33111111111"); + var number2 = PhoneNumber.Parse("+33333333333"); + var number3 = PhoneNumber.Parse("+33111111111"); + + (number1 == number2).Should().BeFalse(); + (number1 == number3).Should().BeTrue(); + } + + [Fact] + public void Operator_NotEquals() + { + var number1 = PhoneNumber.Parse("+33111111111"); + var number2 = PhoneNumber.Parse("+33333333333"); + var number3 = PhoneNumber.Parse("+33111111111"); + + (number1 != number2).Should().BeTrue(); + (number1 != number3).Should().BeFalse(); + } + + [Fact] + public void ToString_ShouldReturnValue() + { + var number = PhoneNumber.Parse("+33111111111"); + + number.ToString().Should().Be("+33111111111"); + number.As().ToString(null, null).Should().Be("+33111111111"); + } + + [Fact] + public void ToInternationalString() + { + var number = PhoneNumber.Parse("+33102030405"); + + number.ToInternationalString().Should().Be("+33 1 02 03 04 05"); + } + + [Fact] + public void ToNationalString() + { + var number = PhoneNumber.Parse("+33102030405"); + + number.ToNationalString().Should().Be("01 02 03 04 05"); + } + + [Fact] + public void ImplicitOperator_PhoneNumberToString() + { + var phoneNumber = PhoneNumber.Parse("+ 33 1 22 33 44 55"); + + string stringValue = phoneNumber; + + stringValue.Should().Be("+33122334455"); + } + + [Fact] + public void ImplicitOperator_PhoneNumberToString_WithNullArgument() + { + var act = () => + { + string _ = (PhoneNumber)null; + }; + + act.Should().ThrowExactly() + .WithParameterName("phoneNumber"); + } + + [Fact] + public void ImplicitOperator_StringToPhoneNumber() + { + PhoneNumber phoneNumber = "+ 33 1 22 33 44 55"; + + phoneNumber.ToString().Should().Be("+33122334455"); + phoneNumber.As().ToString(null, null).Should().Be("+33122334455"); + } + + [Fact] + public void ImplicitOperator_StringToPhoneNumber_WithNullArgument() + { + var act = () => + { + PhoneNumber _ = (string)null; + }; + + act.Should().ThrowExactly() + .WithParameterName("phoneNumber"); + } + + private static T CallParse(string s, IFormatProvider formatProvider) + where T : IParsable + { + return T.Parse(s, formatProvider); + } + + private static bool CallTryParse(string s, IFormatProvider formatProvider, out T result) + where T : IParsable + { + return T.TryParse(s, formatProvider, out result); + } + } +} \ No newline at end of file diff --git a/tests/PhoneNumbers.Tests/PhoneNumberTestData.cs b/tests/PhoneNumbers.Tests/PhoneNumberTestData.cs new file mode 100644 index 0000000..1e990fb --- /dev/null +++ b/tests/PhoneNumbers.Tests/PhoneNumberTestData.cs @@ -0,0 +1,28 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.PhoneNumbers +{ + public static class PhoneNumberTestData + { + public static TheoryData InvalidPhoneNumbers { get; } = new() + { + "invalid phone number", + "111111111", + "1234567891", + "+3360102", + "0102030405", + }; + + public static TheoryData ValidPhoneNumbers { get; } = new() + { + { "+33111111111", "+33111111111" }, + { "+15125111111", "+15125111111" }, + { "+33767678028", "+33767678028" }, + { "+33 1 11 11 11 11", "+33111111111" }, + }; + } +} \ No newline at end of file diff --git a/tests/PhoneNumbers.Tests/PhoneNumbers.Tests.csproj b/tests/PhoneNumbers.Tests/PhoneNumbers.Tests.csproj new file mode 100644 index 0000000..e8f7ddc --- /dev/null +++ b/tests/PhoneNumbers.Tests/PhoneNumbers.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + From 43884bf3f72cf23f9b409cb79b2eca62dfd9d1fc Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 07:33:17 +0100 Subject: [PATCH 43/73] Add the PhoneNumbers.Json package. --- PosInformatique.Foundations.slnx | 4 + README.md | 1 + src/EmailAddresses.Json/README.md | 3 +- src/PhoneNumbers.Json/CHANGELOG.md | 2 + .../PhoneNumberJsonConverter.cs | 45 +++++++ .../PhoneNumbers.Json.csproj | 30 +++++ ...eNumbersJsonSerializerOptionsExtensions.cs | 35 +++++ src/PhoneNumbers.Json/README.md | 126 ++++++++++++++++++ src/PhoneNumbers/README.md | 3 +- .../PhoneNumberJsonConverterTest.cs | 101 ++++++++++++++ .../PhoneNumbers.Json.Tests.csproj | 11 ++ ...bersJsonSerializerOptionsExtensionsTest.cs | 42 ++++++ 12 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 src/PhoneNumbers.Json/CHANGELOG.md create mode 100644 src/PhoneNumbers.Json/PhoneNumberJsonConverter.cs create mode 100644 src/PhoneNumbers.Json/PhoneNumbers.Json.csproj create mode 100644 src/PhoneNumbers.Json/PhoneNumbersJsonSerializerOptionsExtensions.cs create mode 100644 src/PhoneNumbers.Json/README.md create mode 100644 tests/PhoneNumbers.Json.Tests/PhoneNumberJsonConverterTest.cs create mode 100644 tests/PhoneNumbers.Json.Tests/PhoneNumbers.Json.Tests.csproj create mode 100644 tests/PhoneNumbers.Json.Tests/PhoneNumbersJsonSerializerOptionsExtensionsTest.cs diff --git a/PosInformatique.Foundations.slnx b/PosInformatique.Foundations.slnx index 3dcfd74..21f14da 100644 --- a/PosInformatique.Foundations.slnx +++ b/PosInformatique.Foundations.slnx @@ -90,6 +90,10 @@ + + + + diff --git a/README.md b/README.md index d00cbd7..de409c2 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ You can install any package using the .NET CLI or NuGet Package Manager. |PosInformatique.Foundations.People.FluentValidation icon|[**PosInformatique.Foundations.People.FluentValidation**](./src/People.FluentValidation/README.md) | [FluentValidation](https://fluentvalidation.net/) extensions for `FirstName` and `LastName` value objects. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentValidation) | |PosInformatique.Foundations.People.Json icon|[**PosInformatique.Foundations.People.Json**](./src/People.Json/README.md) | `System.Text.Json` converters for `FirstName` and `LastName`, with validation and easy registration via `AddPeopleConverters()`. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.People.Json) | |PosInformatique.Foundations.PhoneNumbers icon|[**PosInformatique.Foundations.PhoneNumbers**](./src/PhoneNumbers/README.md) | Strongly-typed value object representing a phone number in E.164 format, with parsing (including region-aware local numbers), validation, comparison, and formatting helpers. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.PhoneNumbers)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers) | +|PosInformatique.Foundations.PhoneNumbers.Json icon|[**PosInformatique.Foundations.PhoneNumbers.Json**](./src/PhoneNumbers.Json/README.md) | `System.Text.Json` converter for the `PhoneNumber` value object, enabling seamless serialization and deserialization of E.164 compliant phone numbers. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.PhoneNumbers.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.Json) | |PosInformatique.Foundations.Text.Templating icon|[**PosInformatique.Foundations.Text.Templating**](./src/Text.Templating/README.md) | Abstractions for text templating, including the `TextTemplate` base class and `ITextTemplateRenderContext` interface, to be used by concrete templating engine implementations such as Razor-based text templates. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Text.Templating)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating) | |PosInformatique.Foundations.Text.Templating.Razor icon|[**PosInformatique.Foundations.Text.Templating.Razor**](./src/Text.Templating.Razor/README.md) | Razor-based text templating using Blazor components, allowing generation of text from Razor views with a strongly-typed Model parameter and full dependency injection integration. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Text.Templating.Razor)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor) | diff --git a/src/EmailAddresses.Json/README.md b/src/EmailAddresses.Json/README.md index b1fc06c..1d11951 100644 --- a/src/EmailAddresses.Json/README.md +++ b/src/EmailAddresses.Json/README.md @@ -5,7 +5,8 @@ ## Introduction Provides a **System.Text.Json** converter for the `EmailAddress` value object from -[PosInformatique.Foundations.EmailAddresses](../EmailAddresses/README.md). Enables seamless serialization and deserialization of **RFC 5322 compliant** email addresses within JSON documents. +[PosInformatique.Foundations.EmailAddresses](../EmailAddresses/README.md). Enables seamless serialization and deserialization +of **RFC 5322 compliant** email addresses within JSON documents. ## Install You can install the package from NuGet: diff --git a/src/PhoneNumbers.Json/CHANGELOG.md b/src/PhoneNumbers.Json/CHANGELOG.md new file mode 100644 index 0000000..2110d3a --- /dev/null +++ b/src/PhoneNumbers.Json/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with the support JSON serialization (with System.Text.Json) for PhoneNumber value object. diff --git a/src/PhoneNumbers.Json/PhoneNumberJsonConverter.cs b/src/PhoneNumbers.Json/PhoneNumberJsonConverter.cs new file mode 100644 index 0000000..4a46271 --- /dev/null +++ b/src/PhoneNumbers.Json/PhoneNumberJsonConverter.cs @@ -0,0 +1,45 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.PhoneNumbers.Json +{ + using System.Text.Json; + using System.Text.Json.Serialization; + + /// + /// which allows to serialize and deserialize an + /// as a JSON string. + /// + public sealed class PhoneNumberJsonConverter : JsonConverter + { + /// + public override bool HandleNull => true; + + /// + public override PhoneNumber? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var input = reader.GetString(); + + if (input is null) + { + return null; + } + + if (string.IsNullOrWhiteSpace(input)) + { + return null; + } + + return PhoneNumber.Parse(input!); + } + + /// + public override void Write(Utf8JsonWriter writer, PhoneNumber value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); + } + } +} \ No newline at end of file diff --git a/src/PhoneNumbers.Json/PhoneNumbers.Json.csproj b/src/PhoneNumbers.Json/PhoneNumbers.Json.csproj new file mode 100644 index 0000000..cdf1354 --- /dev/null +++ b/src/PhoneNumbers.Json/PhoneNumbers.Json.csproj @@ -0,0 +1,30 @@ + + + + true + + + System.Text.Json converter for the PhoneNumber value object, enabling seamless serialization and deserialization of phone numbers in E.164 format. + + phone;phonenumber;json;serialization;systemtextjson;converter;e164;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + diff --git a/src/PhoneNumbers.Json/PhoneNumbersJsonSerializerOptionsExtensions.cs b/src/PhoneNumbers.Json/PhoneNumbersJsonSerializerOptionsExtensions.cs new file mode 100644 index 0000000..fb5a578 --- /dev/null +++ b/src/PhoneNumbers.Json/PhoneNumbersJsonSerializerOptionsExtensions.cs @@ -0,0 +1,35 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace System.Text.Json +{ + using PosInformatique.Foundations.PhoneNumbers.Json; + + /// + /// Contains extension methods to configure . + /// + public static class PhoneNumbersJsonSerializerOptionsExtensions + { + /// + /// Registers the to the . + /// + /// which the + /// converter will be added in the collection. + /// The instance to continue the configuration. + /// If the specified argument is . + public static JsonSerializerOptions AddPhoneNumbersConverters(this JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (!options.Converters.Any(c => c is PhoneNumberJsonConverter)) + { + options.Converters.Add(new PhoneNumberJsonConverter()); + } + + return options; + } + } +} \ No newline at end of file diff --git a/src/PhoneNumbers.Json/README.md b/src/PhoneNumbers.Json/README.md new file mode 100644 index 0000000..10d237e --- /dev/null +++ b/src/PhoneNumbers.Json/README.md @@ -0,0 +1,126 @@ +# PosInformatique.Foundations.PhoneNumbers.Json + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.PhoneNumbers.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.Json/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.PhoneNumbers.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.Json/) + +## Introduction + +This package provides `System.Text.Json` integration for the `PhoneNumber` value object +from [PosInformatique.Foundations.PhoneNumbers](../PhoneNumbers/README.md). + +It adds a `JsonConverter` that serializes and deserializes phone numbers as JSON strings in **E.164** format, +and an extension method to easily register the converter on your `JsonSerializerOptions`. + +## Install + +You can install the package from [NuGet](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.Json/): + +```powershell +dotnet add package PosInformatique.Foundations.PhoneNumbers.Json +``` + +## Features + +- `System.Text.Json` converter for the `PhoneNumber` value object +- Serializes `PhoneNumber` as a JSON string in **E.164** format +- Deserializes from a JSON string to a `PhoneNumber` instance +- Gracefully handles `null` and empty/whitespace JSON string values as `null` +- Convenient extension method `AddPhoneNumbersConverters` on `JsonSerializerOptions` + +## Use cases + +- Persist and exchange `PhoneNumber` values as JSON with APIs +- Use `PhoneNumber` in your DTOs without custom mapping code +- Share a consistent JSON representation (E.164) across microservices and clients + +## Examples + +### Register the converter globally + +```csharp +using System.Text.Json; +using PosInformatique.Foundations.PhoneNumbers; +using PosInformatique.Foundations.PhoneNumbers.Json; + +// Configure JsonSerializerOptions +var options = new JsonSerializerOptions() + .AddPhoneNumbersConverters(); +``` + +### Serialize a PhoneNumber + +```csharp +using System.Text.Json; +using PosInformatique.Foundations.PhoneNumbers; + +var options = new JsonSerializerOptions().AddPhoneNumbersConverters(); + +PhoneNumber phone = "+33123456789"; + +var json = JsonSerializer.Serialize(phone, options); +// json == "\"+33123456789\"" +``` + +### Deserialize a PhoneNumber + +```csharp +using System.Text.Json; +using PosInformatique.Foundations.PhoneNumbers; + +var options = new JsonSerializerOptions().AddPhoneNumbersConverters(); + +var json = "\"+33123456789\""; + +var phone = JsonSerializer.Deserialize(json, options); +// phone represents "+33123456789" +``` + +### Use PhoneNumber in DTOs + +```csharp +using System.Text.Json; +using PosInformatique.Foundations.PhoneNumbers; + +public sealed class ContactDto +{ + public string Name { get; set; } = default!; + public PhoneNumber? Mobile { get; set; } +} + +var options = new JsonSerializerOptions().AddPhoneNumbersConverters(); + +var contact = new ContactDto +{ + Name = "John Doe", + Mobile = PhoneNumber.Parse("+33123456789") +}; + +var json = JsonSerializer.Serialize(contact, options); +// { +// "Name": "John Doe", +// "Mobile": "+33123456789" +// } + +var deserialized = JsonSerializer.Deserialize(json, options); +``` + +### Handling null and empty values + +- JSON `null` is deserialized as `null` `PhoneNumber`. +- Empty or whitespace JSON strings are also deserialized as `null`. + +```csharp +var options = new JsonSerializerOptions().AddPhoneNumbersConverters(); + +var jsonNull = "null"; +var phoneNull = JsonSerializer.Deserialize(jsonNull, options); // null + +var jsonEmpty = "\"\""; +var phoneEmpty = JsonSerializer.Deserialize(jsonEmpty, options); // null +``` + +## Links + +- [NuGet package: PhoneNumbers (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers/) +- [NuGet package: PhoneNumbers.Json](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.Json/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/src/PhoneNumbers/README.md b/src/PhoneNumbers/README.md index 191a916..d583ea8 100644 --- a/src/PhoneNumbers/README.md +++ b/src/PhoneNumbers/README.md @@ -154,5 +154,6 @@ Console.WriteLine(formatted); // "+33123456789" ## Links -- [NuGet package](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers/) +- [NuGet package (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers/) +- [NuGet package: PhoneNumbers.Json](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.Json/) - [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/tests/PhoneNumbers.Json.Tests/PhoneNumberJsonConverterTest.cs b/tests/PhoneNumbers.Json.Tests/PhoneNumberJsonConverterTest.cs new file mode 100644 index 0000000..b3dd67b --- /dev/null +++ b/tests/PhoneNumbers.Json.Tests/PhoneNumberJsonConverterTest.cs @@ -0,0 +1,101 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.PhoneNumbers.Json.Tests +{ + using System.Text.Json; + + public class PhoneNumberJsonConverterTest + { + [Fact] + public void Serialization() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new PhoneNumberJsonConverter(), + }, + }; + + var @object = new JsonClass + { + StringValue = "The string value", + PhoneNumber = PhoneNumber.Parse("+33111111111"), + }; + + @object.Should().BeJsonSerializableInto( + new + { + StringValue = "The string value", + PhoneNumber = "+33111111111", + }, + options); + } + + [Fact] + public void Deserialization() + { + var options = new JsonSerializerOptions() + { + Converters = + { + new PhoneNumberJsonConverter(), + }, + }; + + var json = new + { + StringValue = "The string value", + PhoneNumber = "+33111111111", + }; + + json.Should().BeJsonDeserializableInto( + new JsonClass + { + StringValue = "The string value", + PhoneNumber = PhoneNumber.Parse("+33111111111"), + }, + options); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Deserialization_WithNullOrWhiteSpaceValue(string value) + { + var options = new JsonSerializerOptions() + { + Converters = + { + new PhoneNumberJsonConverter(), + }, + }; + + var json = new + { + StringValue = "The string value", + PhoneNumber = value, + }; + + json.Should().BeJsonDeserializableInto( + new JsonClass + { + StringValue = "The string value", + PhoneNumber = null, + }, + options); + } + + private class JsonClass + { + public string StringValue { get; set; } + + public PhoneNumber PhoneNumber { get; set; } + } + } +} \ No newline at end of file diff --git a/tests/PhoneNumbers.Json.Tests/PhoneNumbers.Json.Tests.csproj b/tests/PhoneNumbers.Json.Tests/PhoneNumbers.Json.Tests.csproj new file mode 100644 index 0000000..bfc264d --- /dev/null +++ b/tests/PhoneNumbers.Json.Tests/PhoneNumbers.Json.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/PhoneNumbers.Json.Tests/PhoneNumbersJsonSerializerOptionsExtensionsTest.cs b/tests/PhoneNumbers.Json.Tests/PhoneNumbersJsonSerializerOptionsExtensionsTest.cs new file mode 100644 index 0000000..7ad831a --- /dev/null +++ b/tests/PhoneNumbers.Json.Tests/PhoneNumbersJsonSerializerOptionsExtensionsTest.cs @@ -0,0 +1,42 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace System.Text.Json.Tests +{ + using PosInformatique.Foundations.PhoneNumbers.Json; + + public class PhoneNumbersJsonSerializerOptionsExtensionsTest + { + [Fact] + public void AddPhoneNumbersConverters() + { + var options = new JsonSerializerOptions(); + + options.AddPhoneNumbersConverters(); + + options.Converters.Should().HaveCount(1); + options.Converters[0].Should().BeOfType(); + + // Call again to check nothing has been changed. + options.AddPhoneNumbersConverters(); + + options.Converters.Should().HaveCount(1); + options.Converters[0].Should().BeOfType(); + } + + [Fact] + public void AddPhoneNumbersConverters_WithNullArgument() + { + var act = () => + { + PhoneNumbersJsonSerializerOptionsExtensions.AddPhoneNumbersConverters(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("options"); + } + } +} \ No newline at end of file From ae6850e9e91e544b61557e5235087cb045d79d66 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 07:48:47 +0100 Subject: [PATCH 44/73] Reverse README between EF et MediaTypes --- src/EmailAddresses.EntityFramework/README.md | 71 +++++++++---------- src/MediaTypes.EntityFramework/README.md | 73 +++++++++++--------- 2 files changed, 72 insertions(+), 72 deletions(-) diff --git a/src/EmailAddresses.EntityFramework/README.md b/src/EmailAddresses.EntityFramework/README.md index 1964d23..a6f2ebc 100644 --- a/src/EmailAddresses.EntityFramework/README.md +++ b/src/EmailAddresses.EntityFramework/README.md @@ -1,82 +1,73 @@ -# PosInformatique.Foundations.MediaTypes.EntityFramework +# PosInformatique.Foundations.EmailAddresses.EntityFramework -[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.EntityFramework/) -[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.MediaTypes.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.EntityFramework/) +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) ## Introduction +Provides **Entity Framework Core** integration for the `EmailAddress` value object from +[PosInformatique.Foundations.EmailAddresses](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/). +This package enables seamless mapping of RFC 5322 compliant email addresses as strongly-typed properties in Entity Framework Core entities. -Provides **Entity Framework Core** integration for the `MimeType` value object from -[PosInformatique.Foundations.MediaTypes](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes/). -This package enables seamless mapping of MIME types as strongly-typed properties in Entity Framework Core entities. - -It ensures proper SQL type mapping, validation, and conversion to `VARCHAR(128)` when persisted to the database. +It ensures proper SQL type mapping, validation, and conversion to `VARCHAR` when persisted to the database. ## Install - You can install the package from NuGet: ```powershell -dotnet add package PosInformatique.Foundations.MediaTypes.EntityFramework +dotnet add package PosInformatique.Foundations.EmailAddresses.EntityFramework ``` -This package depends on the base package [PosInformatique.Foundations.MediaTypes](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes/). +This package depends on the base package [PosInformatique.Foundations.EmailAddresses](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/). ## Features - -- Provides an extension method `IsMimeType()` to configure EF Core properties for `MimeType`. -- Maps to `VARCHAR(128)` database columns using the SQL type `MimeType` (you must define the SQL type `MimeType` mapped to `VARCHAR(128)` in your database). -- Ensures validation and safe conversion to/from database fields. -- Built on top of the core `MimeType` value object. +- Provides an extension method `IsEmailAddress()` to configure EF Core properties for `EmailAddress`. +- Maps to `VARCHAR(320)` database columns using the SQL type `EmailAddress` (you must define the SQL type `EmailAddress` mapped to `VARCHAR(320)` in your database). +- Ensures validation, normalization, and safe conversion to/from database fields. +- Built on top of the core `EmailAddress` value object. ## Use cases - -- **Entity mapping**: enforce strong typing for MIME types at the persistence layer. -- **Consistency**: ensure the same rules are applied in your entities and database. -- **Safety**: prevent invalid or malformed MIME type strings being stored in your database. +- **Entity mapping**: enforce strong typing for email addresses at the persistence layer. +- **Consistency**: ensure the same validation rules are applied in your entities and database. +- **Safety**: prevent invalid strings being stored in your database ## Examples -> ⚠️ To use `IsMimeType()`, you must first define the SQL type `MimeType` mapped to `VARCHAR(128)` in your database. -> For SQL Server, you can create it with: +> ⚠️ To use `IsEmailAddress()`, you must first define the SQL type `EmailAddress` mapped to `VARCHAR(320)` in your database. +For SQL Server, you can create it with: ```sql -CREATE TYPE MimeType FROM VARCHAR(128) NOT NULL; +CREATE TYPE EmailAddress FROM VARCHAR(320) NOT NULL; ``` ### Example: Configure an entity - ```csharp using Microsoft.EntityFrameworkCore; -using PosInformatique.Foundations.MediaTypes; +using PosInformatique.Foundations; -public class Document +public class User { public int Id { get; set; } - - public MimeType ContentType { get; set; } + public EmailAddress Email { get; set; } } public class ApplicationDbContext : DbContext { - public DbSet Documents => Set(); + public DbSet Users => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity() - .Property(d => d.ContentType) - .IsMimeType(); + modelBuilder.Entity() + .Property(u => u.Email) + .IsEmailAddress(); } } ``` -This will configure the `ContentType` property of the `Document` entity with: - -- `VARCHAR(128)` (non-unicode) column length -- SQL column type `MimeType` -- Proper conversion between `MimeType` and `string` +This will configure the `Email` property of the `User` entity with: +- `VARCHAR(320)` (Non-unicode) column length +- SQL column type `EmailAddress` ## Links - -- [NuGet package: MediaTypes.EntityFramework](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.EntityFramework/) -- [NuGet package: MediaTypes (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes/) +- [NuGet package: EmailAddresses.EntityFramework](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) +- [NuGet package: EmailAddresses (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) - [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/src/MediaTypes.EntityFramework/README.md b/src/MediaTypes.EntityFramework/README.md index e7d1943..1964d23 100644 --- a/src/MediaTypes.EntityFramework/README.md +++ b/src/MediaTypes.EntityFramework/README.md @@ -1,73 +1,82 @@ -# PosInformatique.Foundations.EmailAddresses.EntityFramework +# PosInformatique.Foundations.MediaTypes.EntityFramework -[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) -[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.EmailAddresses.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.EntityFramework/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.MediaTypes.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.EntityFramework/) ## Introduction -Provides **Entity Framework Core** integration for the `EmailAddress` value object from -[PosInformatique.Foundations.EmailAddresses](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/). -This package enables seamless mapping of RFC 5322 compliant email addresses as strongly-typed properties in Entity Framework Core entities. -It ensures proper SQL type mapping, validation, and conversion to `VARCHAR` when persisted to the database. +Provides **Entity Framework Core** integration for the `MimeType` value object from +[PosInformatique.Foundations.MediaTypes](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes/). +This package enables seamless mapping of MIME types as strongly-typed properties in Entity Framework Core entities. + +It ensures proper SQL type mapping, validation, and conversion to `VARCHAR(128)` when persisted to the database. ## Install + You can install the package from NuGet: ```powershell -dotnet add package PosInformatique.Foundations.EmailAddresses.EntityFramework +dotnet add package PosInformatique.Foundations.MediaTypes.EntityFramework ``` -This package depends on the base package [PosInformatique.Foundations.EmailAddresses](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/). +This package depends on the base package [PosInformatique.Foundations.MediaTypes](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes/). ## Features -- Provides an extension method `IsEmailAddress()` to configure EF Core properties for `EmailAddress`. -- Maps to `VARCHAR(320)` database columns using the SQL type `EmailAddress` (you must define the SQL type `EmailAddress` mapped to `VARCHAR(320)` in your database). -- Ensures validation, normalization, and safe conversion to/from database fields. -- Built on top of the core `EmailAddress` value object. + +- Provides an extension method `IsMimeType()` to configure EF Core properties for `MimeType`. +- Maps to `VARCHAR(128)` database columns using the SQL type `MimeType` (you must define the SQL type `MimeType` mapped to `VARCHAR(128)` in your database). +- Ensures validation and safe conversion to/from database fields. +- Built on top of the core `MimeType` value object. ## Use cases -- **Entity mapping**: enforce strong typing for email addresses at the persistence layer. -- **Consistency**: ensure the same validation rules are applied in your entities and database. -- **Safety**: prevent invalid strings being stored in your database + +- **Entity mapping**: enforce strong typing for MIME types at the persistence layer. +- **Consistency**: ensure the same rules are applied in your entities and database. +- **Safety**: prevent invalid or malformed MIME type strings being stored in your database. ## Examples -> ⚠️ To use `IsEmailAddress()`, you must first define the SQL type `EmailAddress` mapped to `VARCHAR(320)` in your database. -For SQL Server, you can create it with: +> ⚠️ To use `IsMimeType()`, you must first define the SQL type `MimeType` mapped to `VARCHAR(128)` in your database. +> For SQL Server, you can create it with: ```sql -CREATE TYPE EmailAddress FROM VARCHAR(320) NOT NULL; +CREATE TYPE MimeType FROM VARCHAR(128) NOT NULL; ``` ### Example: Configure an entity + ```csharp using Microsoft.EntityFrameworkCore; -using PosInformatique.Foundations; +using PosInformatique.Foundations.MediaTypes; -public class User +public class Document { public int Id { get; set; } - public EmailAddress Email { get; set; } + + public MimeType ContentType { get; set; } } public class ApplicationDbContext : DbContext { - public DbSet Users => Set(); + public DbSet Documents => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity() - .Property(u => u.Email) - .IsEmailAddress(); + modelBuilder.Entity() + .Property(d => d.ContentType) + .IsMimeType(); } } ``` -This will configure the `Email` property of the `User` entity with: -- `VARCHAR(320)` (Non-unicode) column length -- SQL column type `EmailAddress` +This will configure the `ContentType` property of the `Document` entity with: + +- `VARCHAR(128)` (non-unicode) column length +- SQL column type `MimeType` +- Proper conversion between `MimeType` and `string` ## Links -- [NuGet package: EmailAddresses.EntityFramework](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.EntityFramework/) -- [NuGet package: EmailAddresses (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses/) -- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) + +- [NuGet package: MediaTypes.EntityFramework](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.EntityFramework/) +- [NuGet package: MediaTypes (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file From 8c6661b42e79ebb68b072a924cc3d4d1a6a69a02 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 07:53:26 +0100 Subject: [PATCH 45/73] Add the PhoneNumbers.EntityFramework package. --- PosInformatique.Foundations.slnx | 4 + README.md | 1 + src/PhoneNumbers.EntityFramework/CHANGELOG.md | 2 + .../PhoneNumberPropertyExtensions.cs | 53 +++++++ .../PhoneNumbers.EntityFramework.csproj | 30 ++++ src/PhoneNumbers.EntityFramework/README.md | 96 +++++++++++++ src/PhoneNumbers/README.md | 1 + .../PhoneNumberPropertyExtensionsTest.cs | 134 ++++++++++++++++++ .../PhoneNumbers.EntityFramework.Tests.csproj | 11 ++ 9 files changed, 332 insertions(+) create mode 100644 src/PhoneNumbers.EntityFramework/CHANGELOG.md create mode 100644 src/PhoneNumbers.EntityFramework/PhoneNumberPropertyExtensions.cs create mode 100644 src/PhoneNumbers.EntityFramework/PhoneNumbers.EntityFramework.csproj create mode 100644 src/PhoneNumbers.EntityFramework/README.md create mode 100644 tests/PhoneNumbers.EntityFramework.Tests/PhoneNumberPropertyExtensionsTest.cs create mode 100644 tests/PhoneNumbers.EntityFramework.Tests/PhoneNumbers.EntityFramework.Tests.csproj diff --git a/PosInformatique.Foundations.slnx b/PosInformatique.Foundations.slnx index 21f14da..93f2790 100644 --- a/PosInformatique.Foundations.slnx +++ b/PosInformatique.Foundations.slnx @@ -90,6 +90,10 @@ + + + + diff --git a/README.md b/README.md index de409c2..b6ae3ad 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ You can install any package using the .NET CLI or NuGet Package Manager. |PosInformatique.Foundations.People.FluentValidation icon|[**PosInformatique.Foundations.People.FluentValidation**](./src/People.FluentValidation/README.md) | [FluentValidation](https://fluentvalidation.net/) extensions for `FirstName` and `LastName` value objects. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.People.FluentValidation) | |PosInformatique.Foundations.People.Json icon|[**PosInformatique.Foundations.People.Json**](./src/People.Json/README.md) | `System.Text.Json` converters for `FirstName` and `LastName`, with validation and easy registration via `AddPeopleConverters()`. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.People.Json) | |PosInformatique.Foundations.PhoneNumbers icon|[**PosInformatique.Foundations.PhoneNumbers**](./src/PhoneNumbers/README.md) | Strongly-typed value object representing a phone number in E.164 format, with parsing (including region-aware local numbers), validation, comparison, and formatting helpers. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.PhoneNumbers)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers) | +|PosInformatique.Foundations.PhoneNumbers.EntityFramework icon|[**PosInformatique.Foundations.PhoneNumbers.EntityFramework**](./src/PhoneNumbers.EntityFramework/README.md) | Entity Framework Core integration for the `PhoneNumber` value object, mapping it to a SQL `PhoneNumber` column type backed by `VARCHAR(16)` using a dedicated value converter. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.PhoneNumbers.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.EntityFramework) | |PosInformatique.Foundations.PhoneNumbers.Json icon|[**PosInformatique.Foundations.PhoneNumbers.Json**](./src/PhoneNumbers.Json/README.md) | `System.Text.Json` converter for the `PhoneNumber` value object, enabling seamless serialization and deserialization of E.164 compliant phone numbers. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.PhoneNumbers.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.Json) | |PosInformatique.Foundations.Text.Templating icon|[**PosInformatique.Foundations.Text.Templating**](./src/Text.Templating/README.md) | Abstractions for text templating, including the `TextTemplate` base class and `ITextTemplateRenderContext` interface, to be used by concrete templating engine implementations such as Razor-based text templates. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Text.Templating)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating) | |PosInformatique.Foundations.Text.Templating.Razor icon|[**PosInformatique.Foundations.Text.Templating.Razor**](./src/Text.Templating.Razor/README.md) | Razor-based text templating using Blazor components, allowing generation of text from Razor views with a strongly-typed Model parameter and full dependency injection integration. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Text.Templating.Razor)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor) | diff --git a/src/PhoneNumbers.EntityFramework/CHANGELOG.md b/src/PhoneNumbers.EntityFramework/CHANGELOG.md new file mode 100644 index 0000000..6b792a8 --- /dev/null +++ b/src/PhoneNumbers.EntityFramework/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with the support Entity Framework persitance for PhoneNumber value object. diff --git a/src/PhoneNumbers.EntityFramework/PhoneNumberPropertyExtensions.cs b/src/PhoneNumbers.EntityFramework/PhoneNumberPropertyExtensions.cs new file mode 100644 index 0000000..b6e298f --- /dev/null +++ b/src/PhoneNumbers.EntityFramework/PhoneNumberPropertyExtensions.cs @@ -0,0 +1,53 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.EntityFrameworkCore +{ + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + using PosInformatique.Foundations.PhoneNumbers; + + /// + /// Contains extension methods to map a to a string column. + /// + public static class PhoneNumberPropertyExtensions + { + /// + /// Configures the specified to be mapped on a column with a SQL PhoneNumber type. + /// The PhoneNumber type must be mapped to a VARCHAR(320). + /// + /// Type of the property which must be . + /// Entity property to map in the . + /// The instance to configure the configuration of the property. + /// If the specified argument is . + /// If the specified generic type is not a . + public static PropertyBuilder IsPhoneNumber(this PropertyBuilder property) + { + ArgumentNullException.ThrowIfNull(property); + + if (typeof(T) != typeof(PhoneNumber)) + { + throw new ArgumentException($"The '{nameof(IsPhoneNumber)}()' method must be called on '{nameof(PhoneNumber)} class.", nameof(property)); + } + + return property + .IsUnicode(false) + .HasMaxLength(16) + .HasColumnType("PhoneNumber") + .HasConversion(PhoneNumberConverter.Instance); + } + + private sealed class PhoneNumberConverter : ValueConverter + { + private PhoneNumberConverter() + : base(v => v.ToString(), v => PhoneNumber.Parse(v, null)) + { + } + + public static PhoneNumberConverter Instance { get; } = new PhoneNumberConverter(); + } + } +} diff --git a/src/PhoneNumbers.EntityFramework/PhoneNumbers.EntityFramework.csproj b/src/PhoneNumbers.EntityFramework/PhoneNumbers.EntityFramework.csproj new file mode 100644 index 0000000..de87d00 --- /dev/null +++ b/src/PhoneNumbers.EntityFramework/PhoneNumbers.EntityFramework.csproj @@ -0,0 +1,30 @@ + + + + true + + + Entity Framework Core integration for the PhoneNumber value object, mapping it to a SQL PhoneNumber column type backed by VARCHAR(16) using a dedicated value converter. + + phone;phonenumber;entityframework;efcore;valueconverter;e164;database;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + diff --git a/src/PhoneNumbers.EntityFramework/README.md b/src/PhoneNumbers.EntityFramework/README.md new file mode 100644 index 0000000..d66f647 --- /dev/null +++ b/src/PhoneNumbers.EntityFramework/README.md @@ -0,0 +1,96 @@ +### PosInformatique.Foundations.PhoneNumbers.EntityFramework + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.PhoneNumbers.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.EntityFramework/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.PhoneNumbers.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.EntityFramework/) + +## Introduction + +This package provides Entity Framework Core integration for the `PhoneNumber` +value object from [PosInformatique.Foundations.PhoneNumbers](../PhoneNumbers/README.md). + +It allows you to map `PhoneNumber` properties to a database column of SQL type `PhoneNumber` +(backed by `VARCHAR(16)`), using a dedicated value converter. + +## Install + +You can install the package from [NuGet](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.EntityFramework/): + +```powershell +dotnet add package PosInformatique.Foundations.PhoneNumbers.EntityFramework +``` + +## Features + +- Entity Framework Core support for the `PhoneNumber` value object +- Simple extension method `IsPhoneNumber()` to configure properties +- Maps `PhoneNumber` to a SQL column with type `PhoneNumber` (`VARCHAR(16)`, non-Unicode) +- Uses a `ValueConverter` to convert between `PhoneNumber` and its E.164 string representation + +## Use cases + +- Persist `PhoneNumber` in your EF Core entities without manual conversion logic +- Keep strong typing in your domain model while storing normalized E.164 strings in the database +- Enforce a consistent database schema for phone numbers (custom `PhoneNumber` type mapped to `VARCHAR(16)`) + +## Examples + +> ⚠️ To use `IsPhoneNumber()`, you must first define the SQL type `PhoneNumber` mapped to `VARCHAR(16)` in your database. +> For SQL Server, you can create it with: + +```sql +CREATE TYPE MimeType FROM VARCHAR(16) NOT NULL; +``` + +### Configure a PhoneNumber property + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PosInformatique.Foundations.PhoneNumbers; + +public sealed class Customer +{ + public int Id { get; set; } + + public PhoneNumber Phone { get; set; } = default!; +} + +public sealed class CustomerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(c => c.Phone) + .IsPhoneNumber(); // Maps to SQL type PhoneNumber (VARCHAR(16)) + } +} +``` + +### Resulting database schema + +The `IsPhoneNumber()` extension configures the property as: + +- Non-Unicode (`IsUnicode(false)`) +- Maximum length: `16` +- Column type: `PhoneNumber` (which must be mapped in your database as `VARCHAR(16)`) + +For example, your database column should look like: + +```sql +Phone PhoneNumber NOT NULL +-- where `PhoneNumber` is mapped to VARCHAR(16) +``` + +### Value conversion + +Under the hood, the extension uses a `ValueConverter`: + +- When saving, `PhoneNumber` is converted to its E.164 string representation (via `ToString()`). +- When loading, the stored string is parsed back to a `PhoneNumber` instance. + +This ensures the database always stores the normalized E.164 value, while your code works with the strongly-typed `PhoneNumber` value object. + +## Links + +- [NuGet package: PhoneNumbers (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers/) +- [NuGet package: PhoneNumbers.EntityFramework](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.EntityFramework/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/src/PhoneNumbers/README.md b/src/PhoneNumbers/README.md index d583ea8..e1d54e0 100644 --- a/src/PhoneNumbers/README.md +++ b/src/PhoneNumbers/README.md @@ -155,5 +155,6 @@ Console.WriteLine(formatted); // "+33123456789" ## Links - [NuGet package (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers/) +- [NuGet package: PhoneNumbers.EntityFramework](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.EntityFramework/) - [NuGet package: PhoneNumbers.Json](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.Json/) - [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/tests/PhoneNumbers.EntityFramework.Tests/PhoneNumberPropertyExtensionsTest.cs b/tests/PhoneNumbers.EntityFramework.Tests/PhoneNumberPropertyExtensionsTest.cs new file mode 100644 index 0000000..0bd291b --- /dev/null +++ b/tests/PhoneNumbers.EntityFramework.Tests/PhoneNumberPropertyExtensionsTest.cs @@ -0,0 +1,134 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.EntityFrameworkCore.Tests +{ + using PosInformatique.Foundations.PhoneNumbers; + + public class PhoneNumberPropertyExtensionsTest + { + [Fact] + public void IsPhoneNumber() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("PhoneNumber"); + + property.GetColumnType().Should().Be("PhoneNumber"); + property.IsUnicode().Should().BeFalse(); + property.GetMaxLength().Should().Be(16); + } + + [Fact] + public void IsPhoneNumber_NullArgument() + { + var act = () => + { + PhoneNumberPropertyExtensions.IsPhoneNumber(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("property"); + } + + [Fact] + public void IsPhoneNumber_NotPhoneNumberProperty() + { + var builder = new ModelBuilder(); + var property = builder.Entity() + .Property(e => e.Id); + + var act = () => + { + property.IsPhoneNumber(); + }; + + act.Should().ThrowExactly() + .WithMessage("The 'IsPhoneNumber()' method must be called on 'PhoneNumber class. (Parameter 'property')") + .WithParameterName("property"); + } + + [Fact] + public void ConvertFromProvider() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("PhoneNumber"); + + var converter = property.GetValueConverter(); + + converter.ConvertFromProvider("+33111111111").Should().Be(PhoneNumber.Parse("+33111111111")); + } + + [Fact] + public void ConvertFromProvider_Null() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("PhoneNumber"); + + var converter = property.GetValueConverter(); + + converter.ConvertFromProvider(null).Should().BeNull(); + } + + [Fact] + public void ConvertToProvider() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("PhoneNumber"); + + var converter = property.GetValueConverter(); + + converter.ConvertToProvider(PhoneNumber.Parse("+33111111111")).Should().Be("+33111111111"); + } + + [Fact] + public void ConvertToProvider_WithNull() + { + var context = new DbContextMock(); + + var entity = context.Model.FindEntityType(typeof(EntityMock)); + var property = entity.GetProperty("PhoneNumber"); + + var converter = property.GetValueConverter(); + + converter.ConvertToProvider(null).Should().BeNull(); + } + + private class DbContextMock : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + optionsBuilder.UseSqlServer(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + var property = modelBuilder.Entity() + .Property(e => e.PhoneNumber); + + property.IsPhoneNumber().Should().BeSameAs(property); + } + } + + private class EntityMock + { + public int Id { get; set; } + + public PhoneNumber PhoneNumber { get; set; } + } + } +} \ No newline at end of file diff --git a/tests/PhoneNumbers.EntityFramework.Tests/PhoneNumbers.EntityFramework.Tests.csproj b/tests/PhoneNumbers.EntityFramework.Tests/PhoneNumbers.EntityFramework.Tests.csproj new file mode 100644 index 0000000..5123697 --- /dev/null +++ b/tests/PhoneNumbers.EntityFramework.Tests/PhoneNumbers.EntityFramework.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + From 901496d20df025bd77cc2ba97f87fb97a791148b Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 08:10:01 +0100 Subject: [PATCH 46/73] Add the PhoneNumber.FluentValidation package. --- PosInformatique.Foundations.slnx | 4 + README.md | 1 + src/PhoneNumbers.EntityFramework/README.md | 2 +- .../CHANGELOG.md | 2 + .../PhoneNumberValidator.cs | 34 +++++++ .../PhoneNumbers.FluentValidation.csproj | 30 +++++++ .../PhoneNumbersValidatorExtensions.cs | 35 ++++++++ src/PhoneNumbers.FluentValidation/README.md | 88 +++++++++++++++++++ src/PhoneNumbers/README.md | 1 + .../PhoneNumberValidatorTest.cs | 58 ++++++++++++ ...PhoneNumbers.FluentValidation.Tests.csproj | 11 +++ .../PhoneNumbersValidatorExtensionsTest.cs | 37 ++++++++ 12 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 src/PhoneNumbers.FluentValidation/CHANGELOG.md create mode 100644 src/PhoneNumbers.FluentValidation/PhoneNumberValidator.cs create mode 100644 src/PhoneNumbers.FluentValidation/PhoneNumbers.FluentValidation.csproj create mode 100644 src/PhoneNumbers.FluentValidation/PhoneNumbersValidatorExtensions.cs create mode 100644 src/PhoneNumbers.FluentValidation/README.md create mode 100644 tests/PhoneNumbers.FluentValidation.Tests/PhoneNumberValidatorTest.cs create mode 100644 tests/PhoneNumbers.FluentValidation.Tests/PhoneNumbers.FluentValidation.Tests.csproj create mode 100644 tests/PhoneNumbers.FluentValidation.Tests/PhoneNumbersValidatorExtensionsTest.cs diff --git a/PosInformatique.Foundations.slnx b/PosInformatique.Foundations.slnx index 93f2790..cebc72f 100644 --- a/PosInformatique.Foundations.slnx +++ b/PosInformatique.Foundations.slnx @@ -94,6 +94,10 @@ + + + + diff --git a/README.md b/README.md index b6ae3ad..ab935b5 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ You can install any package using the .NET CLI or NuGet Package Manager. |PosInformatique.Foundations.People.Json icon|[**PosInformatique.Foundations.People.Json**](./src/People.Json/README.md) | `System.Text.Json` converters for `FirstName` and `LastName`, with validation and easy registration via `AddPeopleConverters()`. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.People.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.People.Json) | |PosInformatique.Foundations.PhoneNumbers icon|[**PosInformatique.Foundations.PhoneNumbers**](./src/PhoneNumbers/README.md) | Strongly-typed value object representing a phone number in E.164 format, with parsing (including region-aware local numbers), validation, comparison, and formatting helpers. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.PhoneNumbers)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers) | |PosInformatique.Foundations.PhoneNumbers.EntityFramework icon|[**PosInformatique.Foundations.PhoneNumbers.EntityFramework**](./src/PhoneNumbers.EntityFramework/README.md) | Entity Framework Core integration for the `PhoneNumber` value object, mapping it to a SQL `PhoneNumber` column type backed by `VARCHAR(16)` using a dedicated value converter. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.PhoneNumbers.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.EntityFramework) | +|PosInformatique.Foundations.PhoneNumbers.FluentValidation icon|[**PosInformatique.Foundations.PhoneNumbers.FluentValidation**](./src/PhoneNumbers.FluentValidation/README.md) | FluentValidation integration for the `PhoneNumber` value object, providing dedicated validators and rules to ensure E.164 compliant phone numbers. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.PhoneNumbers.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.FluentValidation) | |PosInformatique.Foundations.PhoneNumbers.Json icon|[**PosInformatique.Foundations.PhoneNumbers.Json**](./src/PhoneNumbers.Json/README.md) | `System.Text.Json` converter for the `PhoneNumber` value object, enabling seamless serialization and deserialization of E.164 compliant phone numbers. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.PhoneNumbers.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.Json) | |PosInformatique.Foundations.Text.Templating icon|[**PosInformatique.Foundations.Text.Templating**](./src/Text.Templating/README.md) | Abstractions for text templating, including the `TextTemplate` base class and `ITextTemplateRenderContext` interface, to be used by concrete templating engine implementations such as Razor-based text templates. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Text.Templating)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating) | |PosInformatique.Foundations.Text.Templating.Razor icon|[**PosInformatique.Foundations.Text.Templating.Razor**](./src/Text.Templating.Razor/README.md) | Razor-based text templating using Blazor components, allowing generation of text from Razor views with a strongly-typed Model parameter and full dependency injection integration. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Text.Templating.Razor)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor) | diff --git a/src/PhoneNumbers.EntityFramework/README.md b/src/PhoneNumbers.EntityFramework/README.md index d66f647..918970b 100644 --- a/src/PhoneNumbers.EntityFramework/README.md +++ b/src/PhoneNumbers.EntityFramework/README.md @@ -1,4 +1,4 @@ -### PosInformatique.Foundations.PhoneNumbers.EntityFramework +# PosInformatique.Foundations.PhoneNumbers.EntityFramework [![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.PhoneNumbers.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.EntityFramework/) [![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.PhoneNumbers.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.EntityFramework/) diff --git a/src/PhoneNumbers.FluentValidation/CHANGELOG.md b/src/PhoneNumbers.FluentValidation/CHANGELOG.md new file mode 100644 index 0000000..5a5116c --- /dev/null +++ b/src/PhoneNumbers.FluentValidation/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with the support FluentValidation for the validation of EmailAddress value object. diff --git a/src/PhoneNumbers.FluentValidation/PhoneNumberValidator.cs b/src/PhoneNumbers.FluentValidation/PhoneNumberValidator.cs new file mode 100644 index 0000000..cd63bc0 --- /dev/null +++ b/src/PhoneNumbers.FluentValidation/PhoneNumberValidator.cs @@ -0,0 +1,34 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace FluentValidation +{ + using FluentValidation.Validators; + using PosInformatique.Foundations.PhoneNumbers; + + internal sealed class PhoneNumberValidator : PropertyValidator + { + public override string Name + { + get => "PhoneNumberValidator"; + } + + public override bool IsValid(ValidationContext context, string value) + { + if (value is not null) + { + return PhoneNumber.IsValid(value); + } + + return true; + } + + protected override string GetDefaultMessageTemplate(string errorCode) + { + return $"'{{PropertyName}}' must be a valid phone number in E.164 format."; + } + } +} \ No newline at end of file diff --git a/src/PhoneNumbers.FluentValidation/PhoneNumbers.FluentValidation.csproj b/src/PhoneNumbers.FluentValidation/PhoneNumbers.FluentValidation.csproj new file mode 100644 index 0000000..0abcbb5 --- /dev/null +++ b/src/PhoneNumbers.FluentValidation/PhoneNumbers.FluentValidation.csproj @@ -0,0 +1,30 @@ + + + + true + + + FluentValidation integration for the PhoneNumber value object, providing dedicated validators and rules to ensure E.164 compliant phone numbers. + + phone;phonenumber;fluentvalidation;validation;e164;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + diff --git a/src/PhoneNumbers.FluentValidation/PhoneNumbersValidatorExtensions.cs b/src/PhoneNumbers.FluentValidation/PhoneNumbersValidatorExtensions.cs new file mode 100644 index 0000000..f9533ea --- /dev/null +++ b/src/PhoneNumbers.FluentValidation/PhoneNumbersValidatorExtensions.cs @@ -0,0 +1,35 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace FluentValidation +{ + using PosInformatique.Foundations.PhoneNumbers; + + /// + /// Contains extension methods for FluentValidation to validate phone numbers. + /// + public static class PhoneNumbersValidatorExtensions + { + /// + /// Defines a validator that checks if a property is a valid phone number + /// (parsable by the class). + /// Validation fails if the value is not a valid phone number. + /// If the value is , validation succeeds. + /// Use the validator + /// to disallow values. + /// + /// The type of the object being validated. + /// The rule builder on which the validator is defined. + /// The instance to continue configuring the property validator. + /// If the specified argument is . + public static IRuleBuilderOptions MustBePhoneNumber(this IRuleBuilder ruleBuilder) + { + ArgumentNullException.ThrowIfNull(ruleBuilder); + + return ruleBuilder.SetValidator(new PhoneNumberValidator()); + } + } +} \ No newline at end of file diff --git a/src/PhoneNumbers.FluentValidation/README.md b/src/PhoneNumbers.FluentValidation/README.md new file mode 100644 index 0000000..756f282 --- /dev/null +++ b/src/PhoneNumbers.FluentValidation/README.md @@ -0,0 +1,88 @@ +# PosInformatique.Foundations.PhoneNumbers.FluentValidation + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.PhoneNumbers.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.FluentValidation/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.PhoneNumbers.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.FluentValidation/) + +## Introduction + +This package provides [FluentValidation](https://fluentvalidation.net/) integration for the `PhoneNumber` value object +from [PosInformatique.Foundations.PhoneNumbers](../PhoneNumbers/README.md). + +It adds a dedicated validator and extension method to validate that string properties contain valid phone numbers +in **E.164** format, using the same parsing and validation logic as the core `PhoneNumber` type. + +## Install + +You can install the package from [NuGet](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.FluentValidation/): + +```powershell +dotnet add package PosInformatique.Foundations.PhoneNumbers.FluentValidation +``` + +## Features + +- FluentValidation integration for phone number validation +- Extension method `MustBePhoneNumber()` for `string` properties +- Validation based on the core `PhoneNumber.IsValid()` logic (E.164 format) +- `null` values are considered valid by default (combine with `NotNull()` / `NotEmpty()` when needed) +- Consistent validation rules across your application + +## Use cases + +- Validate incoming DTOs or commands that contain phone numbers as strings +- Ensure only valid E.164 phone numbers are accepted at the boundaries of your system +- Reuse the same validation logic used by the `PhoneNumber` value object everywhere + +## Examples + +### Basic validation with MustBePhoneNumber + +```csharp +using FluentValidation; + +public sealed class ContactDto +{ + public string Name { get; set; } = default!; + public string? Mobile { get; set; } +} + +public sealed class ContactDtoValidator : AbstractValidator +{ + public ContactDtoValidator() + { + RuleFor(x => x.Mobile) + .MustBePhoneNumber(); // Validates Mobile as an E.164 phone number (or null) + } +} +``` + +- If `Mobile` is `null`, the rule passes. +- If `Mobile` is not `null`, it must be a valid phone number in E.164 format, otherwise validation fails with the default message: + - `"'Mobile' must be a valid phone number in E.164 format."` + +### Combine with NotNull / NotEmpty + +If you want to make the phone number mandatory, combine `MustBePhoneNumber()` with standard FluentValidation rules: + +```csharp +public sealed class RequiredContactDtoValidator : AbstractValidator +{ + public RequiredContactDtoValidator() + { + RuleFor(x => x.Mobile) + .NotEmpty() + .MustBePhoneNumber(); + } +} +``` + +This enforces: + +- `Mobile` is not `null` or empty. +- `Mobile` must be a valid phone number in E.164 format. + +## Links + +- [NuGet package: PhoneNumbers (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers/) +- [NuGet package: PhoneNumbers.FluentValidation](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.FluentValidation/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/src/PhoneNumbers/README.md b/src/PhoneNumbers/README.md index e1d54e0..14a55ba 100644 --- a/src/PhoneNumbers/README.md +++ b/src/PhoneNumbers/README.md @@ -156,5 +156,6 @@ Console.WriteLine(formatted); // "+33123456789" - [NuGet package (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers/) - [NuGet package: PhoneNumbers.EntityFramework](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.EntityFramework/) +- [NuGet package: PhoneNumbers.FluentValidation](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.FluentValidation/) - [NuGet package: PhoneNumbers.Json](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.Json/) - [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/tests/PhoneNumbers.FluentValidation.Tests/PhoneNumberValidatorTest.cs b/tests/PhoneNumbers.FluentValidation.Tests/PhoneNumberValidatorTest.cs new file mode 100644 index 0000000..86b4afc --- /dev/null +++ b/tests/PhoneNumbers.FluentValidation.Tests/PhoneNumberValidatorTest.cs @@ -0,0 +1,58 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace FluentValidation.Tests +{ + using FluentValidation.Validators; + using PosInformatique.Foundations.PhoneNumbers; + + public class PhoneNumberValidatorTest + { + [Fact] + public void Constructor() + { + var validator = new PhoneNumberValidator(); + + validator.Name.Should().Be("PhoneNumberValidator"); + } + + [Fact] + public void GetDefaultMessageTemplate() + { + var validator = new PhoneNumberValidator(); + + validator.As().GetDefaultMessageTemplate(default).Should().Be("'{PropertyName}' must be a valid phone number in E.164 format."); + } + + [Theory] + [MemberData(nameof(PhoneNumberTestData.ValidPhoneNumbers), MemberType = typeof(PhoneNumberTestData))] +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + public void IsValid_True(string phoneNumber, string _) +#pragma warning restore SA1313 // Parameter names should begin with lower-case letter + { + var validator = new PhoneNumberValidator(); + + validator.IsValid(default, phoneNumber).Should().BeTrue(); + } + + [Fact] + public void IsValid_WithNull() + { + var validator = new PhoneNumberValidator(); + + validator.IsValid(default!, null!).Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(PhoneNumberTestData.InvalidPhoneNumbers), MemberType = typeof(PhoneNumberTestData))] + public void IsValid_False(string phoneNumber) + { + var validator = new PhoneNumberValidator(); + + validator.IsValid(default, phoneNumber).Should().BeFalse(); + } + } +} \ No newline at end of file diff --git a/tests/PhoneNumbers.FluentValidation.Tests/PhoneNumbers.FluentValidation.Tests.csproj b/tests/PhoneNumbers.FluentValidation.Tests/PhoneNumbers.FluentValidation.Tests.csproj new file mode 100644 index 0000000..6d94caa --- /dev/null +++ b/tests/PhoneNumbers.FluentValidation.Tests/PhoneNumbers.FluentValidation.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/PhoneNumbers.FluentValidation.Tests/PhoneNumbersValidatorExtensionsTest.cs b/tests/PhoneNumbers.FluentValidation.Tests/PhoneNumbersValidatorExtensionsTest.cs new file mode 100644 index 0000000..9ff6a5b --- /dev/null +++ b/tests/PhoneNumbers.FluentValidation.Tests/PhoneNumbersValidatorExtensionsTest.cs @@ -0,0 +1,37 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace FluentValidation.Tests +{ + public class PhoneNumbersValidatorExtensionsTest + { + [Fact] + public void MustBePhoneNumber() + { + var options = Mock.Of>(MockBehavior.Strict); + + var ruleBuilder = new Mock>(MockBehavior.Strict); + ruleBuilder.Setup(rb => rb.SetValidator(It.IsNotNull>())) + .Returns(options); + + ruleBuilder.Object.MustBePhoneNumber().Should().BeSameAs(options); + + ruleBuilder.VerifyAll(); + } + + [Fact] + public void MustBePhoneNumber_NullRuleBuilderArgument() + { + var act = () => + { + PhoneNumbersValidatorExtensions.MustBePhoneNumber((IRuleBuilder)null); + }; + + act.Should().ThrowExactly() + .WithParameterName("ruleBuilder"); + } + } +} \ No newline at end of file From 46fb9ec62ac5280d504200112d629ac5adc6abe4 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 08:50:53 +0100 Subject: [PATCH 47/73] Add the Text.Templating.Scriban implementation. --- Directory.Packages.props | 2 + PosInformatique.Foundations.slnx | 4 + README.md | 3 +- src/Emailing/README.md | 4 +- .../CHANGELOG.md | 2 +- .../RazorTextTemplate.cs | 2 +- src/Text.Templating.Scriban/CHANGELOG.md | 2 + src/Text.Templating.Scriban/README.md | 193 ++++++++++++++++++ .../ScribanTextTemplate.cs | 73 +++++++ .../Text.Templating.Scriban.csproj | 39 ++++ src/Text.Templating/README.md | 7 +- .../ScribanTextTemplateTest.cs | 94 +++++++++ .../Text.Templating.Scriban.Tests.csproj | 8 + 13 files changed, 427 insertions(+), 6 deletions(-) create mode 100644 src/Text.Templating.Scriban/CHANGELOG.md create mode 100644 src/Text.Templating.Scriban/README.md create mode 100644 src/Text.Templating.Scriban/ScribanTextTemplate.cs create mode 100644 src/Text.Templating.Scriban/Text.Templating.Scriban.csproj create mode 100644 tests/Text.Templating.Scriban.Tests/ScribanTextTemplateTest.cs create mode 100644 tests/Text.Templating.Scriban.Tests/Text.Templating.Scriban.Tests.csproj diff --git a/Directory.Packages.props b/Directory.Packages.props index d78b76e..7c831b4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + @@ -20,6 +21,7 @@ + diff --git a/PosInformatique.Foundations.slnx b/PosInformatique.Foundations.slnx index cebc72f..45e2576 100644 --- a/PosInformatique.Foundations.slnx +++ b/PosInformatique.Foundations.slnx @@ -110,4 +110,8 @@ + + + + diff --git a/README.md b/README.md index ab935b5..890eade 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,9 @@ You can install any package using the .NET CLI or NuGet Package Manager. |PosInformatique.Foundations.PhoneNumbers.Json icon|[**PosInformatique.Foundations.PhoneNumbers.Json**](./src/PhoneNumbers.Json/README.md) | `System.Text.Json` converter for the `PhoneNumber` value object, enabling seamless serialization and deserialization of E.164 compliant phone numbers. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.PhoneNumbers.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.PhoneNumbers.Json) | |PosInformatique.Foundations.Text.Templating icon|[**PosInformatique.Foundations.Text.Templating**](./src/Text.Templating/README.md) | Abstractions for text templating, including the `TextTemplate` base class and `ITextTemplateRenderContext` interface, to be used by concrete templating engine implementations such as Razor-based text templates. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Text.Templating)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating) | |PosInformatique.Foundations.Text.Templating.Razor icon|[**PosInformatique.Foundations.Text.Templating.Razor**](./src/Text.Templating.Razor/README.md) | Razor-based text templating using Blazor components, allowing generation of text from Razor views with a strongly-typed Model parameter and full dependency injection integration. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Text.Templating.Razor)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor) | +|PosInformatique.Foundations.Text.Templating.Scriban icon|[**PosInformatique.Foundations.Text.Templating.Scriban**](./src/Text.Templating.Scriban/README.md) | Scriban-based text templating with mustache-style syntax, allowing generation of text from templates using a strongly-typed model and automatic property exposure. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Text.Templating.Scriban)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Scriban) | -> Note: Each package is completely independent. You install only what you need. +> Note: Most of the packages are completely independent. You install only what you need. ## 🚀 Why use PosInformatique.Foundations? diff --git a/src/Emailing/README.md b/src/Emailing/README.md index e8c68ca..8c68c73 100644 --- a/src/Emailing/README.md +++ b/src/Emailing/README.md @@ -230,5 +230,7 @@ The typical flow is: ## Links - [NuGet package: Emailing (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing/) -- [NuGet package: Emailing.Azure (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure/) +- [NuGet package: Emailing.Azure](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure/) +- [NuGet package: Text.Templating.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor/) +- [NuGet package: Text.Templating.Scriban](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Scriban/) - [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/src/PhoneNumbers.FluentValidation/CHANGELOG.md b/src/PhoneNumbers.FluentValidation/CHANGELOG.md index 5a5116c..8c2a47f 100644 --- a/src/PhoneNumbers.FluentValidation/CHANGELOG.md +++ b/src/PhoneNumbers.FluentValidation/CHANGELOG.md @@ -1,2 +1,2 @@ 1.0.0 - - Initial release with the support FluentValidation for the validation of EmailAddress value object. + - Initial release with the support FluentValidation for the validation of PhoneNumber value object. diff --git a/src/Text.Templating.Razor/RazorTextTemplate.cs b/src/Text.Templating.Razor/RazorTextTemplate.cs index 9df079e..8d30e9d 100644 --- a/src/Text.Templating.Razor/RazorTextTemplate.cs +++ b/src/Text.Templating.Razor/RazorTextTemplate.cs @@ -9,7 +9,7 @@ namespace PosInformatique.Foundations.Text.Templating.Razor using Microsoft.Extensions.DependencyInjection; /// - /// Implementation of the which generates using a Razor component. + /// Implementation of the which generates text using a Razor component as text template. /// /// Type of the data model to inject to the Razor component. public class RazorTextTemplate : TextTemplate diff --git a/src/Text.Templating.Scriban/CHANGELOG.md b/src/Text.Templating.Scriban/CHANGELOG.md new file mode 100644 index 0000000..2e703b8 --- /dev/null +++ b/src/Text.Templating.Scriban/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with the Razor Text Templating feature. diff --git a/src/Text.Templating.Scriban/README.md b/src/Text.Templating.Scriban/README.md new file mode 100644 index 0000000..2d77dbb --- /dev/null +++ b/src/Text.Templating.Scriban/README.md @@ -0,0 +1,193 @@ +# PosInformatique.Foundations.Text.Templating.Scriban + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.Text.Templating.Scriban)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Scriban/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.Text.Templating.Scriban)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Scriban/) + +## Introduction + +This package provides a simple way to generate text using [Scriban](https://github.com/scriban/scriban) templates. + +You define a Scriban template string with mustache-style syntax (`{{ }}`) and the library renders it to a +`TextWriter` by using a `ScribanTextTemplate` implementation. +The model properties are automatically exposed to the template. + +## Install + +You can install the package from [NuGet](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Scriban/): + +```powershell +dotnet add package PosInformatique.Foundations.Text.Templating.Scriban +``` + +## Features + +- Render text from Scriban templates using mustache-style syntax +- Strongly-typed model with automatic property exposure +- Supports both POCO objects and `ExpandoObject` +- Simple integration with `ITextTemplateRenderContext` +- Lightweight and fast text generation + +## Basic usage + +### 1. Create a model + +Define a model class with the data you want to render: + +```csharp +public class EmailModel +{ + public string Name { get; set; } + public string Email { get; set; } + public DateTime Date { get; set; } +} +``` + +### 2. Create a Scriban template + +Define a Scriban template string using mustache-style syntax: + +```csharp +var templateContent = @" +Hello {{ Name }}, + +Your email address is: {{ Email }} +Today is: {{ Date }} + +Thank you! +"; +``` + +### 3. Use `ScribanTextTemplate.RenderAsync()` + +Create a `ScribanTextTemplate` instance and call `RenderAsync()`: + +```csharp +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using PosInformatique.Foundations.Text.Templating; +using PosInformatique.Foundations.Text.Templating.Scriban; + +// Example of a simple ITextTemplateRenderContext implementation +public class TextTemplateRenderContext : ITextTemplateRenderContext +{ + public TextTemplateRenderContext(IServiceProvider serviceProvider) + { + this.ServiceProvider = serviceProvider; + } + + public IServiceProvider ServiceProvider { get; } +} + +public static class ScribanTemplateSample +{ + public static async Task GenerateAsync() + { + var templateContent = @" +Hello {{ Name }}, + +Your email address is: {{ Email }} +Today is: {{ Date }} + +Thank you! +"; + + // Create the Scriban text template + var template = new ScribanTextTemplate(templateContent); + + // Create a model + var model = new EmailModel + { + Name = "John Doe", + Email = "john.doe@example.com", + Date = DateTime.UtcNow + }; + + // Build the context (can use an empty IServiceProvider if not needed) + var context = new TextTemplateRenderContext(serviceProvider: null); + + using var writer = new StringWriter(); + + // Render the template + await template.RenderAsync(model, writer, context, CancellationToken.None); + + var result = writer.ToString(); + Console.WriteLine(result); + } +} +``` + +Output: + +``` +Hello John Doe, + +Your email address is: john.doe@example.com +Today is: 2025-01-16 10:30:00 + +Thank you! +``` + +### 4. Using ExpandoObject + +You can also use `ExpandoObject` for dynamic models: + +```csharp +dynamic model = new ExpandoObject(); +model.Name = "Alice"; +model.Email = "alice@example.com"; +model.Date = DateTime.UtcNow; + +var templateContent = "Hello {{ Name }}, your email is {{ Email }}."; +var template = new ScribanTextTemplate(templateContent); + +using var writer = new StringWriter(); +await template.RenderAsync(model, writer, context, CancellationToken.None); + +Console.WriteLine(writer.ToString()); +// Output: Hello Alice, your email is alice@example.com. +``` + +## Scriban syntax + +Scriban supports a rich template syntax. Here are some common examples: + +### Variables + +``` +Hello {{ Name }}! +``` + +### Conditionals + +``` +{{ if IsActive }} +User is active +{{ else }} +User is inactive +{{ end }} +``` + +### Loops + +``` +{{ for item in Items }} +- {{ item.Name }} +{{ end }} +``` + +### Filters + +``` +{{ Name | upcase }} +{{ Date | date.to_string '%Y-%m-%d' }} +``` + +For more details, see the [Scriban documentation](https://github.com/scriban/scriban/blob/master/doc/language.md). + +## Links + +- [NuGet package: Text.Templating (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating/) +- [NuGet package: Text.Templating.Scriban](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Scriban/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) +- [Scriban GitHub repository](https://github.com/scriban/scriban) \ No newline at end of file diff --git a/src/Text.Templating.Scriban/ScribanTextTemplate.cs b/src/Text.Templating.Scriban/ScribanTextTemplate.cs new file mode 100644 index 0000000..df600a4 --- /dev/null +++ b/src/Text.Templating.Scriban/ScribanTextTemplate.cs @@ -0,0 +1,73 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Text.Templating.Scriban +{ + using System.Dynamic; + using System.IO; + using global::Scriban; + using global::Scriban.Runtime; + + /// + /// Implementation of the which generates text using a Scriban as text template. + /// + /// Type of the data model to inject to the Scriban text template. + public sealed class ScribanTextTemplate : TextTemplate + { + private readonly string content; + + /// + /// Initializes a new instance of the class + /// with the specified Scriban text template . + /// + /// Scriban text template to use. + public ScribanTextTemplate(string content) + { + ArgumentNullException.ThrowIfNull(content); + + this.content = content; + } + + /// + public override async Task RenderAsync(TModel model, TextWriter output, ITextTemplateRenderContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(context); + + var scriptObject = new ScriptObject(); + + if (model is ExpandoObject expandoData) + { + foreach (var property in (IDictionary)expandoData) + { + scriptObject.Add(property.Key, property.Value); + } + } + else + { + foreach (var property in model.GetType().GetProperties()) + { + scriptObject.Add(property.Name, property.GetValue(model)); + } + } + + var scribanContext = new TemplateContext() + { + MemberRenamer = r => r.Name, + MemberFilter = null, + }; + + scribanContext.PushGlobal(scriptObject); + + var scribanTemplate = Template.Parse(this.content); + + var text = await scribanTemplate.RenderAsync(scribanContext); + + await output.WriteAsync(text); + } + } +} \ No newline at end of file diff --git a/src/Text.Templating.Scriban/Text.Templating.Scriban.csproj b/src/Text.Templating.Scriban/Text.Templating.Scriban.csproj new file mode 100644 index 0000000..5339468 --- /dev/null +++ b/src/Text.Templating.Scriban/Text.Templating.Scriban.csproj @@ -0,0 +1,39 @@ + + + + true + + + Provides Scriban-based text templating with mustache-style syntax. + Allows generating text from Scriban templates with a strongly-typed Model + and automatic property exposure using simple {{ }} syntax. + + + scriban;text;templating;mustache;template;rendering;textgeneration;dotnet;posinformatique + + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Text.Templating/README.md b/src/Text.Templating/README.md index 62bc835..6812a46 100644 --- a/src/Text.Templating/README.md +++ b/src/Text.Templating/README.md @@ -11,6 +11,7 @@ It defines the `TextTemplate` base class and the `ITextTemplateRenderCon Currently only the following text engine implementation are provided in [PosInformatique.Foundations](https://github.com/PosInformatique/PosInformatique.Foundations): - [PosInformatique.Foundations.Text.Templating.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor/) +- [PosInformatique.Foundations.Text.Templating.Scriban](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Scriban/) ## Install @@ -33,9 +34,11 @@ This package only provides the abstraction (base classes and interfaces). To actually render templates using Razor components, use one of the dedicated implementation package: - [PosInformatique.Foundations.Text.Templating.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor/) +- [PosInformatique.Foundations.Text.Templating.Scriban](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Scriban/) ## Links -- [NuGet package (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating/) -- [NuGet package (Text.Templating.Razor)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor/) +- [NuGet package: Text.Templating (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating/) +- [NuGet package: Text.Templating.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor/) +- [NuGet package: Text.Templating.Scriban](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Scriban/) - [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/tests/Text.Templating.Scriban.Tests/ScribanTextTemplateTest.cs b/tests/Text.Templating.Scriban.Tests/ScribanTextTemplateTest.cs new file mode 100644 index 0000000..20551c8 --- /dev/null +++ b/tests/Text.Templating.Scriban.Tests/ScribanTextTemplateTest.cs @@ -0,0 +1,94 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Text.Templating.Scriban.Tests +{ + using System.Dynamic; + using PosInformatique.Foundations.People; + + public class ScribanTextTemplateTest + { + [Fact] + public async Task RenderAsync() + { + var cancellationToken = new CancellationTokenSource().Token; + + var data = new + { + FirstName = FirstName.Create("Gilles"), + LastName = LastName.Create("TOURREAU"), + Subject = "The subject", + InnerObject = new + { + Age = 1234, + }, + }; + + var context = Mock.Of(MockBehavior.Strict); + + using var output = new StringWriter(); + + var textTemplating = new ScribanTextTemplate("FirstName='{{FirstName}}', LastName='{{LastName}}', Age={{InnerObject.Age}}, Subject={{Subject}}"); + + await textTemplating.RenderAsync(data, output, context, cancellationToken); + + output.ToString().Should().Be("FirstName='Gilles', LastName='TOURREAU', Age=1234, Subject=The subject"); + } + + [Fact] + public async Task RenderAsync_UsingExpando() + { + var cancellationToken = new CancellationTokenSource().Token; + + var data = new ExpandoObject(); + + data.As>()["FirstName"] = FirstName.Create("Gilles"); + data.As>()["LastName"] = LastName.Create("TOURREAU"); + data.As>()["InnerObject"] = new { Age = 1234 }; + data.As>()["Subject"] = "The subject"; + + var context = Mock.Of(MockBehavior.Strict); + + using var output = new StringWriter(); + + var textTemplating = new ScribanTextTemplate("FirstName='{{FirstName}}', LastName='{{LastName}}', Age={{InnerObject.Age}}, Subject={{Subject}}"); + + await textTemplating.RenderAsync(data, output, context, cancellationToken); + + output.ToString().Should().Be("FirstName='Gilles', LastName='TOURREAU', Age=1234, Subject=The subject"); + } + + [Fact] + public async Task RenderAsync_WithModelNullArgument() + { + var textTemplating = new ScribanTextTemplate("FirstName='{{FirstName}}', LastName='{{LastName}}', Age={{InnerObject.Age}}, Subject={{Subject}}"); + + await textTemplating.Invoking(r => r.RenderAsync(null, default, default, default)) + .Should().ThrowExactlyAsync() + .WithParameterName("model"); + } + + [Fact] + public async Task RenderAsync_WithOutputNullArgument() + { + var textTemplating = new ScribanTextTemplate("FirstName='{{FirstName}}', LastName='{{LastName}}', Age={{InnerObject.Age}}, Subject={{Subject}}"); + + await textTemplating.Invoking(r => r.RenderAsync(new object(), null, default, default)) + .Should().ThrowExactlyAsync() + .WithParameterName("output"); + } + + [Fact] + public async Task RenderAsync_WithContextNullArgument() + { + var textTemplating = new ScribanTextTemplate("FirstName='{{FirstName}}', LastName='{{LastName}}', Age={{InnerObject.Age}}, Subject={{Subject}}"); + + await textTemplating.Invoking(r => r.RenderAsync(new object(), new StringWriter(), null, default)) + .Should().ThrowExactlyAsync() + .WithParameterName("context"); + } + } +} \ No newline at end of file diff --git a/tests/Text.Templating.Scriban.Tests/Text.Templating.Scriban.Tests.csproj b/tests/Text.Templating.Scriban.Tests/Text.Templating.Scriban.Tests.csproj new file mode 100644 index 0000000..bcacba5 --- /dev/null +++ b/tests/Text.Templating.Scriban.Tests/Text.Templating.Scriban.Tests.csproj @@ -0,0 +1,8 @@ + + + + + + + + From 3408cad77a77f739acd749571fc7b77e845a38d2 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 08:52:07 +0100 Subject: [PATCH 48/73] Centralize .NET version. --- Directory.Packages.props | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 7c831b4..086532b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,6 +1,10 @@  true + + + 9.0.11 + @@ -8,15 +12,15 @@ - - - + + + - - - - - + + + + + From f24c802a1c879e4ba19b98dda44e31a547507443 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 08:52:56 +0100 Subject: [PATCH 49/73] Upgrade PosInformatique.Moq.Analyzers. --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 086532b..7a798e3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,7 +24,7 @@ - + From 0c7fa5ac6f0a1a2e102942b81808cd160325619b Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 08:56:42 +0100 Subject: [PATCH 50/73] IParsable for EmailAddress is now implemented explictly. --- src/EmailAddresses/EmailAddress.cs | 42 +++++++++---------- .../EmailAddresses.Tests/EmailAddressTest.cs | 22 +++++++--- 2 files changed, 38 insertions(+), 26 deletions(-) diff --git a/src/EmailAddresses/EmailAddress.cs b/src/EmailAddresses/EmailAddress.cs index 28e50c1..e1e9d9d 100644 --- a/src/EmailAddresses/EmailAddress.cs +++ b/src/EmailAddresses/EmailAddress.cs @@ -82,7 +82,7 @@ public static implicit operator EmailAddress(string emailAddress) { ArgumentNullException.ThrowIfNull(emailAddress); - return Parse(emailAddress, null); + return Parse(emailAddress); } /// @@ -162,7 +162,12 @@ public static EmailAddress Parse(string s) { ArgumentNullException.ThrowIfNull(s); - return Parse(s, null); + if (!TryParse(s, out var result)) + { + throw new FormatException($"'{s}' is not a valid email address."); + } + + return result; } /// @@ -173,16 +178,11 @@ public static EmailAddress Parse(string s) /// An instance. /// Thrown when the argument is . /// Thrown when the string is not a valid email address. - public static EmailAddress Parse(string s, IFormatProvider? provider) + static EmailAddress IParsable.Parse(string s, IFormatProvider? provider) { ArgumentNullException.ThrowIfNull(s); - if (!TryParse(s, out var result)) - { - throw new FormatException($"'{s}' is not a valid email address."); - } - - return result; + return Parse(s); } /// @@ -192,18 +192,6 @@ public static EmailAddress Parse(string s, IFormatProvider? provider) /// When this method returns, contains the parsed if the parsing succeeded, or if it failed. /// if the parsing succeeded; otherwise, . public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)][NotNullWhen(true)] out EmailAddress? result) - { - return TryParse(s, null, out result); - } - - /// - /// Tries to parse a string representation of an email address using the specified format provider. - /// - /// The string to parse. - /// The format provider (not used in this implementation). - /// When this method returns, contains the parsed if the parsing succeeded, or if it failed. - /// if the parsing succeeded; otherwise, . - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)][NotNullWhen(true)] out EmailAddress? result) { var emailAddress = TryParse(s); @@ -217,6 +205,18 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov return true; } + /// + /// Tries to parse a string representation of an email address using the specified format provider. + /// + /// The string to parse. + /// The format provider (not used in this implementation). + /// When this method returns, contains the parsed if the parsing succeeded, or if it failed. + /// if the parsing succeeded; otherwise, . + static bool IParsable.TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)][NotNullWhen(true)] out EmailAddress? result) + { + return TryParse(s, out result); + } + /// /// Determines if the specified is a valid e-mail address. /// diff --git a/tests/EmailAddresses.Tests/EmailAddressTest.cs b/tests/EmailAddresses.Tests/EmailAddressTest.cs index 16585af..66b10de 100644 --- a/tests/EmailAddresses.Tests/EmailAddressTest.cs +++ b/tests/EmailAddresses.Tests/EmailAddressTest.cs @@ -52,7 +52,7 @@ public void Parse_WithFormatProvider(string emailAddress, string expectedEmailAd { var formatProvider = Mock.Of(MockBehavior.Strict); - var address = EmailAddress.Parse(emailAddress, formatProvider); + var address = CallParse(emailAddress, formatProvider); address.ToString().Should().Be(expectedEmailAddress); address.As().ToString(null, null).Should().Be(expectedEmailAddress); @@ -65,7 +65,7 @@ public void Parse_WithFormatProvider_WithNullArgument() { var act = () => { - EmailAddress.Parse(null, default); + CallParse(null, default); }; act.Should().ThrowExactly() @@ -78,7 +78,7 @@ public void Parse_WithFormatProvider_InvalidEmailAddress(string invalidEmailAddd { var formatProvider = Mock.Of(MockBehavior.Strict); - var act = () => EmailAddress.Parse(invalidEmailAdddress, formatProvider); + var act = () => CallParse(invalidEmailAdddress, formatProvider); act.Should().ThrowExactly() .WithMessage($"'{invalidEmailAdddress}' is not a valid email address."); @@ -119,7 +119,7 @@ public void TryParse_WithFormatProvider(string emailAddress, string expectedEmai { var formatProvider = Mock.Of(MockBehavior.Strict); - var result = EmailAddress.TryParse(emailAddress, formatProvider, out var address); + var result = CallTryParse(emailAddress, formatProvider, out var address); result.Should().BeTrue(); @@ -136,7 +136,7 @@ public void TryParse_WithFormatProvider_InvalidEmailAddress(string invalidEmailA { var formatProvider = Mock.Of(MockBehavior.Strict); - var result = EmailAddress.TryParse(invalidEmailAdddress, formatProvider, out var address); + var result = CallTryParse(invalidEmailAdddress, formatProvider, out var address); result.Should().BeFalse(); address.Should().BeNull(); @@ -363,5 +363,17 @@ public void Operator_GreaterThanOrEqual(string emailAddress1, string emailAddres { ((emailAddress1 is not null ? EmailAddress.Parse(emailAddress1) : null) >= (emailAddress2 is not null ? EmailAddress.Parse(emailAddress2) : null)).Should().Be(expectedResult); } + + private static T CallParse(string s, IFormatProvider formatProvider) + where T : IParsable + { + return T.Parse(s, formatProvider); + } + + private static bool CallTryParse(string s, IFormatProvider formatProvider, out T result) + where T : IParsable + { + return T.TryParse(s, formatProvider, out result); + } } } \ No newline at end of file From da70244e840d45a9bef8670db4d5129d6c386d12 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 09:00:07 +0100 Subject: [PATCH 51/73] IParsable for MimeType is now implemented explictly. --- src/MediaTypes/MimeType.cs | 40 +++++++++++++------------- src/MediaTypes/MimeTypes.cs | 20 ++++++------- tests/MediaTypes.Tests/MimeTypeTest.cs | 22 ++++++++++---- 3 files changed, 47 insertions(+), 35 deletions(-) diff --git a/src/MediaTypes/MimeType.cs b/src/MediaTypes/MimeType.cs index 3a5fd5f..ba4bc6f 100644 --- a/src/MediaTypes/MimeType.cs +++ b/src/MediaTypes/MimeType.cs @@ -68,7 +68,12 @@ public static MimeType Parse(string s) { ArgumentNullException.ThrowIfNull(s); - return Parse(s, null); + if (TryParse(s, out var result)) + { + return result; + } + + throw new FormatException("Invalid MIME type format."); } /// @@ -79,16 +84,11 @@ public static MimeType Parse(string s) /// A new instance representing the specified media type. /// Thrown when the argument is . /// Thrown when the string is not a valid media type. - public static MimeType Parse(string s, IFormatProvider? provider) + static MimeType IParsable.Parse(string s, IFormatProvider? provider) { ArgumentNullException.ThrowIfNull(s); - if (TryParse(s, out var result)) - { - return result; - } - - throw new FormatException("Invalid MIME type format."); + return Parse(s); } /// @@ -98,18 +98,6 @@ public static MimeType Parse(string s, IFormatProvider? provider) /// When this method returns, contains the parsed if the operation succeeded; otherwise, . /// if the string was successfully parsed; otherwise, . public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)][NotNullWhen(true)] out MimeType? result) - { - return TryParse(s, null, out result); - } - - /// - /// Tries to parse the specified string into a instance using the given format provider. - /// - /// The string that contains the media type, for example "application/json". - /// An optional format provider. This parameter is not used. - /// When this method returns, contains the parsed if the operation succeeded; otherwise, . - /// if the string was successfully parsed; otherwise, . - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)][NotNullWhen(true)] out MimeType? result) { result = null; @@ -136,6 +124,18 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov return true; } + /// + /// Tries to parse the specified string into a instance using the given format provider. + /// + /// The string that contains the media type, for example "application/json". + /// An optional format provider. This parameter is not used. + /// When this method returns, contains the parsed if the operation succeeded; otherwise, . + /// if the string was successfully parsed; otherwise, . + static bool IParsable.TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)][NotNullWhen(true)] out MimeType? result) + { + return TryParse(s, out result); + } + /// /// Gets the associated with the specified file extension. /// diff --git a/src/MediaTypes/MimeTypes.cs b/src/MediaTypes/MimeTypes.cs index 26d2924..51e3621 100644 --- a/src/MediaTypes/MimeTypes.cs +++ b/src/MediaTypes/MimeTypes.cs @@ -12,7 +12,7 @@ namespace PosInformatique.Foundations.MediaTypes /// public static class MimeTypes { - private static readonly Dictionary FromExtensions = new Dictionary() + private static readonly Dictionary FromExtensions = new() { { "pdf", Application.Pdf }, { "docx", Application.Docx }, @@ -27,7 +27,7 @@ public static class MimeTypes { "webp", Image.WebP }, }; - private static readonly Dictionary ToExtensions = new Dictionary() + private static readonly Dictionary ToExtensions = new() { { Application.Pdf, "pdf" }, { Application.Docx, "docx" }, @@ -75,17 +75,17 @@ public static class Application /// /// Gets the media type application/octet-stream. /// - public static MimeType OctetStream { get; } = MimeType.Parse("application/octet-stream", null); + public static MimeType OctetStream { get; } = MimeType.Parse("application/octet-stream"); /// /// Gets the media type application/pdf. /// - public static MimeType Pdf { get; } = MimeType.Parse("application/pdf", null); + public static MimeType Pdf { get; } = MimeType.Parse("application/pdf"); /// /// Gets the media type application/vnd.openxmlformats-officedocument.wordprocessingml.document. /// - public static MimeType Docx { get; } = MimeType.Parse("application/vnd.openxmlformats-officedocument.wordprocessingml.document", null); + public static MimeType Docx { get; } = MimeType.Parse("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); } /// @@ -96,7 +96,7 @@ public static class Image /// /// Gets the media type image/bmp. /// - public static MimeType Bmp { get; } = MimeType.Parse("image/bmp", null); + public static MimeType Bmp { get; } = MimeType.Parse("image/bmp"); /// /// Gets the media type image/x-dxf. @@ -111,22 +111,22 @@ public static class Image /// /// Gets the media type image/jpeg. /// - public static MimeType Jpeg { get; } = MimeType.Parse("image/jpeg", null); + public static MimeType Jpeg { get; } = MimeType.Parse("image/jpeg"); /// /// Gets the media type image/png. /// - public static MimeType Png { get; } = MimeType.Parse("image/png", null); + public static MimeType Png { get; } = MimeType.Parse("image/png"); /// /// Gets the media type image/tiff. /// - public static MimeType Tiff { get; } = MimeType.Parse("image/tiff", null); + public static MimeType Tiff { get; } = MimeType.Parse("image/tiff"); /// /// Gets the media type image/webp. /// - public static MimeType WebP { get; } = MimeType.Parse("image/webp", null); + public static MimeType WebP { get; } = MimeType.Parse("image/webp"); } } } \ No newline at end of file diff --git a/tests/MediaTypes.Tests/MimeTypeTest.cs b/tests/MediaTypes.Tests/MimeTypeTest.cs index 4b25b6d..4e4bc26 100644 --- a/tests/MediaTypes.Tests/MimeTypeTest.cs +++ b/tests/MediaTypes.Tests/MimeTypeTest.cs @@ -56,7 +56,7 @@ public void Parse_WithFormatProvider_Success(string input, string expectedType, { var formatProvider = Mock.Of(MockBehavior.Strict); - var mimeType = MimeType.Parse(input, formatProvider); + var mimeType = CallParse(input, formatProvider); mimeType.Type.Should().Be(expectedType); mimeType.Subtype.Should().Be(expectedSubtype); @@ -67,7 +67,7 @@ public void Parse_WithFormatProvider_WithNullArgument() { var act = () => { - MimeType.Parse(null, default); + CallParse(null, default); }; act.Should().ThrowExactly() @@ -87,7 +87,7 @@ public void Parse_WithFormatProvider_Failed(string input) var act = () => { - MimeType.Parse(input, formatProvider); + CallParse(input, formatProvider); }; act.Should().ThrowExactly() @@ -127,7 +127,7 @@ public void TryParse_WithFormatProvider_Success(string input, string expectedTyp { var formatProvider = Mock.Of(MockBehavior.Strict); - MimeType.TryParse(input, formatProvider, out var mimeType).Should().BeTrue(); + CallTryParse(input, formatProvider, out var mimeType).Should().BeTrue(); mimeType.Type.Should().Be(expectedType); mimeType.Subtype.Should().Be(expectedSubtype); @@ -145,7 +145,7 @@ public void TryParse_WithFormatProvider_Failed(string input) { var formatProvider = Mock.Of(MockBehavior.Strict); - MimeType.TryParse(input, formatProvider, out var mimeType).Should().BeFalse(); + CallTryParse(input, formatProvider, out var mimeType).Should().BeFalse(); mimeType.Should().BeNull(); } @@ -273,5 +273,17 @@ private static MimeType GetMimeTypeFromPath(string path) return (MimeType)propertySubType.GetValue(null); } + + private static T CallParse(string s, IFormatProvider formatProvider) + where T : IParsable + { + return T.Parse(s, formatProvider); + } + + private static bool CallTryParse(string s, IFormatProvider formatProvider, out T result) + where T : IParsable + { + return T.TryParse(s, formatProvider, out result); + } } } \ No newline at end of file From 85d12c98b7b86aa09fe91e1f9a0647db791d3b08 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 09:04:49 +0100 Subject: [PATCH 52/73] For MimeType implementation of the IFormattable interfacE. --- src/MediaTypes/MimeType.cs | 8 +++++++- src/MediaTypes/README.md | 1 + tests/MediaTypes.Tests/MimeTypeTest.cs | 8 ++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/MediaTypes/MimeType.cs b/src/MediaTypes/MimeType.cs index ba4bc6f..663ca01 100644 --- a/src/MediaTypes/MimeType.cs +++ b/src/MediaTypes/MimeType.cs @@ -12,7 +12,7 @@ namespace PosInformatique.Foundations.MediaTypes /// Represents an immutable media type (formerly known as MIME type), /// composed of a type and a subtype, such as application/json or image/png. /// - public sealed class MimeType : IEquatable, IParsable + public sealed class MimeType : IEquatable, IFormattable, IParsable { private MimeType(string type, string subtype) { @@ -190,6 +190,12 @@ public override string ToString() return $"{this.Type}/{this.Subtype}"; } + /// + string IFormattable.ToString(string? format, IFormatProvider? formatProvider) + { + return this.ToString(); + } + /// /// Gets the default file extension associated with this media type. /// diff --git a/src/MediaTypes/README.md b/src/MediaTypes/README.md index 1d2009b..2f69ae7 100644 --- a/src/MediaTypes/README.md +++ b/src/MediaTypes/README.md @@ -22,6 +22,7 @@ dotnet add package PosInformatique.Foundations.MediaTypes - Immutable `MimeType` value object (`type/subtype`, e.g. `application/json`, `image/png`). - Parsing and safe parsing from `string` (`Parse` / `TryParse`). +- Provides `IFormattable` and `IParsable` for seamless integration with .NET APIs - Resolve a `MimeType` from a file extension (with or without leading dot). - Resolve a default file extension from a `MimeType`. - Set of common `application/*` and `image/*` media types. diff --git a/tests/MediaTypes.Tests/MimeTypeTest.cs b/tests/MediaTypes.Tests/MimeTypeTest.cs index 4e4bc26..f6176f6 100644 --- a/tests/MediaTypes.Tests/MimeTypeTest.cs +++ b/tests/MediaTypes.Tests/MimeTypeTest.cs @@ -264,6 +264,14 @@ public void ToString_Test() mimeType.ToString().Should().Be("text/plain"); } + [Fact] + public void ToString_IFormattable_Test() + { + var mimeType = MimeType.Parse("text/plain"); + + mimeType.As().ToString(null, null).Should().Be("text/plain"); + } + private static MimeType GetMimeTypeFromPath(string path) { var properties = path.Split("."); From e4b967379c3ebc5c0316313f10dff1e65418d7c2 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 09:07:53 +0100 Subject: [PATCH 53/73] Renames EmailAddressJsonSerializerOptionsExtensions to EmailAddressesJsonSerializerOptionsExtensions. --- ...s => EmailAddressesJsonSerializerOptionsExtensions.cs} | 8 +++++--- ... EmailAddressesJsonSerializerOptionsExtensionsTest.cs} | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) rename src/EmailAddresses.Json/{EmailAddressJsonSerializerOptionsExtensions.cs => EmailAddressesJsonSerializerOptionsExtensions.cs} (82%) rename tests/EmailAddresses.Json.Tests/{EmailAddressJsonSerializerOptionsExtensionsTest.cs => EmailAddressesJsonSerializerOptionsExtensionsTest.cs} (81%) diff --git a/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs b/src/EmailAddresses.Json/EmailAddressesJsonSerializerOptionsExtensions.cs similarity index 82% rename from src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs rename to src/EmailAddresses.Json/EmailAddressesJsonSerializerOptionsExtensions.cs index 28504fb..48c4596 100644 --- a/src/EmailAddresses.Json/EmailAddressJsonSerializerOptionsExtensions.cs +++ b/src/EmailAddresses.Json/EmailAddressesJsonSerializerOptionsExtensions.cs @@ -1,17 +1,19 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) P.O.S Informatique. All rights reserved. // //----------------------------------------------------------------------- namespace System.Text.Json { + using PosInformatique.Foundations.EmailAddresses; using PosInformatique.Foundations.EmailAddresses.Json; /// - /// Contains extension methods to configure . + /// Contains extension methods to configure for + /// JSON serialization. /// - public static class EmailAddressJsonSerializerOptionsExtensions + public static class EmailAddressesJsonSerializerOptionsExtensions { /// /// Registers the to the . diff --git a/tests/EmailAddresses.Json.Tests/EmailAddressJsonSerializerOptionsExtensionsTest.cs b/tests/EmailAddresses.Json.Tests/EmailAddressesJsonSerializerOptionsExtensionsTest.cs similarity index 81% rename from tests/EmailAddresses.Json.Tests/EmailAddressJsonSerializerOptionsExtensionsTest.cs rename to tests/EmailAddresses.Json.Tests/EmailAddressesJsonSerializerOptionsExtensionsTest.cs index d0153ef..8d206a0 100644 --- a/tests/EmailAddresses.Json.Tests/EmailAddressJsonSerializerOptionsExtensionsTest.cs +++ b/tests/EmailAddresses.Json.Tests/EmailAddressesJsonSerializerOptionsExtensionsTest.cs @@ -1,5 +1,5 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) P.O.S Informatique. All rights reserved. // //----------------------------------------------------------------------- @@ -8,7 +8,7 @@ namespace System.Text.Json.Tests { using PosInformatique.Foundations.EmailAddresses.Json; - public class EmailAddressJsonSerializerOptionsExtensionsTest + public class EmailAddressesJsonSerializerOptionsExtensionsTest { [Fact] public void AddEmailAddressesConverters() @@ -32,7 +32,7 @@ public void AddEmailAddressesConverters_WithNullArgument() { var act = () => { - EmailAddressJsonSerializerOptionsExtensions.AddEmailAddressesConverters(null); + EmailAddressesJsonSerializerOptionsExtensions.AddEmailAddressesConverters(null); }; act.Should().ThrowExactly() From 47cbf6bf695fe743ca4a038a643af242911053b5 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 09:11:17 +0100 Subject: [PATCH 54/73] Add missing unit tests for PhoneNumber.TryParse() with null argument. --- tests/PhoneNumbers.Tests/PhoneNumberTest.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/PhoneNumbers.Tests/PhoneNumberTest.cs b/tests/PhoneNumbers.Tests/PhoneNumberTest.cs index a21fc10..0faa92c 100644 --- a/tests/PhoneNumbers.Tests/PhoneNumberTest.cs +++ b/tests/PhoneNumbers.Tests/PhoneNumberTest.cs @@ -128,6 +128,7 @@ public void TryParse_WithDefaultRegion(string phoneNumber, string expectedPhoneN [Theory] [MemberData(nameof(PhoneNumberTestData.InvalidPhoneNumbers), MemberType = typeof(PhoneNumberTestData))] + [InlineData(null)] public void TryParse_InvalidPhoneNumber(string invalidPhoneNumber) { var result = PhoneNumber.TryParse(invalidPhoneNumber, out var number); @@ -149,6 +150,7 @@ public void TryParse_IParsable(string phoneNumber, string expectedValue) [Theory] [MemberData(nameof(PhoneNumberTestData.InvalidPhoneNumbers), MemberType = typeof(PhoneNumberTestData))] + [InlineData(null)] public void TryParse_IParsable_InvalidPhoneNumber(string invalidPhoneNumber) { var result = CallTryParse(invalidPhoneNumber, null, out var number); From 8d5a0c64dbdb29e211f871d55a3cab5aa989582e Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 09:17:27 +0100 Subject: [PATCH 55/73] Add missing unit tests --- tests/.editorconfig | 5 ++++ .../RazorEmailTemplateBodyTest.cs | 27 +++++++++++++++++++ .../RazorEmailTemplateSubjectTest.cs | 27 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateBodyTest.cs create mode 100644 tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateSubjectTest.cs diff --git a/tests/.editorconfig b/tests/.editorconfig index 96ca6d5..69eeef4 100644 --- a/tests/.editorconfig +++ b/tests/.editorconfig @@ -1,5 +1,10 @@ [*.cs] +#### Blazor #### + +# BL0005: Component parameter should not be set outside of its component. +dotnet_diagnostic.BL0005.severity = none + #### Code Analysis #### # CA1806: Do not ignore method results diff --git a/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateBodyTest.cs b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateBodyTest.cs new file mode 100644 index 0000000..a54c3b0 --- /dev/null +++ b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateBodyTest.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Templates.Razor.Tests +{ + public class RazorEmailTemplateBodyTest + { + [Fact] + public void Constructor() + { + var template = Mock.Of>(MockBehavior.Strict); + + var model = new Model(); + + template.Model = model; + + template.Model.Should().BeSameAs(model); + } + + internal sealed class Model : EmailModel + { + } + } +} \ No newline at end of file diff --git a/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateSubjectTest.cs b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateSubjectTest.cs new file mode 100644 index 0000000..5c85984 --- /dev/null +++ b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateSubjectTest.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Templates.Razor.Tests +{ + public class RazorEmailTemplateSubjectTest + { + [Fact] + public void Constructor() + { + var template = Mock.Of>(MockBehavior.Strict); + + var model = new Model(); + + template.Model = model; + + template.Model.Should().BeSameAs(model); + } + + internal sealed class Model : EmailModel + { + } + } +} \ No newline at end of file From 22a1a60858a83da4da3e15c8d01a84d80d395030 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 09:18:28 +0100 Subject: [PATCH 56/73] Renames the EmailAddressValidatorExtensionsTest to EmailAddressesValidatorExtensions --- ...orExtensions.cs => EmailAddressesValidatorExtensions.cs} | 4 ++-- ...ionsTest.cs => EmailAddressesValidatorExtensionsTest.cs} | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) rename src/EmailAddresses.FluentValidation/{EmailAddressValidatorExtensions.cs => EmailAddressesValidatorExtensions.cs} (92%) rename tests/EmailAddresses.FluentValidation.Tests/{EmailAddressValidatorExtensionsTest.cs => EmailAddressesValidatorExtensionsTest.cs} (81%) diff --git a/src/EmailAddresses.FluentValidation/EmailAddressValidatorExtensions.cs b/src/EmailAddresses.FluentValidation/EmailAddressesValidatorExtensions.cs similarity index 92% rename from src/EmailAddresses.FluentValidation/EmailAddressValidatorExtensions.cs rename to src/EmailAddresses.FluentValidation/EmailAddressesValidatorExtensions.cs index 5aa167f..55f2467 100644 --- a/src/EmailAddresses.FluentValidation/EmailAddressValidatorExtensions.cs +++ b/src/EmailAddresses.FluentValidation/EmailAddressesValidatorExtensions.cs @@ -1,5 +1,5 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) P.O.S Informatique. All rights reserved. // //----------------------------------------------------------------------- @@ -11,7 +11,7 @@ namespace FluentValidation /// /// Contains extension methods for FluentValidation to validate e-mail addresses. /// - public static class EmailAddressValidatorExtensions + public static class EmailAddressesValidatorExtensions { /// /// Defines a validator that checks if a property is a valid e-mail address diff --git a/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorExtensionsTest.cs b/tests/EmailAddresses.FluentValidation.Tests/EmailAddressesValidatorExtensionsTest.cs similarity index 81% rename from tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorExtensionsTest.cs rename to tests/EmailAddresses.FluentValidation.Tests/EmailAddressesValidatorExtensionsTest.cs index 20d474e..d8847bf 100644 --- a/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorExtensionsTest.cs +++ b/tests/EmailAddresses.FluentValidation.Tests/EmailAddressesValidatorExtensionsTest.cs @@ -1,12 +1,12 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) P.O.S Informatique. All rights reserved. // //----------------------------------------------------------------------- namespace FluentValidation.Tests { - public class EmailAddressValidatorExtensionsTest + public class EmailAddressesValidatorExtensionsTest { [Fact] public void MustBeEmailAddress() @@ -27,7 +27,7 @@ public void MustBeEmailAddress_NullRuleBuilderArgument() { var act = () => { - EmailAddressValidatorExtensions.MustBeEmailAddress((IRuleBuilder)null); + EmailAddressesValidatorExtensions.MustBeEmailAddress((IRuleBuilder)null); }; act.Should().ThrowExactly() From eb5b17d0e3623c42b844f8017aa9da9fdaa17ed1 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 09:20:12 +0100 Subject: [PATCH 57/73] Fix the asynchronous check of the ThrowExactyAsync<>(). --- tests/Emailing.Tests/EmailManagerTest.cs | 4 ++-- .../RazorTextTemplateTest.cs | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/Emailing.Tests/EmailManagerTest.cs b/tests/Emailing.Tests/EmailManagerTest.cs index 72ba007..b3bf404 100644 --- a/tests/Emailing.Tests/EmailManagerTest.cs +++ b/tests/Emailing.Tests/EmailManagerTest.cs @@ -174,14 +174,14 @@ public async Task SendAsync() } [Fact] - public void SendAsync_WithNullIdentifier() + public async Task SendAsync_WithNullIdentifier() { var options = new EmailingOptions(); options.SenderEmailAddress = EmailAddress.Parse("sender@domain.com"); var manager = new EmailManager(Options.Create(options), default, default); - manager.Invoking(m => m.SendAsync(null, default)) + await manager.Invoking(m => m.SendAsync(null, default)) .Should().ThrowExactlyAsync() .WithParameterName("template"); } diff --git a/tests/Text.Templating.Razor.Tests/RazorTextTemplateTest.cs b/tests/Text.Templating.Razor.Tests/RazorTextTemplateTest.cs index ee6e59d..cfa41e4 100644 --- a/tests/Text.Templating.Razor.Tests/RazorTextTemplateTest.cs +++ b/tests/Text.Templating.Razor.Tests/RazorTextTemplateTest.cs @@ -57,36 +57,36 @@ public async Task RenderAsync() } [Fact] - public void RenderAsync_WithModelArgumentNull() + public async Task RenderAsync_WithModelArgumentNull() { var template = new RazorTextTemplate(typeof(string)); - template.Invoking(t => t.RenderAsync(null, default, default, default)) + await template.Invoking(t => t.RenderAsync(null, default, default, default)) .Should().ThrowExactlyAsync() .WithParameterName("model"); } [Fact] - public void RenderAsync_WithOutputArgumentNull() + public async Task RenderAsync_WithOutputArgumentNull() { var model = new Model(); var template = new RazorTextTemplate(typeof(string)); - template.Invoking(t => t.RenderAsync(model, default, default, default)) + await template.Invoking(t => t.RenderAsync(model, default, default, default)) .Should().ThrowExactlyAsync() .WithParameterName("output"); } [Fact] - public void RenderAsync_WithContextArgumentNull() + public async Task RenderAsync_WithContextArgumentNull() { var model = new Model(); var output = new StringWriter(); var template = new RazorTextTemplate(typeof(string)); - template.Invoking(t => t.RenderAsync(model, output, null, default)) + await template.Invoking(t => t.RenderAsync(model, output, null, default)) .Should().ThrowExactlyAsync() .WithParameterName("context"); } From 67617f5bf9582715dae101d0f203c39b7fb22fcb Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 09:26:00 +0100 Subject: [PATCH 58/73] Fix unit tests. --- tests/Emailing.Tests/EmailManagerTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Emailing.Tests/EmailManagerTest.cs b/tests/Emailing.Tests/EmailManagerTest.cs index b3bf404..747e45b 100644 --- a/tests/Emailing.Tests/EmailManagerTest.cs +++ b/tests/Emailing.Tests/EmailManagerTest.cs @@ -183,7 +183,7 @@ public async Task SendAsync_WithNullIdentifier() await manager.Invoking(m => m.SendAsync(null, default)) .Should().ThrowExactlyAsync() - .WithParameterName("template"); + .WithParameterName("email"); } internal sealed class Model : EmailModel From d719a0f4eb19c6f80e2f36ede69946aede8d8eb9 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 11:07:43 +0100 Subject: [PATCH 59/73] Add the package Emailing.Graph. --- Directory.Packages.props | 3 +- PosInformatique.Foundations.slnx | 4 + README.md | 3 +- src/Emailing.Graph/CHANGELOG.md | 2 + src/Emailing.Graph/Emailing.Graph.csproj | 33 +++++ src/Emailing.Graph/GraphEmailProvider.cs | 70 ++++++++++ .../GraphEmailingBuilderExtensions.cs | 40 ++++++ src/Emailing.Graph/README.md | 123 ++++++++++++++++++ src/Emailing/README.md | 11 +- src/Text.Templating.Scriban/CHANGELOG.md | 2 +- .../AzureEmailProviderTest.cs | 14 ++ .../Emailing.Graph.Tests.csproj | 11 ++ .../GraphBuilderExtensionsTest.cs | 99 ++++++++++++++ .../GraphEmailProviderTest.cs | 101 ++++++++++++++ 14 files changed, 509 insertions(+), 7 deletions(-) create mode 100644 src/Emailing.Graph/CHANGELOG.md create mode 100644 src/Emailing.Graph/Emailing.Graph.csproj create mode 100644 src/Emailing.Graph/GraphEmailProvider.cs create mode 100644 src/Emailing.Graph/GraphEmailingBuilderExtensions.cs create mode 100644 src/Emailing.Graph/README.md create mode 100644 tests/Emailing.Graph.Tests/Emailing.Graph.Tests.csproj create mode 100644 tests/Emailing.Graph.Tests/GraphBuilderExtensionsTest.cs create mode 100644 tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 7a798e3..cf9a17f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,10 +1,8 @@  true - 9.0.11 - @@ -21,6 +19,7 @@ + diff --git a/PosInformatique.Foundations.slnx b/PosInformatique.Foundations.slnx index 45e2576..ec78bb5 100644 --- a/PosInformatique.Foundations.slnx +++ b/PosInformatique.Foundations.slnx @@ -45,6 +45,10 @@ + + + + diff --git a/README.md b/README.md index 890eade..e809af0 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ You can install any package using the .NET CLI or NuGet Package Manager. |PosInformatique.Foundations.EmailAddresses.FluentValidation icon|[**PosInformatique.Foundations.EmailAddresses.FluentValidation**](./src/EmailAddresses.FluentValidation/README.md) | FluentValidation integration for the `EmailAddress` value object, providing dedicated validators and rules to ensure RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.FluentValidation)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.FluentValidation) | |PosInformatique.Foundations.EmailAddresses.Json icon|[**PosInformatique.Foundations.EmailAddresses.Json**](./src/EmailAddresses.Json/README.md) | `System.Text.Json` converter for the `EmailAddress` value object, enabling seamless serialization and deserialization of RFC 5322 compliant email addresses. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.EmailAddresses.Json)](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json) | |PosInformatique.Foundations.Emailing icon|[**PosInformatique.Foundations.Emailing**](./src/Emailing/README.md) | Template-based emailing infrastructure for .NET that lets you register strongly-typed email templates, create emails from models, and send them through pluggable providers. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Emailing)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing) | -|PosInformatique.Foundations.Emailing.Azure icon|[**PosInformatique.Foundations.Emailing.Azure**](./src/Emailing.Azure/README.md) | `IEmailProvider` implementation for `PosInformatique.Foundations.Emailing` using **Azure Communication Service**. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Emailing.Azure)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure) | +|PosInformatique.Foundations.Emailing.Azure icon|[**PosInformatique.Foundations.Emailing.Azure**](./src/Emailing.Azure/README.md) | `IEmailProvider` implementation for [PosInformatique.Foundations.Emailing](./src/Emailing/README.md) using **Azure Communication Service**. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Emailing.Azure)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure) | +|PosInformatique.Foundations.Emailing.Graph icon|[**PosInformatique.Foundations.Emailing.Graph**](./src/Emailing.Graph/README.md) | `IEmailProvider` implementation for [PosInformatique.Foundations.Emailing](./src/Emailing/README.md) using **Microsoft Graph API**. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Emailing.Graph)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Graph) | |PosInformatique.Foundations.Emailing.Templates.Razor icon|[**PosInformatique.Foundations.Emailing.Templates.Razor**](./src/Emailing.Templates.Razor/README.md) | Helpers to build EmailTemplate instances from Razor components for subject and HTML body, supporting strongly-typed models and reusable layouts. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.Emailing.Templates.Razor)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Templates.Razor) | |PosInformatique.Foundations.MediaTypes icon|[**PosInformatique.Foundations.MediaTypes**](./src/MediaTypes/README.md) | Immutable `MimeType` value object with well-known media types and helpers to map between media types and file extensions. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes) | |PosInformatique.Foundations.MediaTypes.EntityFramework icon|[**PosInformatique.Foundations.MediaTypes.EntityFramework**](./src/MediaTypes.EntityFramework/README.md) | Entity Framework Core integration for the `MimeType` value object, including property configuration and value converter for seamless database persistence. | [![NuGet](https://img.shields.io/nuget/v/PosInformatique.Foundations.MediaTypes.EntityFramework)](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes.EntityFramework) | diff --git a/src/Emailing.Graph/CHANGELOG.md b/src/Emailing.Graph/CHANGELOG.md new file mode 100644 index 0000000..eb7a9ad --- /dev/null +++ b/src/Emailing.Graph/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with Microsoft Graph Emailing provider. diff --git a/src/Emailing.Graph/Emailing.Graph.csproj b/src/Emailing.Graph/Emailing.Graph.csproj new file mode 100644 index 0000000..4c17a30 --- /dev/null +++ b/src/Emailing.Graph/Emailing.Graph.csproj @@ -0,0 +1,33 @@ + + + + true + + + Provides an IEmailProvider implementation for PosInformatique.Foundations.Emailing using Microsoft Graph API. + Uses Microsoft.Graph.GraphServiceClient to send templated emails through Microsoft 365 mailboxes with Azure AD authentication via TokenCredential. + + + email;emailing;graph;microsoftgraph;microsoft365;azure;azuread;tokencredential;provider;dotnet;dependencyinjection;posinformatique + + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + diff --git a/src/Emailing.Graph/GraphEmailProvider.cs b/src/Emailing.Graph/GraphEmailProvider.cs new file mode 100644 index 0000000..cd93d29 --- /dev/null +++ b/src/Emailing.Graph/GraphEmailProvider.cs @@ -0,0 +1,70 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Graph +{ + using Microsoft.Graph; + using Microsoft.Graph.Models; + using Microsoft.Graph.Users.Item.SendMail; + + /// + /// Implementation of the to send the e-mail using + /// the Graph API. + /// + public sealed class GraphEmailProvider : IEmailProvider + { + private readonly GraphServiceClient serviceClient; + + /// + /// Initializes a new instance of the class + /// using the Microsoft . + /// + /// + /// used to call the Azure Communication Service API. + /// Thrown when the argument is . + public GraphEmailProvider(GraphServiceClient serviceClient) + { + ArgumentNullException.ThrowIfNull(serviceClient); + + this.serviceClient = serviceClient; + } + + /// + public async Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(message); + + var graphMessage = new Message() + { + Body = new ItemBody + { + ContentType = BodyType.Html, + Content = message.HtmlContent, + }, + Subject = message.Subject, + ToRecipients = new List + { + new() + { + EmailAddress = new EmailAddress + { + Address = message.To.Email.ToString(), + Name = message.To.DisplayName, + }, + }, + }, + }; + + var body = new SendMailPostRequestBody() + { + Message = graphMessage, + SaveToSentItems = false, + }; + + await this.serviceClient.Users[message.From.Email.ToString()].SendMail.PostAsync(body, cancellationToken: cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/Emailing.Graph/GraphEmailingBuilderExtensions.cs b/src/Emailing.Graph/GraphEmailingBuilderExtensions.cs new file mode 100644 index 0000000..27331d4 --- /dev/null +++ b/src/Emailing.Graph/GraphEmailingBuilderExtensions.cs @@ -0,0 +1,40 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Graph +{ + using Azure.Core; + using Microsoft.Extensions.DependencyInjection.Extensions; + using Microsoft.Graph; + + /// + /// Extension methods to configure the Azure Communication Service provider + /// for the . + /// + public static class GraphEmailingBuilderExtensions + { + /// + /// Configure the provider of to use Azure Communication Service. + /// + /// which to configure. + /// The for authenticating to Microsoft Graph API. + /// The base service URL of the API Graph. If not specified the https://graph.microsoft.com/v1.0 will be use. + /// Thrown when the argument is . + /// Thrown when the argument is . + public static void UseGraph(this EmailingBuilder builder, TokenCredential tokenCredential, string? baseUrl = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(tokenCredential); + + builder.Services.TryAddSingleton(sp => + { + var serviceClient = new GraphServiceClient(tokenCredential, null, baseUrl); + + return new GraphEmailProvider(serviceClient); + }); + } + } +} \ No newline at end of file diff --git a/src/Emailing.Graph/README.md b/src/Emailing.Graph/README.md new file mode 100644 index 0000000..0a62105 --- /dev/null +++ b/src/Emailing.Graph/README.md @@ -0,0 +1,123 @@ +### PosInformatique.Foundations.Emailing.Graph + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.Emailing.Graph)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Graph/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.Emailing.Graph)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Graph/) + +## Introduction + +[PosInformatique.Foundations.Emailing.Graph](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Graph/) +provides an `IEmailProvider` +implementation for [PosInformatique.Foundations.Emailing](../Emailing/README.md) based on the **Microsoft Graph** API. + +It uses [Microsoft.Graph.GraphServiceClient](https://learn.microsoft.com/en-us/graph/sdks/create-client?tabs=csharp) +to send templated emails (created via `IEmailManager`) +through a Microsoft 365 mailbox, using Azure AD authentication. + +Authentication is fully driven by an +[Azure.Core.TokenCredential](https://learn.microsoft.com/en-us/dotnet/api/azure.core.tokencredential?view=azure-dotnet) +instance, allowing you to use: + +- Managed identity +- Client credentials (client id/secret or certificate) +- Interactive login, device code, etc. + +## Install + +You can install the package from [NuGet](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Graph/): + +```powershell +dotnet add package PosInformatique.Foundations.Emailing.Graph +``` + +## Features + +- `IEmailProvider` implementation using `Microsoft.Graph.GraphServiceClient`. +- Simple configuration through `AddEmailing().UseGraph(...)`. +- Authentication configured via `TokenCredential`: + - `DefaultAzureCredential` (managed identity, VS, CLI, etc.) + - `ClientSecretCredential`, `ClientCertificateCredential`, etc. +- Optional `baseUrl` parameter to customize the Graph endpoint (defaults to `https://graph.microsoft.com/v1.0`). +- Sends HTML emails using the `EmailMessage` produced by [PosInformatique.Foundations.Emailing](../Emailing/README.md). + +## Basic configuration + +### Using DefaultAzureCredential (managed identity or local dev) + +```csharp +using Azure.Identity; +using Microsoft.Extensions.DependencyInjection; +using PosInformatique.Foundations.EmailAddresses; +using PosInformatique.Foundations.Emailing; +using PosInformatique.Foundations.Emailing.Graph; + +var services = new ServiceCollection(); + +// TokenCredential for Microsoft Graph (for example: managed identity or local dev) +var credential = new DefaultAzureCredential(); + +services + .AddEmailing(options => + { + options.SenderEmailAddress = EmailAddress.Parse("sender@yourtenant.onmicrosoft.com"); + + // Register your templates here... + // options.RegisterTemplate(EmailTemplateIdentifiers.Invitation, invitationTemplate); + }) + .UseGraph(credential); +``` + +### Using client credentials (app registration) + +If you want to authenticate with a client id / tenant id / client secret: + +```csharp +using Azure.Identity; +using PosInformatique.Foundations.Emailing.Graph; + +var tenantId = configuration["AzureAd:TenantId"]; +var clientId = configuration["AzureAd:ClientId"]; +var clientSecret = configuration["AzureAd:ClientSecret"]; + +var credential = new ClientSecretCredential(tenantId, clientId, clientSecret); + +services + .AddEmailing(options => + { + options.SenderEmailAddress = EmailAddress.Parse("sender@yourtenant.onmicrosoft.com"); + }) + .UseGraph(credential); +``` + +The `TokenCredential` you provide is responsible for acquiring tokens for the Microsoft Graph API. The provider does not manage scopes or credentials itself; this is entirely delegated to the credential implementation. + +### Custom Graph endpoint + +You can optionally customize the Graph base URL (for example, for national clouds): + +```csharp +var baseUrl = "https://graph.microsoft.com/v1.0/beta"; + +services + .AddEmailing(options => + { + options.SenderEmailAddress = EmailAddress.Parse("sender@yourtenant.onmicrosoft.com"); + }) + .UseGraph(credential, baseUrl); +``` + +If `baseUrl` is `null`, `https://graph.microsoft.com/v1.0` is used by default. + +## Typical end-to-end usage + +1. Configure emailing (sender address, templates) with `AddEmailing(...)`. +2. Configure the Graph provider with `UseGraph(TokenCredential, baseUrl?)`. +3. Inject `IEmailManager` and create emails from template identifiers. +4. Add recipients and models. +5. Call `SendAsync(...)` to send emails via Microsoft Graph. + +## Links + +- [NuGet package: Emailing](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing/) +s- [Microsoft Graph .NET SDK](https://learn.microsoft.com/graph/sdks/sdks-overview) +- [Azure Identity (TokenCredential)](https://learn.microsoft.com/dotnet/azure/sdk/authentication/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/src/Emailing/README.md b/src/Emailing/README.md index 8c68c73..2380087 100644 --- a/src/Emailing/README.md +++ b/src/Emailing/README.md @@ -13,9 +13,10 @@ It allows you to: - Instantiate emails from registered templates via an `IEmailManager`. - Generate and send templated emails for each recipient through an `IEmailProvider` implementation. -The actual transport (SMTP, Azure Communication Service, etc.) is delegated to a provider implementation. +The actual transport (SMTP, Azure Communication Service, Graph API, etc.) is delegated to a provider implementation. Existing implementation are available in the following packages: - Azure Communication Service: [PosInformatique.Foundations.Emailing.Azure](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure/). +- Microsoft Graph API: [PosInformatique.Foundations.Emailing.Graph](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Graph/). ## Install @@ -133,14 +134,17 @@ services.AddEmailing(options => The `AddEmailing()` method returns an `EmailingBuilder` that can be used to continue configuring the emailing infrastructure (for example, provider registration in other packages). -### Email provider +### Email providers `IEmailProvider` is responsible for sending the final `EmailMessage`. This package only defines the abstraction. A typical provider implementation is located in another package, such as: - [PosInformatique.Foundations.Emailing.Azure](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure/). +- [PosInformatique.Foundations.Emailing.Graph](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Graph/). -See the [PosInformatique.Foundations.Emailing.Azure](../Emailing.Azure/README.md) documentation for an example of provider registration. +See the [PosInformatique.Foundations.Emailing.Azure](../Emailing.Azure/README.md) +or [PosInformatique.Foundations.Emailing.Graph](../Emailing.Graph/README.md) +documentation for an example of provider registration. ## Usage @@ -231,6 +235,7 @@ The typical flow is: - [NuGet package: Emailing (core library)](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing/) - [NuGet package: Emailing.Azure](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Azure/) +- [NuGet package: Emailing.Graph](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Graph/) - [NuGet package: Text.Templating.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor/) - [NuGet package: Text.Templating.Scriban](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Scriban/) - [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) \ No newline at end of file diff --git a/src/Text.Templating.Scriban/CHANGELOG.md b/src/Text.Templating.Scriban/CHANGELOG.md index 2e703b8..11422a3 100644 --- a/src/Text.Templating.Scriban/CHANGELOG.md +++ b/src/Text.Templating.Scriban/CHANGELOG.md @@ -1,2 +1,2 @@ 1.0.0 - - Initial release with the Razor Text Templating feature. + - Initial release with the Scriban Text Templating feature. diff --git a/tests/Emailing.Azure.Tests/AzureEmailProviderTest.cs b/tests/Emailing.Azure.Tests/AzureEmailProviderTest.cs index daed9c6..bab6a54 100644 --- a/tests/Emailing.Azure.Tests/AzureEmailProviderTest.cs +++ b/tests/Emailing.Azure.Tests/AzureEmailProviderTest.cs @@ -55,5 +55,19 @@ public async Task SendSync() azureClient.VerifyAll(); } + + [Fact] + public async Task SendSync_WithMessageArgumentNull() + { + var azureClient = new Mock(MockBehavior.Strict); + + var provider = new AzureEmailProvider(azureClient.Object); + + await provider.Invoking(p => p.SendAsync(null, default)) + .Should().ThrowExactlyAsync() + .WithParameterName("message"); + + azureClient.VerifyAll(); + } } } \ No newline at end of file diff --git a/tests/Emailing.Graph.Tests/Emailing.Graph.Tests.csproj b/tests/Emailing.Graph.Tests/Emailing.Graph.Tests.csproj new file mode 100644 index 0000000..88c876f --- /dev/null +++ b/tests/Emailing.Graph.Tests/Emailing.Graph.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/Emailing.Graph.Tests/GraphBuilderExtensionsTest.cs b/tests/Emailing.Graph.Tests/GraphBuilderExtensionsTest.cs new file mode 100644 index 0000000..511c256 --- /dev/null +++ b/tests/Emailing.Graph.Tests/GraphBuilderExtensionsTest.cs @@ -0,0 +1,99 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Graph.Tests +{ + using System.Reflection; + using Azure.Core; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Graph; + using Microsoft.Graph.Authentication; + + public class GraphBuilderExtensionsTest + { + [Theory] + [InlineData(null, "https://graph.microsoft.com/v1.0")] + [InlineData("https://the/url", "https://the/url")] + public void UseGraph(string baseUrl, string expectedBaseUrl) + { + var serviceCollection = new ServiceCollection(); + var builder = new EmailingBuilder(serviceCollection); + + var credential = Mock.Of(MockBehavior.Strict); + + builder.UseGraph(credential, baseUrl); + + var sp = builder.Services.BuildServiceProvider(); + + var provider = sp.GetRequiredService(); + provider.Should().BeOfType(); + + var provider2 = sp.GetRequiredService(); + provider2.Should().BeSameAs(provider); + + var graphServiceClient = GetFieldValue(provider, "serviceClient"); + graphServiceClient.RequestAdapter.As().BaseUrl.Should().Be(expectedBaseUrl); + + GetFieldValue(provider2, "serviceClient").Should().BeSameAs(graphServiceClient); + + var graphServiceClientCredential = GetCredential(graphServiceClient); + + graphServiceClientCredential.Should().BeSameAs(credential); + } + + [Fact] + public void UseGraph_WithBuilderArgumentNull() + { + var act = () => + { + GraphEmailingBuilderExtensions.UseGraph(null, default, default); + }; + + act.Should().ThrowExactly() + .WithParameterName("builder"); + } + + [Fact] + public void UseGraph_WithTokenCredentialArgumentNull() + { + var serviceCollection = new ServiceCollection(); + var builder = new EmailingBuilder(serviceCollection); + + var act = () => + { + GraphEmailingBuilderExtensions.UseGraph(builder, null, default); + }; + + act.Should().ThrowExactly() + .WithParameterName("tokenCredential"); + } + + private static TokenCredential GetCredential(GraphServiceClient serviceClient) + { + var requestAdapter = serviceClient.RequestAdapter.As(); + var authenticationProvider = GetFieldValue(requestAdapter, "authProvider"); + + return GetFieldValue(authenticationProvider.AccessTokenProvider, "_credential"); + } + + private static T GetFieldValue(object obj, string name) + { + var currentType = obj.GetType(); + + FieldInfo field; + + do + { + field = currentType + .GetField(name, BindingFlags.NonPublic | BindingFlags.Instance); + currentType = currentType.BaseType; + } + while (field is null); + + return (T)field.GetValue(obj)!; + } + } +} \ No newline at end of file diff --git a/tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs b/tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs new file mode 100644 index 0000000..6c1511b --- /dev/null +++ b/tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs @@ -0,0 +1,101 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Graph.Tests +{ + using Microsoft.Graph; + using Microsoft.Graph.Models; + using Microsoft.Graph.Users.Item.SendMail; + using Microsoft.Kiota.Abstractions; + using Microsoft.Kiota.Abstractions.Serialization; + using Microsoft.Kiota.Serialization.Json; + + public class GraphEmailProviderTest + { + [Fact] + public void Constructor_WithServiceClientArgumentNull() + { + var act = () => + { + new GraphEmailProvider(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("serviceClient"); + } + + [Fact] + public async Task SendAsync() + { + var cancellationToken = new CancellationTokenSource().Token; + + var serializationWriterFactory = new Mock(MockBehavior.Strict); + serializationWriterFactory.Setup(f => f.GetSerializationWriter("application/json")) + .Returns(new JsonSerializationWriter()); + + var requestAdapter = new Mock(MockBehavior.Strict); + requestAdapter.Setup(r => r.BaseUrl) + .Returns("http://base/url"); + requestAdapter.Setup(r => r.EnableBackingStore(null)); + requestAdapter.Setup(r => r.SerializationWriterFactory) + .Returns(serializationWriterFactory.Object); + requestAdapter.Setup(r => r.SendNoContentAsync(It.IsAny(), It.IsNotNull>>(), cancellationToken)) + .Callback((RequestInformation requestInfo, Dictionary> _, CancellationToken _) => + { + requestInfo.HttpMethod.Should().Be(Method.POST); + requestInfo.URI.Should().Be("http://base/url/users/sender%40domain.com/sendMail"); + + var jsonMessage = KiotaJsonSerializer.DeserializeAsync(requestInfo.Content).GetAwaiter().GetResult(); + + jsonMessage.Message.Attachments.Should().BeNull(); + jsonMessage.Message.Body.Content.Should().Be("The HTML content"); + jsonMessage.Message.Body.ContentType.Should().Be(BodyType.Html); + jsonMessage.Message.BccRecipients.Should().BeNull(); + jsonMessage.Message.CcRecipients.Should().BeNull(); + jsonMessage.Message.ToRecipients.Should().HaveCount(1); + jsonMessage.Message.ToRecipients[0].EmailAddress.Address.Should().Be("recipient@domain.com"); + jsonMessage.Message.ToRecipients[0].EmailAddress.Name.Should().Be("The recipient"); + jsonMessage.SaveToSentItems.Should().BeFalse(); + }) + .Returns(Task.CompletedTask); + + var graphServiceClient = new Mock(MockBehavior.Strict, requestAdapter.Object, null); + + var client = new GraphEmailProvider(graphServiceClient.Object); + + var from = new EmailContact(EmailAddresses.EmailAddress.Parse("sender@domain.com"), "The sender"); + var to = new EmailContact(EmailAddresses.EmailAddress.Parse("recipient@domain.com"), "The recipient"); + + var message = new EmailMessage(from, to, "The subject", "The HTML content"); + + await client.SendAsync(message, cancellationToken); + + graphServiceClient.VerifyAll(); + requestAdapter.VerifyAll(); + serializationWriterFactory.VerifyAll(); + } + + [Fact] + public async Task SendSync_WithMessageArgumentNull() + { + var requestAdapter = new Mock(MockBehavior.Strict); + requestAdapter.Setup(r => r.BaseUrl) + .Returns("http://base/url"); + requestAdapter.Setup(r => r.EnableBackingStore(null)); + + var serviceClient = new Mock(MockBehavior.Strict, requestAdapter.Object, null); + + var provider = new GraphEmailProvider(serviceClient.Object); + + await provider.Invoking(p => p.SendAsync(null, default)) + .Should().ThrowExactlyAsync() + .WithParameterName("message"); + + requestAdapter.VerifyAll(); + serviceClient.VerifyAll(); + } + } +} \ No newline at end of file From e4d66f8bb92a317a5aa9320e3035e3f1b4e68cb5 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 11:47:05 +0100 Subject: [PATCH 60/73] Remove the base class EmailModel. --- src/Emailing.Templates.Razor/README.md | 2 +- .../RazorEmailTemplate.cs | 1 - .../RazorEmailTemplateBody.cs | 1 - .../RazorEmailTemplateSubject.cs | 1 - src/Emailing/Email.cs | 1 - src/Emailing/EmailManager.cs | 2 -- src/Emailing/EmailModel.cs | 21 ------------------- src/Emailing/EmailRecipientCollection.cs | 1 - src/Emailing/EmailTemplate.cs | 1 - src/Emailing/EmailTemplateIdentifier.cs | 1 - src/Emailing/EmailingOptions.cs | 2 -- src/Emailing/IEmailManager.cs | 6 ++---- src/Emailing/README.md | 12 +++++------ .../RazorEmailTemplateBodyTest.cs | 2 +- .../RazorEmailTemplateSubjectTest.cs | 2 +- .../RazorEmailTemplateTest.cs | 2 +- tests/Emailing.Tests/EmailManagerTest.cs | 2 +- .../EmailRecipientCollectionTest.cs | 2 +- tests/Emailing.Tests/EmailRecipientTest.cs | 2 +- .../EmailTemplateIdentifierTest.cs | 2 +- tests/Emailing.Tests/EmailTemplateTest.cs | 2 +- tests/Emailing.Tests/EmailTest.cs | 2 +- tests/Emailing.Tests/EmailingOptionsTest.cs | 2 +- 23 files changed, 19 insertions(+), 53 deletions(-) delete mode 100644 src/Emailing/EmailModel.cs diff --git a/src/Emailing.Templates.Razor/README.md b/src/Emailing.Templates.Razor/README.md index 686aa1b..3b48f68 100644 --- a/src/Emailing.Templates.Razor/README.md +++ b/src/Emailing.Templates.Razor/README.md @@ -43,7 +43,7 @@ Example model used by the templates: ```csharp using PosInformatique.Foundations.Emailing; -public sealed class InvitationEmailTemplateModel : EmailModel +public sealed class InvitationEmailTemplateModel { public string FirstName { get; set; } = string.Empty; public string InvitationLink { get; set; } = string.Empty; diff --git a/src/Emailing.Templates.Razor/RazorEmailTemplate.cs b/src/Emailing.Templates.Razor/RazorEmailTemplate.cs index 8d1b25a..ebb5732 100644 --- a/src/Emailing.Templates.Razor/RazorEmailTemplate.cs +++ b/src/Emailing.Templates.Razor/RazorEmailTemplate.cs @@ -13,7 +13,6 @@ namespace PosInformatique.Foundations.Emailing.Templates.Razor /// /// Type of the model used to generate the subject and body of the e-mail. public static class RazorEmailTemplate - where TModel : EmailModel { /// /// Creates an using the specified Razor components as text templating for the subject and body. diff --git a/src/Emailing.Templates.Razor/RazorEmailTemplateBody.cs b/src/Emailing.Templates.Razor/RazorEmailTemplateBody.cs index 0c2dc04..96fb275 100644 --- a/src/Emailing.Templates.Razor/RazorEmailTemplateBody.cs +++ b/src/Emailing.Templates.Razor/RazorEmailTemplateBody.cs @@ -13,7 +13,6 @@ namespace PosInformatique.Foundations.Emailing.Templates.Razor /// /// Type of the used to generate the body of the e-mail. public abstract class RazorEmailTemplateBody : ComponentBase - where TModel : EmailModel { /// /// Initializes a new instance of the class. diff --git a/src/Emailing.Templates.Razor/RazorEmailTemplateSubject.cs b/src/Emailing.Templates.Razor/RazorEmailTemplateSubject.cs index f43a187..ef56752 100644 --- a/src/Emailing.Templates.Razor/RazorEmailTemplateSubject.cs +++ b/src/Emailing.Templates.Razor/RazorEmailTemplateSubject.cs @@ -13,7 +13,6 @@ namespace PosInformatique.Foundations.Emailing.Templates.Razor /// /// Type of the used to generate the subject of the e-mail. public abstract class RazorEmailTemplateSubject : ComponentBase - where TModel : EmailModel { /// /// Initializes a new instance of the class. diff --git a/src/Emailing/Email.cs b/src/Emailing/Email.cs index fea8761..609e535 100644 --- a/src/Emailing/Email.cs +++ b/src/Emailing/Email.cs @@ -11,7 +11,6 @@ namespace PosInformatique.Foundations.Emailing /// /// Type of data model injected to the to generate the e-mail. public sealed class Email - where TModel : EmailModel { /// /// Initializes a new instance of the class diff --git a/src/Emailing/EmailManager.cs b/src/Emailing/EmailManager.cs index f506d2e..7f7fc4f 100644 --- a/src/Emailing/EmailManager.cs +++ b/src/Emailing/EmailManager.cs @@ -30,7 +30,6 @@ public EmailManager(IOptions options, IEmailProvider provider, } public Email Create(EmailTemplateIdentifier identifier) - where TModel : EmailModel { ArgumentNullException.ThrowIfNull(identifier); @@ -45,7 +44,6 @@ public Email Create(EmailTemplateIdentifier identifier) } public async Task SendAsync(Email email, CancellationToken cancellationToken = default) - where TModel : EmailModel { ArgumentNullException.ThrowIfNull(email); diff --git a/src/Emailing/EmailModel.cs b/src/Emailing/EmailModel.cs deleted file mode 100644 index 0e55a86..0000000 --- a/src/Emailing/EmailModel.cs +++ /dev/null @@ -1,21 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) P.O.S Informatique. All rights reserved. -// -//----------------------------------------------------------------------- - -namespace PosInformatique.Foundations.Emailing -{ - /// - /// Base class of the data model of the . - /// - public abstract class EmailModel - { - /// - /// Initializes a new instance of the class. - /// - protected EmailModel() - { - } - } -} \ No newline at end of file diff --git a/src/Emailing/EmailRecipientCollection.cs b/src/Emailing/EmailRecipientCollection.cs index cba9903..c2a1f30 100644 --- a/src/Emailing/EmailRecipientCollection.cs +++ b/src/Emailing/EmailRecipientCollection.cs @@ -14,7 +14,6 @@ namespace PosInformatique.Foundations.Emailing /// /// Data model injected to the to generate the e-mail for each recipient. public class EmailRecipientCollection : Collection> - where TModel : EmailModel { /// /// Creates and add new in the . diff --git a/src/Emailing/EmailTemplate.cs b/src/Emailing/EmailTemplate.cs index 93a9bc3..769293d 100644 --- a/src/Emailing/EmailTemplate.cs +++ b/src/Emailing/EmailTemplate.cs @@ -13,7 +13,6 @@ namespace PosInformatique.Foundations.Emailing /// /// Type of data model injected to the and to generate the e-mail. public class EmailTemplate - where TModel : EmailModel { /// /// Initializes a new instance of the class. diff --git a/src/Emailing/EmailTemplateIdentifier.cs b/src/Emailing/EmailTemplateIdentifier.cs index 7441fc9..f1eae00 100644 --- a/src/Emailing/EmailTemplateIdentifier.cs +++ b/src/Emailing/EmailTemplateIdentifier.cs @@ -11,7 +11,6 @@ namespace PosInformatique.Foundations.Emailing /// /// Data model injected to the to generate the e-mail. public sealed class EmailTemplateIdentifier - where TModel : EmailModel { private EmailTemplateIdentifier() { diff --git a/src/Emailing/EmailingOptions.cs b/src/Emailing/EmailingOptions.cs index 35cb573..4a0a517 100644 --- a/src/Emailing/EmailingOptions.cs +++ b/src/Emailing/EmailingOptions.cs @@ -38,7 +38,6 @@ public EmailingOptions() /// Thrown when the argument is . /// If a has already been registered with the specified . public void RegisterTemplate(EmailTemplateIdentifier identifier, EmailTemplate template) - where TModel : EmailModel { ArgumentNullException.ThrowIfNull(identifier); ArgumentNullException.ThrowIfNull(template); @@ -52,7 +51,6 @@ public void RegisterTemplate(EmailTemplateIdentifier identifier, } internal EmailTemplate? GetTemplate(EmailTemplateIdentifier identifier) - where TModel : EmailModel { if (!this.templates.TryGetValue(identifier, out var templateFound)) { diff --git a/src/Emailing/IEmailManager.cs b/src/Emailing/IEmailManager.cs index 0afd0f4..3d96e15 100644 --- a/src/Emailing/IEmailManager.cs +++ b/src/Emailing/IEmailManager.cs @@ -25,8 +25,7 @@ public interface IEmailManager /// associated to the . /// Thrown when the argument is . /// Thrown if no has been registered with the specified . - Email Create(EmailTemplateIdentifier identifier) - where TModel : EmailModel; + Email Create(EmailTemplateIdentifier identifier); /// /// Sends the specified . @@ -36,7 +35,6 @@ Email Create(EmailTemplateIdentifier identifier) /// which allows to cancel the send process. /// An instance of the class which represents the asynchronous operation. /// Thrown when the argument is . - Task SendAsync(Email email, CancellationToken cancellationToken = default) - where TModel : EmailModel; + Task SendAsync(Email email, CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/src/Emailing/README.md b/src/Emailing/README.md index 2380087..b629d0d 100644 --- a/src/Emailing/README.md +++ b/src/Emailing/README.md @@ -38,9 +38,9 @@ and one of its concrete implementations (for example - Registration of email templates through `AddEmailing(...)` and `EmailingOptions.RegisterTemplate(...)`. - Strongly-typed template identifiers via `EmailTemplateIdentifier`. -- Data models for templates based on an abstract `EmailModel` base class. +- Data models for templates based on any custom class. - Template-based subject and HTML body using `TextTemplate` (e.g. Razor or Scriban). -- Per-recipient data injection using a `EmailModel` with `EmailRecipient`. +- Per-recipient data injection using a model with `EmailRecipient`. - Central `IEmailManager` to create and send emails. - Pluggable `IEmailProvider` to send the final `EmailMessage` (transport-agnostic design). @@ -48,17 +48,17 @@ and one of its concrete implementations (for example ### Email models -Each email template is associated with a data model that derives from `EmailModel`. +Each email template is associated with a data model. This model is injected into the subject and HTML body templates when generating the email content for a recipient. ```csharp -public sealed class InvitationEmailTemplateModel : EmailModel +public sealed class InvitationEmailTemplateModel { public string FirstName { get; set; } = string.Empty; public string InvitationLink { get; set; } = string.Empty; } -public sealed class AccountDeletionEmailTemplateModel : EmailModel +public sealed class AccountDeletionEmailTemplateModel { public string FirstName { get; set; } = string.Empty; public DateTime DeletionDate { get; set; } @@ -226,7 +226,7 @@ The typical flow is: - Register templates and sender via `AddEmailing(...)`. - Register an `IEmailProvider` implementation. 2. Define and centralize template identifiers using `EmailTemplateIdentifier`. -3. Define data models by inheriting from `EmailModel`. +3. Define data models by creating custom data class. 4. At runtime, use `IEmailManager.Create(...)` to instantiate a strongly-typed email. 5. Add recipients and models through `EmailRecipientCollection`. 6. Call `IEmailManager.SendAsync()` to generate and send emails through the `IEmailProvider`. diff --git a/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateBodyTest.cs b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateBodyTest.cs index a54c3b0..da17868 100644 --- a/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateBodyTest.cs +++ b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateBodyTest.cs @@ -20,7 +20,7 @@ public void Constructor() template.Model.Should().BeSameAs(model); } - internal sealed class Model : EmailModel + internal sealed class Model { } } diff --git a/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateSubjectTest.cs b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateSubjectTest.cs index 5c85984..5cc13f5 100644 --- a/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateSubjectTest.cs +++ b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateSubjectTest.cs @@ -20,7 +20,7 @@ public void Constructor() template.Model.Should().BeSameAs(model); } - internal sealed class Model : EmailModel + internal sealed class Model { } } diff --git a/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateTest.cs b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateTest.cs index 3b3d8c5..bea309f 100644 --- a/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateTest.cs +++ b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateTest.cs @@ -19,7 +19,7 @@ public void Create() template.Subject.Should().BeOfType>(); } - private sealed class Model : EmailModel + private sealed class Model { } diff --git a/tests/Emailing.Tests/EmailManagerTest.cs b/tests/Emailing.Tests/EmailManagerTest.cs index 747e45b..33ae489 100644 --- a/tests/Emailing.Tests/EmailManagerTest.cs +++ b/tests/Emailing.Tests/EmailManagerTest.cs @@ -186,7 +186,7 @@ await manager.Invoking(m => m.SendAsync(null, default)) .WithParameterName("email"); } - internal sealed class Model : EmailModel + internal sealed class Model { } } diff --git a/tests/Emailing.Tests/EmailRecipientCollectionTest.cs b/tests/Emailing.Tests/EmailRecipientCollectionTest.cs index ce58ec4..7a48f84 100644 --- a/tests/Emailing.Tests/EmailRecipientCollectionTest.cs +++ b/tests/Emailing.Tests/EmailRecipientCollectionTest.cs @@ -68,7 +68,7 @@ public void Add_WithNullModel() .WithParameterName("model"); } - private sealed class Model : EmailModel + private sealed class Model { } } diff --git a/tests/Emailing.Tests/EmailRecipientTest.cs b/tests/Emailing.Tests/EmailRecipientTest.cs index 26d3872..675bf63 100644 --- a/tests/Emailing.Tests/EmailRecipientTest.cs +++ b/tests/Emailing.Tests/EmailRecipientTest.cs @@ -63,7 +63,7 @@ public void Constructor_WithNullModel() .WithParameterName("model"); } - private class Model : EmailModel + private class Model { } } diff --git a/tests/Emailing.Tests/EmailTemplateIdentifierTest.cs b/tests/Emailing.Tests/EmailTemplateIdentifierTest.cs index ecec028..9c445f0 100644 --- a/tests/Emailing.Tests/EmailTemplateIdentifierTest.cs +++ b/tests/Emailing.Tests/EmailTemplateIdentifierTest.cs @@ -17,7 +17,7 @@ public void Constructor() identifier.Should().NotBeSameAs(otherIdentifier); } - private sealed class Model : EmailModel + private sealed class Model { } } diff --git a/tests/Emailing.Tests/EmailTemplateTest.cs b/tests/Emailing.Tests/EmailTemplateTest.cs index 26882e8..28c254f 100644 --- a/tests/Emailing.Tests/EmailTemplateTest.cs +++ b/tests/Emailing.Tests/EmailTemplateTest.cs @@ -48,7 +48,7 @@ public void Constructor_WithNullHtmlBody() .WithParameterName("htmlBody"); } - internal sealed class Model : EmailModel + internal sealed class Model { } } diff --git a/tests/Emailing.Tests/EmailTest.cs b/tests/Emailing.Tests/EmailTest.cs index 1f8029c..6cc56dd 100644 --- a/tests/Emailing.Tests/EmailTest.cs +++ b/tests/Emailing.Tests/EmailTest.cs @@ -36,7 +36,7 @@ public void Constructor_WithNullTemplate() .WithParameterName("template"); } - internal sealed class Model : EmailModel + internal sealed class Model { } } diff --git a/tests/Emailing.Tests/EmailingOptionsTest.cs b/tests/Emailing.Tests/EmailingOptionsTest.cs index 6b4d52e..adde1d7 100644 --- a/tests/Emailing.Tests/EmailingOptionsTest.cs +++ b/tests/Emailing.Tests/EmailingOptionsTest.cs @@ -92,7 +92,7 @@ public void GetTemplate_NotRegistered() options.GetTemplate(identifier).Should().BeNull(); } - public sealed class Model : EmailModel + public sealed class Model { } } From c22bf0ca79fa57631b81a9f30b91da7a51ecba7c Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 11:50:14 +0100 Subject: [PATCH 61/73] Fix warnings. --- .../PhoneNumberPropertyExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PhoneNumbers.EntityFramework/PhoneNumberPropertyExtensions.cs b/src/PhoneNumbers.EntityFramework/PhoneNumberPropertyExtensions.cs index b6e298f..28ffc12 100644 --- a/src/PhoneNumbers.EntityFramework/PhoneNumberPropertyExtensions.cs +++ b/src/PhoneNumbers.EntityFramework/PhoneNumberPropertyExtensions.cs @@ -50,4 +50,4 @@ private PhoneNumberConverter() public static PhoneNumberConverter Instance { get; } = new PhoneNumberConverter(); } } -} +} \ No newline at end of file From c6546e9dc7b0ebf6fcbb83c06e2f3e7542354820 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 12:23:17 +0100 Subject: [PATCH 62/73] Change the Emailing provider registration namespace to Microsoft.Extensions.DependencyInjection. --- src/Emailing.Azure/AzureEmailingBuilderExtensions.cs | 4 +++- src/Emailing.Azure/README.md | 4 ---- src/Emailing.Graph/GraphEmailingBuilderExtensions.cs | 4 +++- src/Emailing/EmailingBuilder.cs | 4 +--- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Emailing.Azure/AzureEmailingBuilderExtensions.cs b/src/Emailing.Azure/AzureEmailingBuilderExtensions.cs index 9b50c2a..f1e4a8a 100644 --- a/src/Emailing.Azure/AzureEmailingBuilderExtensions.cs +++ b/src/Emailing.Azure/AzureEmailingBuilderExtensions.cs @@ -4,12 +4,14 @@ // //----------------------------------------------------------------------- -namespace PosInformatique.Foundations.Emailing.Azure +namespace Microsoft.Extensions.DependencyInjection { using global::Azure.Communication.Email; using global::Azure.Core.Extensions; using Microsoft.Extensions.Azure; using Microsoft.Extensions.DependencyInjection.Extensions; + using PosInformatique.Foundations.Emailing; + using PosInformatique.Foundations.Emailing.Azure; /// /// Extension methods to configure the Azure Communication Service provider diff --git a/src/Emailing.Azure/README.md b/src/Emailing.Azure/README.md index 3e81ea7..e706330 100644 --- a/src/Emailing.Azure/README.md +++ b/src/Emailing.Azure/README.md @@ -37,8 +37,6 @@ dotnet add package PosInformatique.Foundations.Emailing.Azure ```csharp using Microsoft.Extensions.DependencyInjection; using PosInformatique.Foundations.EmailAddresses; -using PosInformatique.Foundations.Emailing; -using PosInformatique.Foundations.Emailing.Azure; var services = new ServiceCollection(); @@ -66,8 +64,6 @@ using Azure.Communication.Email; using Azure.Identity; using Microsoft.Extensions.Azure; using Microsoft.Extensions.DependencyInjection; -using PosInformatique.Foundations.Emailing; -using PosInformatique.Foundations.Emailing.Azure; var services = new ServiceCollection(); diff --git a/src/Emailing.Graph/GraphEmailingBuilderExtensions.cs b/src/Emailing.Graph/GraphEmailingBuilderExtensions.cs index 27331d4..c402e03 100644 --- a/src/Emailing.Graph/GraphEmailingBuilderExtensions.cs +++ b/src/Emailing.Graph/GraphEmailingBuilderExtensions.cs @@ -4,11 +4,13 @@ // //----------------------------------------------------------------------- -namespace PosInformatique.Foundations.Emailing.Graph +namespace Microsoft.Extensions.DependencyInjection { using Azure.Core; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Graph; + using PosInformatique.Foundations.Emailing; + using PosInformatique.Foundations.Emailing.Graph; /// /// Extension methods to configure the Azure Communication Service provider diff --git a/src/Emailing/EmailingBuilder.cs b/src/Emailing/EmailingBuilder.cs index 3c6d80d..f8dfc5c 100644 --- a/src/Emailing/EmailingBuilder.cs +++ b/src/Emailing/EmailingBuilder.cs @@ -4,10 +4,8 @@ // //----------------------------------------------------------------------- -namespace PosInformatique.Foundations.Emailing +namespace Microsoft.Extensions.DependencyInjection { - using Microsoft.Extensions.DependencyInjection; - /// /// Used to configure e-mailing feature. /// From 04c77b68b1bf9645e1451d85dd49cf866209542e Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 13:07:22 +0100 Subject: [PATCH 63/73] Add warning about AddEmailing() registration scope. --- src/Emailing/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Emailing/README.md b/src/Emailing/README.md index b629d0d..2695ee2 100644 --- a/src/Emailing/README.md +++ b/src/Emailing/README.md @@ -131,6 +131,12 @@ services.AddEmailing(options => }); ``` +> **Important:** +> The `AddEmailing()` method registers a scoped implementation of `IEmailManager`. +> This is required because email templates (for example Razor-based templates) may depend on scoped services +> that are tied to the currently authenticated user. +> As a consequence, every service that depends on `IEmailManager` must also be registered with a scoped lifetime. + The `AddEmailing()` method returns an `EmailingBuilder` that can be used to continue configuring the emailing infrastructure (for example, provider registration in other packages). From 3b7ef2bb302def547a978c6a7fd5498e54d7cdbb Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 16 Nov 2025 13:20:41 +0100 Subject: [PATCH 64/73] Add the UseRazorEmailTemplates() extension method. --- .../AzureEmailingBuilderExtensions.cs | 10 ++- .../GraphEmailingBuilderExtensions.cs | 5 +- src/Emailing.Templates.Razor/README.md | 86 ++++++++++++------- ...mailTemplateServiceCollectionExtensions.cs | 29 +++++++ .../AzureEmailingBuilderExtensionsTest.cs | 12 ++- .../GraphBuilderExtensionsTest.cs | 3 +- ...TemplateServiceCollectionExtensionsTest.cs | 42 +++++++++ 7 files changed, 146 insertions(+), 41 deletions(-) create mode 100644 src/Emailing.Templates.Razor/RazorEmailTemplateServiceCollectionExtensions.cs create mode 100644 tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateServiceCollectionExtensionsTest.cs diff --git a/src/Emailing.Azure/AzureEmailingBuilderExtensions.cs b/src/Emailing.Azure/AzureEmailingBuilderExtensions.cs index f1e4a8a..b1185e7 100644 --- a/src/Emailing.Azure/AzureEmailingBuilderExtensions.cs +++ b/src/Emailing.Azure/AzureEmailingBuilderExtensions.cs @@ -25,9 +25,10 @@ public static class AzureEmailingBuilderExtensions /// which to configure. /// Uri to the the Azure Communication Service instance. /// Allows to configure the used by the provider. + /// The instance to continue the configuration of the emailing feature. /// Thrown when the argument is . /// Thrown when the argument is . - public static void UseAzureCommunicationService(this EmailingBuilder builder, Uri uri, Action>? clientBuilder = null) + public static EmailingBuilder UseAzureCommunicationService(this EmailingBuilder builder, Uri uri, Action>? clientBuilder = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(uri); @@ -43,6 +44,8 @@ public static void UseAzureCommunicationService(this EmailingBuilder builder, Ur clientBuilder(emailClientBuilder); } }); + + return builder; } /// @@ -51,9 +54,10 @@ public static void UseAzureCommunicationService(this EmailingBuilder builder, Ur /// which to configure. /// Connection string to the Azure Communication Service instance. /// Allows to configure the used by the provider. + /// The instance to continue the configuration of the emailing feature. /// Thrown when the argument is . /// Thrown when the argument is . - public static void UseAzureCommunicationService(this EmailingBuilder builder, string connectionString, Action>? clientBuilder = null) + public static EmailingBuilder UseAzureCommunicationService(this EmailingBuilder builder, string connectionString, Action>? clientBuilder = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(connectionString); @@ -69,6 +73,8 @@ public static void UseAzureCommunicationService(this EmailingBuilder builder, st clientBuilder(emailClientBuilder); } }); + + return builder; } } } \ No newline at end of file diff --git a/src/Emailing.Graph/GraphEmailingBuilderExtensions.cs b/src/Emailing.Graph/GraphEmailingBuilderExtensions.cs index c402e03..600b651 100644 --- a/src/Emailing.Graph/GraphEmailingBuilderExtensions.cs +++ b/src/Emailing.Graph/GraphEmailingBuilderExtensions.cs @@ -24,9 +24,10 @@ public static class GraphEmailingBuilderExtensions /// which to configure. /// The for authenticating to Microsoft Graph API. /// The base service URL of the API Graph. If not specified the https://graph.microsoft.com/v1.0 will be use. + /// The instance to continue the configuration of the emailing feature. /// Thrown when the argument is . /// Thrown when the argument is . - public static void UseGraph(this EmailingBuilder builder, TokenCredential tokenCredential, string? baseUrl = null) + public static EmailingBuilder UseGraph(this EmailingBuilder builder, TokenCredential tokenCredential, string? baseUrl = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(tokenCredential); @@ -37,6 +38,8 @@ public static void UseGraph(this EmailingBuilder builder, TokenCredential tokenC return new GraphEmailProvider(serviceClient); }); + + return builder; } } } \ No newline at end of file diff --git a/src/Emailing.Templates.Razor/README.md b/src/Emailing.Templates.Razor/README.md index 3b48f68..6264b3a 100644 --- a/src/Emailing.Templates.Razor/README.md +++ b/src/Emailing.Templates.Razor/README.md @@ -10,8 +10,8 @@ provides helpers to create `EmailTemplate` instances using Razor compone It is built on top of: -- [PosInformatique.Foundations.Emailing](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Templates.Razor/) -- [PosInformatique.Foundations.Text.Templates.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing.Templates.Razor/) +- [PosInformatique.Foundations.Emailing](https://www.nuget.org/packages/PosInformatique.Foundations.Emailing/) +- [PosInformatique.Foundations.Text.Templates.Razor](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Razor/) This allows you to design your email content with Blazor-style Razor components, benefiting from layout reuse, strongly-typed models, and familiar Razor syntax. @@ -33,10 +33,30 @@ You also need a Blazor-compatible environment for compiling/executing Razor comp - `RazorEmailTemplateBody` for email HTML body. - Strongly-typed model support via the `Model` parameter. - Supports Razor layout features for the email body (reuse consistent layout across multiple templates). +- `UseRazorEmailTemplates()` extension method to enable Razor-based email templates in the emailing configuration. -## Creating Razor components for subject and body +## Configuring emailing with Razor email templates -### 1. Define the email model +### 1. Configure emailing services + +Use `AddEmailing(...)` and then `UseRazorEmailTemplates()`. + +```csharp +using Microsoft.Extensions.DependencyInjection; + +var services = new ServiceCollection(); + +var emailingBuilder = services.AddEmailing(options => +{ + // Configure emailing options here and register the templates +}) +UseRazorEmailTemplates();. +``` + +- `AddEmailing(...)` registers the core emailing services and the templates. +- `UseRazorEmailTemplates()` enables Razor-based text templating for emailing. + +### 2. Define the email model Example model used by the templates: @@ -50,7 +70,7 @@ public sealed class InvitationEmailTemplateModel } ``` -### 2. Subject component +### 3. Subject component Create a Razor component for the subject that inherits from `RazorEmailTemplateSubject` and uses the `Model` parameter. @@ -69,7 +89,7 @@ This component: - Renders a single line of text. - Uses the strongly-typed `Model` to build the subject. -### 3. Body component with layout +### 4. Body component with layout You can define a layout component that centralizes common HTML structure (header, footer, styles, etc.), then reuse it across different email bodies. @@ -85,33 +105,33 @@ then reuse it across different email bodies. @Title diff --git a/src/Emailing.Templates.Razor/RazorEmailTemplateServiceCollectionExtensions.cs b/src/Emailing.Templates.Razor/RazorEmailTemplateServiceCollectionExtensions.cs new file mode 100644 index 0000000..c2da371 --- /dev/null +++ b/src/Emailing.Templates.Razor/RazorEmailTemplateServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods to configure the Email templates based on Razor components. + /// + public static class RazorEmailTemplateServiceCollectionExtensions + { + /// + /// Adds Razor text templating support to the emailing services. + /// + /// to configure. + /// The instance to continue the configuration of the emailing service. + /// Thrown when the argument is . + public static EmailingBuilder UseRazorEmailTemplates(this EmailingBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddRazorTextTemplating(); + + return builder; + } + } +} \ No newline at end of file diff --git a/tests/Emailing.Azure.Tests/AzureEmailingBuilderExtensionsTest.cs b/tests/Emailing.Azure.Tests/AzureEmailingBuilderExtensionsTest.cs index 85690bc..fd82756 100644 --- a/tests/Emailing.Azure.Tests/AzureEmailingBuilderExtensionsTest.cs +++ b/tests/Emailing.Azure.Tests/AzureEmailingBuilderExtensionsTest.cs @@ -18,7 +18,8 @@ public void UseAzureCommunicationService_WithConnectionString() var serviceCollection = new ServiceCollection(); var builder = new EmailingBuilder(serviceCollection); - builder.UseAzureCommunicationService("endpoint=https://my-acs-resource.communication.azure.com/;accesskey=2x3Yz=="); + builder.UseAzureCommunicationService("endpoint=https://my-acs-resource.communication.azure.com/;accesskey=2x3Yz==") + .Should().BeSameAs(builder); var sp = builder.Services.BuildServiceProvider(); @@ -44,7 +45,8 @@ public void UseAzureCommunicationService_WithConnectionString_WithClientBuilder( builder.UseAzureCommunicationService("endpoint=https://my-acs-resource.communication.azure.com/;accesskey=2x3Yz==", clientBuilder => { clientBuilderCalled = true; - }); + }) + .Should().BeSameAs(builder); var sp = builder.Services.BuildServiceProvider(); @@ -93,7 +95,8 @@ public void UseAzureCommunicationService_WithUri() var serviceCollection = new ServiceCollection(); var builder = new EmailingBuilder(serviceCollection); - builder.UseAzureCommunicationService(new Uri("https://my-acs-resource.communication.azure.com/")); + builder.UseAzureCommunicationService(new Uri("https://my-acs-resource.communication.azure.com/")) + .Should().BeSameAs(builder); var sp = builder.Services.BuildServiceProvider(); @@ -119,7 +122,8 @@ public void UseAzureCommunicationService_WithUri_WithClientBuilder() builder.UseAzureCommunicationService(new Uri("https://my-acs-resource.communication.azure.com/"), clientBuilder => { clientBuilderCalled = true; - }); + }) + .Should().BeSameAs(builder); var sp = builder.Services.BuildServiceProvider(); diff --git a/tests/Emailing.Graph.Tests/GraphBuilderExtensionsTest.cs b/tests/Emailing.Graph.Tests/GraphBuilderExtensionsTest.cs index 511c256..4e2f3cb 100644 --- a/tests/Emailing.Graph.Tests/GraphBuilderExtensionsTest.cs +++ b/tests/Emailing.Graph.Tests/GraphBuilderExtensionsTest.cs @@ -24,7 +24,8 @@ public void UseGraph(string baseUrl, string expectedBaseUrl) var credential = Mock.Of(MockBehavior.Strict); - builder.UseGraph(credential, baseUrl); + builder.UseGraph(credential, baseUrl) + .Should().BeSameAs(builder); var sp = builder.Services.BuildServiceProvider(); diff --git a/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateServiceCollectionExtensionsTest.cs b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateServiceCollectionExtensionsTest.cs new file mode 100644 index 0000000..b6f2def --- /dev/null +++ b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateServiceCollectionExtensionsTest.cs @@ -0,0 +1,42 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.Extensions.DependencyInjection.Tests +{ + using Microsoft.Extensions.Logging; + + public class RazorEmailTemplateServiceCollectionExtensionsTest + { + private static readonly Type IRazorTextTemplateRendererInterface = Type.GetType("PosInformatique.Foundations.Text.Templating.Razor.IRazorTextTemplateRenderer, PosInformatique.Foundations.Text.Templating.Razor"); + private static readonly Type RazorTextTemplateRendererClass = Type.GetType("PosInformatique.Foundations.Text.Templating.Razor.RazorTextTemplateRenderer, PosInformatique.Foundations.Text.Templating.Razor"); + + [Fact] + public void UseRazorEmailTemplates() + { + var serviceCollection = new ServiceCollection(); + var emailingBuilder = new EmailingBuilder(serviceCollection); + + emailingBuilder.UseRazorEmailTemplates().Should().BeSameAs(emailingBuilder); + + var sp = serviceCollection.BuildServiceProvider(); + + sp.GetRequiredService(IRazorTextTemplateRendererInterface).Should().BeOfType(RazorTextTemplateRendererClass); + sp.GetRequiredService>().Should().NotBeNull(); + } + + [Fact] + public void UseRazorEmailTemplates_WithBuilderArgumentNull() + { + var act = () => + { + RazorEmailTemplateServiceCollectionExtensions.UseRazorEmailTemplates(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("builder"); + } + } +} \ No newline at end of file From 578c04b0bb8ff9f7732bf47e2ec9d2cbd73b423c Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Tue, 18 Nov 2025 09:54:02 +0100 Subject: [PATCH 65/73] Add code coverage settings. --- CodeCoverage.runsettings | 73 ++++++++++++++++++++++++++++++++ PosInformatique.Foundations.slnx | 1 + tests/Directory.Build.props | 3 ++ 3 files changed, 77 insertions(+) create mode 100644 CodeCoverage.runsettings diff --git a/CodeCoverage.runsettings b/CodeCoverage.runsettings new file mode 100644 index 0000000..2ce4a98 --- /dev/null +++ b/CodeCoverage.runsettings @@ -0,0 +1,73 @@ + + + + + + + + + + + + + .*\.dll$ + + + .*xunit.* + .*webjobs.* + bunit.* + fluentvalidation.* + moq.* + .*durabletask.* + microsoft.* + .*tests\.dll$ + + + + + + + ^System\.CodeDom\.Compiler\.GeneratedCodeAttribute$ + ^System\.Diagnostics\.CodeAnalysis\.ExcludeFromCodeCoverageAttribute$ + + + + + + ^MimeKit\..* + + + + + + .*\.razor + .*\.cshtml + + + + + True + True + True + False + + + + + + + + + + + opencover,cobertura + [MimeKit.*]* + GeneratedCodeAttribute,CompilerGeneratedAttribute + **/*.razor,**/*.razor.cs,**/*.cshtml + false + + + + + + \ No newline at end of file diff --git a/PosInformatique.Foundations.slnx b/PosInformatique.Foundations.slnx index ec78bb5..cd18ea0 100644 --- a/PosInformatique.Foundations.slnx +++ b/PosInformatique.Foundations.slnx @@ -2,6 +2,7 @@ + diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 5949799..d0a1079 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -26,6 +26,9 @@ net9.0 + + $(SolutionDir)\CodeCoverage.runsettings + false From 19757c92d49e71729991b8a2bffe6d3ee5008e02 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Tue, 18 Nov 2025 11:11:20 +0100 Subject: [PATCH 66/73] Reduce the dependency of the external libraries and add the support of .NET 8.0. --- Directory.Packages.props | 13 +++---- README.md | 36 ++++++++++++++++++- src/Directory.Build.props | 4 +-- tests/Directory.Build.props | 2 +- .../Emailing.Azure.Tests.csproj | 4 +++ .../GraphEmailProviderTest.cs | 2 +- 6 files changed, 50 insertions(+), 11 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index cf9a17f..6fea702 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,24 +2,25 @@ true - 9.0.11 + 8.0.0 + 9.0.0 - + - - + + - + - + diff --git a/README.md b/README.md index e809af0..9ce41a7 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,41 @@ You can install any package using the .NET CLI or NuGet Package Manager. - Apply standards-based implementations (RFC, E.164, ...). - Improve consistency across your projects. - Get lightweight, modular libraries tailored to single responsibilities. -- Add missing building blocks to your projects without introducing a heavyweight framework. +- Add missing building blocks to your projects without introducing a heavyweight framework. + +## 📌 .NET and dependency compatibility + +All [PosInformatique.Foundations](https://github.com/PosInformatique/PosInformatique.Foundations) packages are designed to be compatible with **.NET 8.0**, **.NET 9.0** and **.NET 10.0**. + +To maximize backward compatibility with existing projects, dependencies on external libraries (such as `Microsoft.Graph`, etc.) +intentionally target **relatively old versions**. This avoids forcing you to update your entire solution to the +latest versions used internally by PosInformatique.Foundations. + +> Important: It is the responsibility of the application developer to explicitly reference and update +any **transitive dependencies** in their own project if they want to use newer versions. +> See [NuGet dependency resolution](https://learn.microsoft.com/en-us/nuget/concepts/dependency-resolution) +and [transitive dependencies in Visual Studio](https://devblogs.microsoft.com/dotnet/introducing-transitive-dependencies-in-visual-studio/) +for more details. + +### Example with Microsoft.Graph + +The [PosInformatique.Foundations.Emailing.Graph](https://www.nuget.org/packages/[PosInformatique.Foundations.Emailing.Graph/) +package depends on [Microsoft.Graph](https://www.nuget.org/packages/Microsoft.Graph/) **5.35.0** +for backward compatibility with a wide range of existing projects. + +If your application requires a newer version, you can simply add an explicit reference in your project, for example: + +```xml + + + + +``` + +In this case, your project will use [Microsoft.Graph](https://www.nuget.org/packages/Microsoft.Graph/) **5.96.0** +while still consuming +[PosInformatique.Foundations.Emailing.Graph](https://www.nuget.org/packages/[PosInformatique.Foundations.Emailing.Graph/). +This is **recommended**, especially to benefit from the latest security updates and bug fixes of the underlying dependencies. ## 📄 License diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 0bbb420..a6e1a64 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,10 +1,10 @@ - + - net9.0 + net8.0;net9.0 enable true diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index d0a1079..94f80d6 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -24,7 +24,7 @@ - net9.0 + net8.0;net9.0 $(SolutionDir)\CodeCoverage.runsettings diff --git a/tests/Emailing.Azure.Tests/Emailing.Azure.Tests.csproj b/tests/Emailing.Azure.Tests/Emailing.Azure.Tests.csproj index 3cce086..a84162b 100644 --- a/tests/Emailing.Azure.Tests/Emailing.Azure.Tests.csproj +++ b/tests/Emailing.Azure.Tests/Emailing.Azure.Tests.csproj @@ -1,5 +1,9 @@  + + + + diff --git a/tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs b/tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs index 6c1511b..57bdad3 100644 --- a/tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs +++ b/tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs @@ -48,7 +48,7 @@ public async Task SendAsync() requestInfo.HttpMethod.Should().Be(Method.POST); requestInfo.URI.Should().Be("http://base/url/users/sender%40domain.com/sendMail"); - var jsonMessage = KiotaJsonSerializer.DeserializeAsync(requestInfo.Content).GetAwaiter().GetResult(); + var jsonMessage = KiotaJsonSerializer.Deserialize(requestInfo.Content); jsonMessage.Message.Attachments.Should().BeNull(); jsonMessage.Message.Body.Content.Should().Be("The HTML content"); From 4af1b9b3d569d72b80227bcbd6cbd400c76f674c Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Tue, 18 Nov 2025 11:15:20 +0100 Subject: [PATCH 67/73] Fix CI. --- .github/workflows/github-actions-ci.yaml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-actions-ci.yaml b/.github/workflows/github-actions-ci.yaml index ffe2920..64565e9 100644 --- a/.github/workflows/github-actions-ci.yaml +++ b/.github/workflows/github-actions-ci.yaml @@ -9,9 +9,11 @@ on: jobs: build: runs-on: ubuntu-latest + permissions: checks: write pull-requests: write + steps: - uses: actions/checkout@v4 @@ -29,13 +31,25 @@ jobs: --configuration Release \ --no-restore - - name: Run tests + - name: Run tests (.NET 8.0) + run: | + dotnet test PosInformatique.Foundations.slnx \ + --configuration Release \ + --no-build \ + --logger "trx;LogFileName=test_results.trx" \ + --results-directory ./TestResults \ + --collect "XPlat Code Coverage" \ + --framework net8.0 + + - name: Run tests (.NET 9.0) run: | dotnet test PosInformatique.Foundations.slnx \ --configuration Release \ --no-build \ --logger "trx;LogFileName=test_results.trx" \ - --results-directory ./TestResults + --results-directory ./TestResults \ + --collect "XPlat Code Coverage" \ + --framework net9.0 - name: Publish Test Results uses: EnricoMi/publish-unit-test-result-action@v2 From a395e8f68645ff880f3dd7cdb8241594644f212f Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Tue, 18 Nov 2025 11:23:14 +0100 Subject: [PATCH 68/73] Remove the name of the trx file. --- .github/workflows/github-actions-ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-actions-ci.yaml b/.github/workflows/github-actions-ci.yaml index 64565e9..b7681a6 100644 --- a/.github/workflows/github-actions-ci.yaml +++ b/.github/workflows/github-actions-ci.yaml @@ -36,7 +36,7 @@ jobs: dotnet test PosInformatique.Foundations.slnx \ --configuration Release \ --no-build \ - --logger "trx;LogFileName=test_results.trx" \ + --logger "trx" \ --results-directory ./TestResults \ --collect "XPlat Code Coverage" \ --framework net8.0 @@ -46,7 +46,7 @@ jobs: dotnet test PosInformatique.Foundations.slnx \ --configuration Release \ --no-build \ - --logger "trx;LogFileName=test_results.trx" \ + --logger "trx" \ --results-directory ./TestResults \ --collect "XPlat Code Coverage" \ --framework net9.0 From 4d3163e96b021ca843a884c7d5a7d54dde37f99b Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Tue, 18 Nov 2025 17:32:51 +0100 Subject: [PATCH 69/73] Add the support of the Importance for the e-mail to send with Emailing feature. --- src/Emailing.Azure/AzureEmailProvider.cs | 11 ++++++- src/Emailing.Graph/GraphEmailProvider.cs | 14 +++++++-- src/Emailing/Email.cs | 8 ++++- src/Emailing/EmailImportance.cs | 29 +++++++++++++++++++ src/Emailing/EmailMessage.cs | 5 ++++ src/Emailing/README.md | 4 +++ .../AzureEmailProviderTest.cs | 15 ++++++++-- .../GraphEmailProviderTest.cs | 13 +++++++-- tests/Emailing.Tests/EmailMessageTest.cs | 23 ++++++++++++++- tests/Emailing.Tests/EmailTest.cs | 16 ++++++++++ 10 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 src/Emailing/EmailImportance.cs diff --git a/src/Emailing.Azure/AzureEmailProvider.cs b/src/Emailing.Azure/AzureEmailProvider.cs index fb9cf2e..ca4fdf8 100644 --- a/src/Emailing.Azure/AzureEmailProvider.cs +++ b/src/Emailing.Azure/AzureEmailProvider.cs @@ -6,6 +6,8 @@ namespace PosInformatique.Foundations.Emailing.Azure { + using System.Globalization; + /// /// Implementation of the to send the e-mail using /// Azure Communication Service. @@ -46,7 +48,14 @@ public async Task SendAsync(EmailMessage message, CancellationToken cancellation Html = message.HtmlContent, }; - var azureMessage = new global::Azure.Communication.Email.EmailMessage(message.From.Email, receipients, content); + var azureMessage = new global::Azure.Communication.Email.EmailMessage(message.From.Email, receipients, content) + { + Headers = + { + { "X-Priority", Convert.ToString(Convert.ToInt32(message.Importance, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture) }, + { "Importance", Convert.ToString(message.Importance, CultureInfo.InvariantCulture) }, + }, + }; await this.client.SendAsync(global::Azure.WaitUntil.Started, azureMessage, cancellationToken); } diff --git a/src/Emailing.Graph/GraphEmailProvider.cs b/src/Emailing.Graph/GraphEmailProvider.cs index cd93d29..bcfbc0c 100644 --- a/src/Emailing.Graph/GraphEmailProvider.cs +++ b/src/Emailing.Graph/GraphEmailProvider.cs @@ -37,6 +37,13 @@ public async Task SendAsync(EmailMessage message, CancellationToken cancellation { ArgumentNullException.ThrowIfNull(message); + var importance = message.Importance switch + { + EmailImportance.Low => Importance.Low, + EmailImportance.High => Importance.High, + _ => Importance.Normal, + }; + var graphMessage = new Message() { Body = new ItemBody @@ -44,9 +51,10 @@ public async Task SendAsync(EmailMessage message, CancellationToken cancellation ContentType = BodyType.Html, Content = message.HtmlContent, }, + Importance = importance, Subject = message.Subject, - ToRecipients = new List - { + ToRecipients = + [ new() { EmailAddress = new EmailAddress @@ -55,7 +63,7 @@ public async Task SendAsync(EmailMessage message, CancellationToken cancellation Name = message.To.DisplayName, }, }, - }, + ], }; var body = new SendMailPostRequestBody() diff --git a/src/Emailing/Email.cs b/src/Emailing/Email.cs index 609e535..4fe4b81 100644 --- a/src/Emailing/Email.cs +++ b/src/Emailing/Email.cs @@ -24,9 +24,15 @@ public Email(EmailTemplate template) this.Template = template; - this.Recipients = new EmailRecipientCollection(); + this.Importance = EmailImportance.Normal; + this.Recipients = []; } + /// + /// Gets or sets the importance of the e-mail. + /// + public EmailImportance Importance { get; set; } + /// /// Gets the collection of the recipients which the e-mail have to be send. /// diff --git a/src/Emailing/EmailImportance.cs b/src/Emailing/EmailImportance.cs new file mode 100644 index 0000000..76e5f77 --- /dev/null +++ b/src/Emailing/EmailImportance.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + /// + /// Importance of the . + /// + public enum EmailImportance + { + /// + /// Low importance. + /// + Low = 5, + + /// + /// Normal importance. + /// + Normal = 3, + + /// + /// High importance. + /// + High = 1, + } +} \ No newline at end of file diff --git a/src/Emailing/EmailMessage.cs b/src/Emailing/EmailMessage.cs index d564abd..99a9523 100644 --- a/src/Emailing/EmailMessage.cs +++ b/src/Emailing/EmailMessage.cs @@ -54,5 +54,10 @@ public EmailMessage(EmailContact from, EmailContact to, string subject, string h /// Gets the HTML content of the e-mail message. /// public string HtmlContent { get; } + + /// + /// Gets or sets the importance of the e-mail message. + /// + public EmailImportance Importance { get; set; } } } \ No newline at end of file diff --git a/src/Emailing/README.md b/src/Emailing/README.md index 2695ee2..53b1898 100644 --- a/src/Emailing/README.md +++ b/src/Emailing/README.md @@ -43,6 +43,7 @@ and one of its concrete implementations (for example - Per-recipient data injection using a model with `EmailRecipient`. - Central `IEmailManager` to create and send emails. - Pluggable `IEmailProvider` to send the final `EmailMessage` (transport-agnostic design). +- Support of the *Importance* of the e-mails (**Low, Normal and High). ## Basic concepts @@ -165,12 +166,15 @@ var emailManager = serviceProvider.GetRequiredService(); // Create an email based on the "Invitation" template var invitationEmail = emailManager.Create(EmailTemplateIdentifiers.Invitation); + +invitationEmail.Importance = EmailImportance.High; ``` At this stage: - The `Email` is linked to the `EmailTemplate` previously registered in `EmailingOptions`. - No recipient has been added yet. +- The importance of the e-mail is defined to `EmailImportance.High`. ### 2. Add recipients and models diff --git a/tests/Emailing.Azure.Tests/AzureEmailProviderTest.cs b/tests/Emailing.Azure.Tests/AzureEmailProviderTest.cs index bab6a54..885c8a2 100644 --- a/tests/Emailing.Azure.Tests/AzureEmailProviderTest.cs +++ b/tests/Emailing.Azure.Tests/AzureEmailProviderTest.cs @@ -22,21 +22,30 @@ public void Constructor_WithClientArgumentNull() .WithParameterName("client"); } - [Fact] - public async Task SendSync() + [Theory] + [InlineData(EmailImportance.Low, "5", "Low")] + [InlineData(EmailImportance.Normal, "3", "Normal")] + [InlineData(EmailImportance.High, "1", "High")] + public async Task SendSync(EmailImportance importance, string expectedXPriority, string expectedImportance) { var cancellationToken = new CancellationTokenSource().Token; var from = new EmailContact(EmailAddress.Parse("sender@domain.com"), "Ignored"); var to = new EmailContact(EmailAddress.Parse("recipient@domain.com"), "The recipient"); - var message = new EmailMessage(from, to, "The subject", "The HTML content"); + var message = new EmailMessage(from, to, "The subject", "The HTML content") + { + Importance = importance, + }; var azureClient = new Mock(MockBehavior.Strict); azureClient.Setup(c => c.SendAsync(global::Azure.WaitUntil.Started, It.IsAny(), cancellationToken)) .Callback((global::Azure.WaitUntil _, global::Azure.Communication.Email.EmailMessage m, CancellationToken _) => { m.Attachments.Should().BeEmpty(); + m.Headers.Should().HaveCount(2); + m.Headers["X-Priority"].Should().Be(expectedXPriority); + m.Headers["Importance"].Should().Be(expectedImportance); m.Content.Html.Should().Be("The HTML content"); m.Content.PlainText.Should().BeNull(); m.Content.Subject.Should().Be("The subject"); diff --git a/tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs b/tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs index 57bdad3..6fb8e42 100644 --- a/tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs +++ b/tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs @@ -27,8 +27,11 @@ public void Constructor_WithServiceClientArgumentNull() .WithParameterName("serviceClient"); } - [Fact] - public async Task SendAsync() + [Theory] + [InlineData(EmailImportance.Low, Importance.Low)] + [InlineData(EmailImportance.Normal, Importance.Normal)] + [InlineData(EmailImportance.High, Importance.High)] + public async Task SendAsync(EmailImportance importance, Importance expectedImportance) { var cancellationToken = new CancellationTokenSource().Token; @@ -55,6 +58,7 @@ public async Task SendAsync() jsonMessage.Message.Body.ContentType.Should().Be(BodyType.Html); jsonMessage.Message.BccRecipients.Should().BeNull(); jsonMessage.Message.CcRecipients.Should().BeNull(); + jsonMessage.Message.Importance.Should().Be(expectedImportance); jsonMessage.Message.ToRecipients.Should().HaveCount(1); jsonMessage.Message.ToRecipients[0].EmailAddress.Address.Should().Be("recipient@domain.com"); jsonMessage.Message.ToRecipients[0].EmailAddress.Name.Should().Be("The recipient"); @@ -69,7 +73,10 @@ public async Task SendAsync() var from = new EmailContact(EmailAddresses.EmailAddress.Parse("sender@domain.com"), "The sender"); var to = new EmailContact(EmailAddresses.EmailAddress.Parse("recipient@domain.com"), "The recipient"); - var message = new EmailMessage(from, to, "The subject", "The HTML content"); + var message = new EmailMessage(from, to, "The subject", "The HTML content") + { + Importance = importance, + }; await client.SendAsync(message, cancellationToken); diff --git a/tests/Emailing.Tests/EmailMessageTest.cs b/tests/Emailing.Tests/EmailMessageTest.cs index 5c7a209..a984ff7 100644 --- a/tests/Emailing.Tests/EmailMessageTest.cs +++ b/tests/Emailing.Tests/EmailMessageTest.cs @@ -20,9 +20,13 @@ public void Constructor() from, to, "The subject", - "HTML content"); + "HTML content") + { + Importance = EmailImportance.High, + }; emailMessage.From.Should().Be(from); + emailMessage.Importance.Should().Be(EmailImportance.High); emailMessage.HtmlContent.Should().Be("HTML content"); emailMessage.Subject.Should().Be("The subject"); emailMessage.To.Should().Be(to); @@ -83,5 +87,22 @@ public void Constructor_WithNullHtmlContent() act.Should().ThrowExactly() .WithParameterName("htmlContent"); } + + [Fact] + public void Importance_ValueChanged() + { + var from = new EmailContact(EmailAddress.Parse("from@domain.com"), "From"); + var to = new EmailContact(EmailAddress.Parse("to@domain.com"), "To"); + + var emailMessage = new EmailMessage( + from, + to, + "The subject", + "HTML content"); + + emailMessage.Importance = EmailImportance.High; + + emailMessage.Importance.Should().Be(EmailImportance.High); + } } } \ No newline at end of file diff --git a/tests/Emailing.Tests/EmailTest.cs b/tests/Emailing.Tests/EmailTest.cs index 6cc56dd..b5c3841 100644 --- a/tests/Emailing.Tests/EmailTest.cs +++ b/tests/Emailing.Tests/EmailTest.cs @@ -20,6 +20,7 @@ public void Constructor() var email = new Email(template); + email.Importance.Should().Be(EmailImportance.Normal); email.Recipients.Should().BeEmpty(); email.Template.Should().BeSameAs(template); } @@ -36,6 +37,21 @@ public void Constructor_WithNullTemplate() .WithParameterName("template"); } + [Fact] + public void Importance_ValueChanged() + { + var subject = Mock.Of>(MockBehavior.Strict); + var htmlContent = Mock.Of>(MockBehavior.Strict); + + var template = new EmailTemplate(subject, htmlContent); + + var email = new Email(template); + + email.Importance = EmailImportance.High; + + email.Importance.Should().Be(EmailImportance.High); + } + internal sealed class Model { } From 2a39dc924973db59bb1a125b814dc9e8d1e07536 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Tue, 18 Nov 2025 18:19:13 +0100 Subject: [PATCH 70/73] Change Microsoft.Graph version. --- Directory.Packages.props | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6fea702..6415f3a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,7 +20,7 @@ - + diff --git a/README.md b/README.md index 9ce41a7..a269e78 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ for more details. ### Example with Microsoft.Graph The [PosInformatique.Foundations.Emailing.Graph](https://www.nuget.org/packages/[PosInformatique.Foundations.Emailing.Graph/) -package depends on [Microsoft.Graph](https://www.nuget.org/packages/Microsoft.Graph/) **5.35.0** +package depends on [Microsoft.Graph](https://www.nuget.org/packages/Microsoft.Graph/) **5.89.0** for backward compatibility with a wide range of existing projects. If your application requires a newer version, you can simply add an explicit reference in your project, for example: From b183b51f9ba9af4d0c1dc5bd7cf7faf889be73a2 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Wed, 19 Nov 2025 08:21:26 +0100 Subject: [PATCH 71/73] Fix a bug in the EmailManager. --- src/Emailing/EmailManager.cs | 5 ++++- src/Emailing/EmailMessage.cs | 2 ++ tests/Emailing.Tests/EmailManagerTest.cs | 4 ++++ tests/Emailing.Tests/EmailMessageTest.cs | 7 ++----- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/Emailing/EmailManager.cs b/src/Emailing/EmailManager.cs index 7f7fc4f..2975047 100644 --- a/src/Emailing/EmailManager.cs +++ b/src/Emailing/EmailManager.cs @@ -73,7 +73,10 @@ public async Task SendAsync(Email email, CancellationToken cance new EmailContact(senderEmailAddress, string.Empty), new EmailContact(recipient.Address, recipient.DisplayName), subject, - htmlContent); + htmlContent) + { + Importance = email.Importance, + }; await this.provider.SendAsync(message, cancellationToken); } diff --git a/src/Emailing/EmailMessage.cs b/src/Emailing/EmailMessage.cs index 99a9523..1a5b583 100644 --- a/src/Emailing/EmailMessage.cs +++ b/src/Emailing/EmailMessage.cs @@ -33,6 +33,8 @@ public EmailMessage(EmailContact from, EmailContact to, string subject, string h this.To = to; this.Subject = subject; this.HtmlContent = htmlContent; + + this.Importance = EmailImportance.Normal; } /// diff --git a/tests/Emailing.Tests/EmailManagerTest.cs b/tests/Emailing.Tests/EmailManagerTest.cs index 33ae489..20faabd 100644 --- a/tests/Emailing.Tests/EmailManagerTest.cs +++ b/tests/Emailing.Tests/EmailManagerTest.cs @@ -44,6 +44,7 @@ public void Create() var email = manager.Create(identifier); + email.Importance.Should().Be(EmailImportance.Normal); email.Recipients.Should().BeEmpty(); email.Template.Should().BeSameAs(template); } @@ -130,6 +131,7 @@ public async Task SendAsync() var email = new Email(template) { + Importance = EmailImportance.High, Recipients = { new EmailRecipient(emailAddressRecipient1, "The display name 1", model1), @@ -148,6 +150,7 @@ public async Task SendAsync() { m.From.Email.Should().BeSameAs(sender); m.From.DisplayName.Should().BeEmpty(); + m.Importance.Should().Be(EmailImportance.High); m.Subject.Should().Be("Subject 1"); m.HtmlContent.Should().Be("HTML Content 1"); m.To.DisplayName.Should().Be("The display name 1"); @@ -158,6 +161,7 @@ public async Task SendAsync() { m.From.Email.Should().BeSameAs(sender); m.From.DisplayName.Should().BeEmpty(); + m.Importance.Should().Be(EmailImportance.High); m.Subject.Should().Be("Subject 2"); m.HtmlContent.Should().Be("HTML Content 2"); m.To.DisplayName.Should().Be("The display name 2"); diff --git a/tests/Emailing.Tests/EmailMessageTest.cs b/tests/Emailing.Tests/EmailMessageTest.cs index a984ff7..a43cfd6 100644 --- a/tests/Emailing.Tests/EmailMessageTest.cs +++ b/tests/Emailing.Tests/EmailMessageTest.cs @@ -20,13 +20,10 @@ public void Constructor() from, to, "The subject", - "HTML content") - { - Importance = EmailImportance.High, - }; + "HTML content"); emailMessage.From.Should().Be(from); - emailMessage.Importance.Should().Be(EmailImportance.High); + emailMessage.Importance.Should().Be(EmailImportance.Normal); emailMessage.HtmlContent.Should().Be("HTML content"); emailMessage.Subject.Should().Be("The subject"); emailMessage.To.Should().Be(to); From 42e006809776975b7f040d1f0d3b3f49375f8858 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Wed, 19 Nov 2025 08:24:08 +0100 Subject: [PATCH 72/73] Fix warnings. --- tests/.editorconfig | 3 +++ tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/.editorconfig b/tests/.editorconfig index 69eeef4..893a9e9 100644 --- a/tests/.editorconfig +++ b/tests/.editorconfig @@ -10,6 +10,9 @@ dotnet_diagnostic.BL0005.severity = none # CA1806: Do not ignore method results dotnet_diagnostic.CA1806.severity = none +# CA2016: Forward the 'CancellationToken' parameter to methods +dotnet_diagnostic.CA2016.severity = none + #### Sonar Analyzers #### # S1144: Unused private types or members should be removed diff --git a/tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs b/tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs index 6fb8e42..706dd2c 100644 --- a/tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs +++ b/tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs @@ -51,7 +51,7 @@ public async Task SendAsync(EmailImportance importance, Importance expectedImpor requestInfo.HttpMethod.Should().Be(Method.POST); requestInfo.URI.Should().Be("http://base/url/users/sender%40domain.com/sendMail"); - var jsonMessage = KiotaJsonSerializer.Deserialize(requestInfo.Content); + var jsonMessage = KiotaJsonSerializer.DeserializeAsync(requestInfo.Content).GetAwaiter().GetResult(); jsonMessage.Message.Attachments.Should().BeNull(); jsonMessage.Message.Body.Content.Should().Be("The HTML content"); From 15d68508ec6bdc0d6d037ad5a3f679bfec3df451 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Wed, 19 Nov 2025 09:14:59 +0100 Subject: [PATCH 73/73] Add the skip-duplicated option when publishing on NuGet. --- .github/workflows/github-actions-release.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/github-actions-release.yaml b/.github/workflows/github-actions-release.yaml index ee671c3..c196d88 100644 --- a/.github/workflows/github-actions-release.yaml +++ b/.github/workflows/github-actions-release.yaml @@ -34,4 +34,8 @@ jobs: --output ./artifacts - name: Publish the package to nuget.org - run: dotnet nuget push "./artifacts/*.nupkg" --api-key "${{ secrets.NUGET_APIKEY }}" --source https://api.nuget.org/v3/index.json + run: | + dotnet nuget push "./artifacts/*.nupkg" \ + --api-key "${{ secrets.NUGET_APIKEY }}" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate