diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1117d16 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,93 @@ +root = true + +[*] +charset = utf-8-bom +insert_final_newline = false +trim_trailing_whitespace = true + +# 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 +indent_size = 2 + +[*.{cs,vb}] + +#### Naming styles #### +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 +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_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 +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +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 new file mode 100644 index 0000000..b7681a6 --- /dev/null +++ b/.github/workflows/github-actions-ci.yaml @@ -0,0 +1,59 @@ +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.slnx + + - name: Build solution + run: | + dotnet build PosInformatique.Foundations.slnx \ + --configuration Release \ + --no-restore + + - name: Run tests (.NET 8.0) + run: | + dotnet test PosInformatique.Foundations.slnx \ + --configuration Release \ + --no-build \ + --logger "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" \ + --results-directory ./TestResults \ + --collect "XPlat Code Coverage" \ + --framework net9.0 + + - 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.yaml b/.github/workflows/github-actions-release.yaml new file mode 100644 index 0000000..c196d88 --- /dev/null +++ b/.github/workflows/github-actions-release.yaml @@ -0,0 +1,41 @@ +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.slnx \ + --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 \ + --skip-duplicate 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/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..4c60f5f --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,54 @@ + + + + + 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 + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..6415f3a --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,34 @@ + + + true + + 8.0.0 + 9.0.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Icon.png b/Icon.png new file mode 100644 index 0000000..1b2cc54 Binary files /dev/null and b/Icon.png differ diff --git a/PosInformatique.Foundations.slnx b/PosInformatique.Foundations.slnx new file mode 100644 index 0000000..cd18ea0 --- /dev/null +++ b/PosInformatique.Foundations.slnx @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 908ec8f..a269e78 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,97 @@ -# PosInformatique.Foundations -A lightweight collection of foundational .NET libraries for standardizing technical and functional development with reusable components. +# PosInformatique.Foundations + +PosInformatique.Foundations icon + +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. + +## ✨ 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. + +## 📦 Packages Overview + +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.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](./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) | +|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) | +|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.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) | +|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: Most of the packages are completely independent. You install only what you need. + +## 🚀 Why use PosInformatique.Foundations? + +- 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. + +## 📌 .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.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: + +```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 + +Licensed under the [MIT License](./LICENSE). 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" + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..a6e1a64 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,27 @@ + + + + + + + net8.0;net9.0 + 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.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..a609889 --- /dev/null +++ b/src/EmailAddresses.EntityFramework/EmailAddressPropertyExtensions.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.EmailAddresses; + + /// + /// Contains extension methods 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). + /// + /// 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 IsEmailAddress(this PropertyBuilder property) + { + ArgumentNullException.ThrowIfNull(property); + + if (typeof(T) != typeof(EmailAddress)) + { + throw new ArgumentException($"The '{nameof(IsEmailAddress)}()' method must be called on '{nameof(EmailAddress)} class.", 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(); + } + } +} \ No newline at end of file diff --git a/src/EmailAddresses.EntityFramework/EmailAddresses.EntityFramework.csproj b/src/EmailAddresses.EntityFramework/EmailAddresses.EntityFramework.csproj new file mode 100644 index 0000000..2bb7599 --- /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..a6f2ebc --- /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 +``` + +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) \ 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..067438d --- /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."; + } + } +} \ No newline at end of file diff --git a/src/EmailAddresses.FluentValidation/EmailAddresses.FluentValidation.csproj b/src/EmailAddresses.FluentValidation/EmailAddresses.FluentValidation.csproj new file mode 100644 index 0000000..035ca70 --- /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/EmailAddressesValidatorExtensions.cs b/src/EmailAddresses.FluentValidation/EmailAddressesValidatorExtensions.cs new file mode 100644 index 0000000..55f2467 --- /dev/null +++ b/src/EmailAddresses.FluentValidation/EmailAddressesValidatorExtensions.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 EmailAddressesValidatorExtensions + { + /// + /// 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); + + return ruleBuilder.SetValidator(new EmailAddressValidator()); + } + } +} \ No newline at end of file diff --git a/src/EmailAddresses.FluentValidation/README.md b/src/EmailAddresses.FluentValidation/README.md new file mode 100644 index 0000000..74ce2e0 --- /dev/null +++ b/src/EmailAddresses.FluentValidation/README.md @@ -0,0 +1,81 @@ +# 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. + +## 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](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) + +## 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/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..99a4745 --- /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()); + } + } +} \ No newline at end of file diff --git a/src/EmailAddresses.Json/EmailAddresses.Json.csproj b/src/EmailAddresses.Json/EmailAddresses.Json.csproj new file mode 100644 index 0000000..d7aa7d2 --- /dev/null +++ b/src/EmailAddresses.Json/EmailAddresses.Json.csproj @@ -0,0 +1,31 @@ + + + + true + + + Provides a System.Text.Json converter for the MimeType value object. + Enables seamless serialization and deserialization of MIME types within JSON documents. + + mimetype;mediatype;contenttype;valueobject;ddd;json;parsing;validation;dotnet;posinformatique + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + + + + + + + + + + + + + + + + + diff --git a/src/EmailAddresses.Json/EmailAddressesJsonSerializerOptionsExtensions.cs b/src/EmailAddresses.Json/EmailAddressesJsonSerializerOptionsExtensions.cs new file mode 100644 index 0000000..48c4596 --- /dev/null +++ b/src/EmailAddresses.Json/EmailAddressesJsonSerializerOptionsExtensions.cs @@ -0,0 +1,37 @@ +//----------------------------------------------------------------------- +// +// 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 for + /// JSON serialization. + /// + public static class EmailAddressesJsonSerializerOptionsExtensions + { + /// + /// 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); + + if (!options.Converters.Any(c => c is EmailAddressJsonConverter)) + { + options.Converters.Add(new EmailAddressJsonConverter()); + } + + return options; + } + } +} \ No newline at end of file diff --git a/src/EmailAddresses.Json/README.md b/src/EmailAddresses.Json/README.md new file mode 100644 index 0000000..1d11951 --- /dev/null +++ b/src/EmailAddresses.Json/README.md @@ -0,0 +1,83 @@ +# 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 +``` + +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. +- 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.EmailAddresses; +using PosInformatique.Foundations.EmailAddresses.Json; + +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.EmailAddresses; + +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 +- [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/CHANGELOG.md b/src/EmailAddresses/CHANGELOG.md new file mode 100644 index 0000000..206c6d3 --- /dev/null +++ b/src/EmailAddresses/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with strongly-typed EmailAddress value object. diff --git a/src/EmailAddresses/EmailAddress.cs b/src/EmailAddresses/EmailAddress.cs new file mode 100644 index 0000000..e1e9d9d --- /dev/null +++ b/src/EmailAddresses/EmailAddress.cs @@ -0,0 +1,326 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.EmailAddresses +{ + 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); + + return emailAddress.value; + } + + /// + /// Implicitly converts a to an . + /// + /// The string to convert to an email address. + /// An instance. + /// 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); + + return Parse(emailAddress); + } + + /// + /// 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); + + if (!TryParse(s, out var result)) + { + throw new FormatException($"'{s}' is not a valid email address."); + } + + return result; + } + + /// + /// 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. + static EmailAddress IParsable.Parse(string s, IFormatProvider? provider) + { + ArgumentNullException.ThrowIfNull(s); + + return Parse(s); + } + + /// + /// 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) + { + var emailAddress = TryParse(s); + + if (emailAddress is null) + { + result = null; + return false; + } + + result = new EmailAddress(emailAddress); + 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. + /// + /// 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; + } + } +} \ No newline at end of file diff --git a/src/EmailAddresses/EmailAddresses.csproj b/src/EmailAddresses/EmailAddresses.csproj new file mode 100644 index 0000000..22b7061 --- /dev/null +++ b/src/EmailAddresses/EmailAddresses.csproj @@ -0,0 +1,27 @@ + + + + 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 + + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/CHANGELOG.md")) + + + true + + + + + + + + + + + + diff --git a/src/EmailAddresses/Icon.png b/src/EmailAddresses/Icon.png new file mode 100644 index 0000000..ed1fab2 Binary files /dev/null and b/src/EmailAddresses/Icon.png differ 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); + } + } + } +} diff --git a/src/EmailAddresses/README.md b/src/EmailAddresses/README.md new file mode 100644 index 0000000..848c717 --- /dev/null +++ b/src/EmailAddresses/README.md @@ -0,0 +1,77 @@ +# 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. + +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: 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.FluentValidation/) +- [NuGet package: EmailAddresses.Json](https://www.nuget.org/packages/PosInformatique.Foundations.EmailAddresses.Json/) +- [Source code](https://github.com/PosInformatique/PosInformatique.Foundations) diff --git a/src/Emailing.Azure/AzureEmailProvider.cs b/src/Emailing.Azure/AzureEmailProvider.cs new file mode 100644 index 0000000..ca4fdf8 --- /dev/null +++ b/src/Emailing.Azure/AzureEmailProvider.cs @@ -0,0 +1,63 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Azure +{ + using System.Globalization; + + /// + /// 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) + { + 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); + } + } +} \ 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..b1185e7 --- /dev/null +++ b/src/Emailing.Azure/AzureEmailingBuilderExtensions.cs @@ -0,0 +1,80 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +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 + /// 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. + /// The instance to continue the configuration of the emailing feature. + /// Thrown when the argument is . + /// Thrown when the argument is . + public static EmailingBuilder 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); + } + }); + + return builder; + } + + /// + /// 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. + /// The instance to continue the configuration of the emailing feature. + /// Thrown when the argument is . + /// Thrown when the argument is . + public static EmailingBuilder 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); + } + }); + + return builder; + } + } +} \ 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..e706330 --- /dev/null +++ b/src/Emailing.Azure/README.md @@ -0,0 +1,109 @@ +# 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; + +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; + +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.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..bcfbc0c --- /dev/null +++ b/src/Emailing.Graph/GraphEmailProvider.cs @@ -0,0 +1,78 @@ +//----------------------------------------------------------------------- +// +// 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 importance = message.Importance switch + { + EmailImportance.Low => Importance.Low, + EmailImportance.High => Importance.High, + _ => Importance.Normal, + }; + + var graphMessage = new Message() + { + Body = new ItemBody + { + ContentType = BodyType.Html, + Content = message.HtmlContent, + }, + Importance = importance, + Subject = message.Subject, + ToRecipients = + [ + 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..600b651 --- /dev/null +++ b/src/Emailing.Graph/GraphEmailingBuilderExtensions.cs @@ -0,0 +1,45 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +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 + /// 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. + /// The instance to continue the configuration of the emailing feature. + /// Thrown when the argument is . + /// Thrown when the argument is . + public static EmailingBuilder 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); + }); + + return builder; + } + } +} \ 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.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..6264b3a --- /dev/null +++ b/src/Emailing.Templates.Razor/README.md @@ -0,0 +1,221 @@ +# 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/) +- [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. + +## 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). +- `UseRazorEmailTemplates()` extension method to enable Razor-based email templates in the emailing configuration. + +## Configuring emailing with Razor email templates + +### 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: + +```csharp +using PosInformatique.Foundations.Emailing; + +public sealed class InvitationEmailTemplateModel +{ + public string FirstName { get; set; } = string.Empty; + public string InvitationLink { get; set; } = string.Empty; +} +``` + +### 3. 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. + +### 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. + +`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..ebb5732 --- /dev/null +++ b/src/Emailing.Templates.Razor/RazorEmailTemplate.cs @@ -0,0 +1,32 @@ +//----------------------------------------------------------------------- +// +// 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 + { + /// + /// 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..96fb275 --- /dev/null +++ b/src/Emailing.Templates.Razor/RazorEmailTemplateBody.cs @@ -0,0 +1,30 @@ +//----------------------------------------------------------------------- +// +// 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 + { + /// + /// 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/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/src/Emailing.Templates.Razor/RazorEmailTemplateSubject.cs b/src/Emailing.Templates.Razor/RazorEmailTemplateSubject.cs new file mode 100644 index 0000000..ef56752 --- /dev/null +++ b/src/Emailing.Templates.Razor/RazorEmailTemplateSubject.cs @@ -0,0 +1,30 @@ +//----------------------------------------------------------------------- +// +// 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 + { + /// + /// 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/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..4fe4b81 --- /dev/null +++ b/src/Emailing/Email.cs @@ -0,0 +1,46 @@ +//----------------------------------------------------------------------- +// +// 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 + { + /// + /// 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.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. + /// + 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/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/EmailManager.cs b/src/Emailing/EmailManager.cs new file mode 100644 index 0000000..2975047 --- /dev/null +++ b/src/Emailing/EmailManager.cs @@ -0,0 +1,95 @@ +//----------------------------------------------------------------------- +// +// 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) + { + 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) + { + 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) + { + Importance = email.Importance, + }; + + 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..1a5b583 --- /dev/null +++ b/src/Emailing/EmailMessage.cs @@ -0,0 +1,65 @@ +//----------------------------------------------------------------------- +// +// 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; + + this.Importance = EmailImportance.Normal; + } + + /// + /// 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; } + + /// + /// 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/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..c2a1f30 --- /dev/null +++ b/src/Emailing/EmailRecipientCollection.cs @@ -0,0 +1,41 @@ +//----------------------------------------------------------------------- +// +// 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> + { + /// + /// 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..769293d --- /dev/null +++ b/src/Emailing/EmailTemplate.cs @@ -0,0 +1,43 @@ +//----------------------------------------------------------------------- +// +// 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 + { + /// + /// 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..f1eae00 --- /dev/null +++ b/src/Emailing/EmailTemplateIdentifier.cs @@ -0,0 +1,28 @@ +//----------------------------------------------------------------------- +// +// 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 + { + 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..f8dfc5c --- /dev/null +++ b/src/Emailing/EmailingBuilder.cs @@ -0,0 +1,32 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace 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..4a0a517 --- /dev/null +++ b/src/Emailing/EmailingOptions.cs @@ -0,0 +1,63 @@ +//----------------------------------------------------------------------- +// +// 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) + { + 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) + { + 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..3d96e15 --- /dev/null +++ b/src/Emailing/IEmailManager.cs @@ -0,0 +1,40 @@ +//----------------------------------------------------------------------- +// +// 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); + + /// + /// 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); + } +} \ 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 0000000..a867ea3 Binary files /dev/null and b/src/Emailing/Icon.png differ diff --git a/src/Emailing/README.md b/src/Emailing/README.md new file mode 100644 index 0000000..53b1898 --- /dev/null +++ b/src/Emailing/README.md @@ -0,0 +1,251 @@ +# 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, 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 + +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 any custom class. +- Template-based subject and HTML body using `TextTemplate` (e.g. Razor or Scriban). +- 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 + +### Email models + +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 +{ + public string FirstName { get; set; } = string.Empty; + public string InvitationLink { get; set; } = string.Empty; +} + +public sealed class AccountDeletionEmailTemplateModel +{ + 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); +}); +``` + +> **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). + +### 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) +or [PosInformatique.Foundations.Emailing.Graph](../Emailing.Graph/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); + +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 + +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 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`. + +## 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/) +- [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/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..1964d23 --- /dev/null +++ b/src/MediaTypes.EntityFramework/README.md @@ -0,0 +1,82 @@ +# PosInformatique.Foundations.MediaTypes.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 `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.MediaTypes.EntityFramework +``` + +This package depends on the base package [PosInformatique.Foundations.MediaTypes](https://www.nuget.org/packages/PosInformatique.Foundations.MediaTypes/). + +## 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. + +## 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. + +## 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: + +```sql +CREATE TYPE MimeType FROM VARCHAR(128) NOT NULL; +``` + +### Example: Configure an entity + +```csharp +using Microsoft.EntityFrameworkCore; +using PosInformatique.Foundations.MediaTypes; + +public class Document +{ + public int Id { get; set; } + + public MimeType ContentType { get; set; } +} + +public class ApplicationDbContext : DbContext +{ + public DbSet Documents => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .Property(d => d.ContentType) + .IsMimeType(); + } +} +``` + +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: 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.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..cf6d284 --- /dev/null +++ b/src/MediaTypes.Json/MediaTypes.Json.csproj @@ -0,0 +1,31 @@ + + + + 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")) + + + + + + + + + + + + + + + + + + + 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/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 0000000..40be9e6 Binary files /dev/null and b/src/MediaTypes/Icon.png differ diff --git a/src/MediaTypes/MediaTypes.csproj b/src/MediaTypes/MediaTypes.csproj new file mode 100644 index 0000000..0f43a8b --- /dev/null +++ b/src/MediaTypes/MediaTypes.csproj @@ -0,0 +1,27 @@ + + + + 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")) + + + + + + + + + + + + + + + diff --git a/src/MediaTypes/MimeType.cs b/src/MediaTypes/MimeType.cs new file mode 100644 index 0000000..663ca01 --- /dev/null +++ b/src/MediaTypes/MimeType.cs @@ -0,0 +1,208 @@ +//----------------------------------------------------------------------- +// +// 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, IFormattable, 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); + + if (TryParse(s, out var result)) + { + return result; + } + + throw new FormatException("Invalid MIME type format."); + } + + /// + /// 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. + static MimeType IParsable.Parse(string s, IFormatProvider? provider) + { + ArgumentNullException.ThrowIfNull(s); + + return Parse(s); + } + + /// + /// 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) + { + 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; + } + + /// + /// 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. + /// + /// 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}"; + } + + /// + string IFormattable.ToString(string? format, IFormatProvider? formatProvider) + { + return this.ToString(); + } + + /// + /// 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..41d83d0 --- /dev/null +++ b/src/MediaTypes/MimeTypeExtensions.cs @@ -0,0 +1,63 @@ +//----------------------------------------------------------------------- +// +// 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 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. + /// + /// 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 AutoCAD drawing are excluded). + /// + /// 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" && !IsAutoCad(mimeType); + } + } +} \ No newline at end of file diff --git a/src/MediaTypes/MimeTypes.cs b/src/MediaTypes/MimeTypes.cs new file mode 100644 index 0000000..51e3621 --- /dev/null +++ b/src/MediaTypes/MimeTypes.cs @@ -0,0 +1,132 @@ +//----------------------------------------------------------------------- +// +// 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() + { + { "pdf", Application.Pdf }, + { "docx", Application.Docx }, + { "bmp", Image.Bmp }, + { "dxf", Image.Dxf }, + { "dwg", Image.Dwg }, + { "jpg", Image.Jpeg }, + { "jpeg", Image.Jpeg }, + { "png", Image.Png }, + { "tif", Image.Tiff }, + { "tiff", Image.Tiff }, + { "webp", Image.WebP }, + }; + + private static readonly Dictionary ToExtensions = new() + { + { Application.Pdf, "pdf" }, + { Application.Docx, "docx" }, + { Image.Bmp, "bmp" }, + { Image.Dxf, "dxf" }, + { Image.Dwg, "dwg" }, + { 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"); + + /// + /// Gets the media type application/pdf. + /// + 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"); + } + + /// + /// Common image/* media types. + /// + public static class Image + { + /// + /// Gets the media type image/bmp. + /// + public static MimeType Bmp { get; } = MimeType.Parse("image/bmp"); + + /// + /// 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. + /// + public static MimeType Jpeg { get; } = MimeType.Parse("image/jpeg"); + + /// + /// Gets the media type image/png. + /// + public static MimeType Png { get; } = MimeType.Parse("image/png"); + + /// + /// Gets the media type image/tiff. + /// + public static MimeType Tiff { get; } = MimeType.Parse("image/tiff"); + + /// + /// Gets the media type image/webp. + /// + public static MimeType WebP { get; } = MimeType.Parse("image/webp"); + } + } +} \ No newline at end of file diff --git a/src/MediaTypes/README.md b/src/MediaTypes/README.md new file mode 100644 index 0000000..2f69ae7 --- /dev/null +++ b/src/MediaTypes/README.md @@ -0,0 +1,150 @@ +# 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`). +- 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. +- 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."); +} + +var drawing = MimeTypes.Image.Dwg; +if (drawing.IsAutoCad()) +{ + Console.WriteLine("This is an AutoCAD drawing 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` + - `OctetStream` (`application/octet-stream`) + - `Pdf` (`application/pdf`) + - `Docx` (`application/vnd.openxmlformats-officedocument.wordprocessingml.document`) + +- `MimeTypes.Image` + - `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 IsAutoCad(this MimeType mimeType)` +- `bool IsImage(this MimeType mimeType)` +- `bool IsPdf(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/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..bf5ad6d --- /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); + } + } +} \ No newline at end of file diff --git a/src/People.DataAnnotations/LastNameAttribute.cs b/src/People.DataAnnotations/LastNameAttribute.cs new file mode 100644 index 0000000..34e9f43 --- /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); + } + } +} \ No newline at end of file diff --git a/src/People.DataAnnotations/People.DataAnnotations.csproj b/src/People.DataAnnotations/People.DataAnnotations.csproj new file mode 100644 index 0000000..3070cad --- /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.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..a708c79 --- /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 methods 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(); + } + } +} \ No newline at end of file diff --git a/src/People.EntityFramework/LastNamePropertyExtensions.cs b/src/People.EntityFramework/LastNamePropertyExtensions.cs new file mode 100644 index 0000000..b5685a6 --- /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 methods 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); + + 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(); + } + } +} \ No newline at end of file diff --git a/src/People.EntityFramework/People.EntityFramework.csproj b/src/People.EntityFramework/People.EntityFramework.csproj new file mode 100644 index 0000000..3194793 --- /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/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..92288a8 --- /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); + } + } +} \ No newline at end of file diff --git a/src/People.FluentAssertions/LastNameAssertions.cs b/src/People.FluentAssertions/LastNameAssertions.cs new file mode 100644 index 0000000..790f537 --- /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); + } + } +} \ No newline at end of file diff --git a/src/People.FluentAssertions/People.FluentAssertions.csproj b/src/People.FluentAssertions/People.FluentAssertions.csproj new file mode 100644 index 0000000..9f3244f --- /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..bc3ed10 --- /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); + + 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); + + return new LastNameAssertions(subject); + } + } +} \ No newline at end of file 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/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..d55540d --- /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."; + } + } +} \ No newline at end of file diff --git a/src/People.FluentValidation/LastNameValidator.cs b/src/People.FluentValidation/LastNameValidator.cs new file mode 100644 index 0000000..bcea2b9 --- /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."; + } + } +} \ No newline at end of file diff --git a/src/People.FluentValidation/NameValidatorExtensions.cs b/src/People.FluentValidation/NameValidatorExtensions.cs new file mode 100644 index 0000000..54ce100 --- /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); + + 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); + + return ruleBuilder.SetValidator(new LastNameValidator()); + } + } +} \ No newline at end of file diff --git a/src/People.FluentValidation/People.FluentValidation.csproj b/src/People.FluentValidation/People.FluentValidation.csproj new file mode 100644 index 0000000..5e1b039 --- /dev/null +++ b/src/People.FluentValidation/People.FluentValidation.csproj @@ -0,0 +1,30 @@ + + + + true + + + Provides FluentValidation extensions to validate first and last names + using the strongly-typed FirstName and LastName value objects from PosInformatique.Foundations.People. + + 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..54ec23c --- /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](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. + +## 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/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..d0da749 --- /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); + } + } +} \ No newline at end of file diff --git a/src/People.Json/LastNameJsonConverter.cs b/src/People.Json/LastNameJsonConverter.cs new file mode 100644 index 0000000..07db2c7 --- /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); + } + } +} \ No newline at end of file diff --git a/src/People.Json/People.Json.csproj b/src/People.Json/People.Json.csproj new file mode 100644 index 0000000..b384b8d --- /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..733616c --- /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); + + if (!options.Converters.Any(c => c is FirstNameJsonConverter)) + { + options.Converters.Add(new FirstNameJsonConverter()); + } + + if (!options.Converters.Any(c => c is LastNameJsonConverter)) + { + options.Converters.Add(new LastNameJsonConverter()); + } + + return options; + } + } +} \ No newline at end of file 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/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..0dc465d --- /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); + + 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); + } + } + } +} \ No newline at end of file diff --git a/src/People/IPerson.cs b/src/People/IPerson.cs new file mode 100644 index 0000000..f5d2a1d --- /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; } + } +} \ No newline at end of file diff --git a/src/People/Icon.png b/src/People/Icon.png new file mode 100644 index 0000000..49e0da9 Binary files /dev/null and b/src/People/Icon.png differ diff --git a/src/People/LastName.cs b/src/People/LastName.cs new file mode 100644 index 0000000..4e223dc --- /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); + + 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); + } + } + } +} \ No newline at end of file diff --git a/src/People/NameNormalizer.cs b/src/People/NameNormalizer.cs new file mode 100644 index 0000000..3b7fee9 --- /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); + ArgumentNullException.ThrowIfNull(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); + ArgumentNullException.ThrowIfNull(lastName); + + return $"{lastName} {firstName}"; + } + } +} \ No newline at end of file diff --git a/src/People/People.csproj b/src/People/People.csproj new file mode 100644 index 0000000..60d7f08 --- /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..8e5e324 --- /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); + + 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); + + 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); + + return $"{person.FirstName[0]}{person.LastName[0]}"; + } + } +} \ No newline at end of file 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/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..28ffc12 --- /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(); + } + } +} \ No newline at end of file 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..918970b --- /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.FluentValidation/CHANGELOG.md b/src/PhoneNumbers.FluentValidation/CHANGELOG.md new file mode 100644 index 0000000..8c2a47f --- /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 PhoneNumber 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.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/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 0000000..a39aa58 Binary files /dev/null and b/src/PhoneNumbers/Icon.png differ 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..14a55ba --- /dev/null +++ b/src/PhoneNumbers/README.md @@ -0,0 +1,161 @@ +# 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 (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/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..016a229 --- /dev/null +++ b/src/Text.Templating.Razor/IRazorTextTemplateRenderer.cs @@ -0,0 +1,24 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +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/src/Text.Templating.Razor/README.md b/src/Text.Templating.Razor/README.md new file mode 100644 index 0000000..533c3f5 --- /dev/null +++ b/src/Text.Templating.Razor/README.md @@ -0,0 +1,163 @@ +### 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: 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/src/Text.Templating.Razor/RazorTextTemplate.cs b/src/Text.Templating.Razor/RazorTextTemplate.cs new file mode 100644 index 0000000..8d30e9d --- /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 text using a Razor component as text template. + /// + /// 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.Scriban/CHANGELOG.md b/src/Text.Templating.Scriban/CHANGELOG.md new file mode 100644 index 0000000..11422a3 --- /dev/null +++ b/src/Text.Templating.Scriban/CHANGELOG.md @@ -0,0 +1,2 @@ +1.0.0 + - Initial release with the Scriban 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/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 0000000..62a86e5 Binary files /dev/null and b/src/Text.Templating/Icon.png differ diff --git a/src/Text.Templating/README.md b/src/Text.Templating/README.md new file mode 100644 index 0000000..6812a46 --- /dev/null +++ b/src/Text.Templating/README.md @@ -0,0 +1,44 @@ +# PosInformatique.Foundations.Text.Templating + +[![NuGet version](https://img.shields.io/nuget/v/PosInformatique.Foundations.Text.Templating)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating/) +[![NuGet downloads](https://img.shields.io/nuget/dt/PosInformatique.Foundations.Text.Templating)](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating/) + +## Introduction + +This package provides a small abstraction to generate text from templates, independently of the underlying templating engine. + +It defines the `TextTemplate` 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/) +- [PosInformatique.Foundations.Text.Templating.Scriban](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Scriban/) + +## 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/) +- [PosInformatique.Foundations.Text.Templating.Scriban](https://www.nuget.org/packages/PosInformatique.Foundations.Text.Templating.Scriban/) + +## 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.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/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/stylecop.json b/stylecop.json new file mode 100644 index 0000000..fca1ad8 --- /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..893a9e9 --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1,44 @@ +[*.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 +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 +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 + +# S3878: Arrays should not be created for params parameters +dotnet_diagnostic.S3878.severity = none + +#### 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 + +# SA1601: Partial elements should be documented +dotnet_diagnostic.SA1601.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 new file mode 100644 index 0000000..94f80d6 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,53 @@ + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + net8.0;net9.0 + + + $(SolutionDir)\CodeCoverage.runsettings + + + false + + + $(NoWarn);SA0001 + + + + + + + + + + + + + <_Parameter1>DynamicProxyGenAssembly2 + + + + diff --git a/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs b/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs new file mode 100644 index 0000000..e94fbd0 --- /dev/null +++ b/tests/EmailAddresses.EntityFramework.Tests/EmailAddressPropertyExtensionsTest.cs @@ -0,0 +1,153 @@ +//----------------------------------------------------------------------- +// +// 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); + + property = entity.GetProperty("NullableEmailAddress"); + + 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"); + } + + [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 'property')") + .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); + + var nullableProperty = modelBuilder.Entity() + .Property(e => e.NullableEmailAddress); + + nullableProperty.IsEmailAddress().Should().BeSameAs(nullableProperty); + } + } + + private class EntityMock + { + public int Id { get; set; } + + public EmailAddress EmailAddress { get; set; } + +#nullable enable + public EmailAddress? NullableEmailAddress { get; set; } +#nullable restore + } + } +} \ No newline at end of file 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 @@ + + + + + + + + + + + diff --git a/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorTest.cs b/tests/EmailAddresses.FluentValidation.Tests/EmailAddressValidatorTest.cs new file mode 100644 index 0000000..f8d767d --- /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(); + } + } +} \ No newline at end of file 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..0e11a95 --- /dev/null +++ b/tests/EmailAddresses.FluentValidation.Tests/EmailAddresses.FluentValidation.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/EmailAddresses.FluentValidation.Tests/EmailAddressesValidatorExtensionsTest.cs b/tests/EmailAddresses.FluentValidation.Tests/EmailAddressesValidatorExtensionsTest.cs new file mode 100644 index 0000000..d8847bf --- /dev/null +++ b/tests/EmailAddresses.FluentValidation.Tests/EmailAddressesValidatorExtensionsTest.cs @@ -0,0 +1,37 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace FluentValidation.Tests +{ + public class EmailAddressesValidatorExtensionsTest + { + [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(); + } + + [Fact] + public void MustBeEmailAddress_NullRuleBuilderArgument() + { + var act = () => + { + EmailAddressesValidatorExtensions.MustBeEmailAddress((IRuleBuilder)null); + }; + + act.Should().ThrowExactly() + .WithParameterName("ruleBuilder"); + } + } +} \ No newline at end of file diff --git a/tests/EmailAddresses.Json.Tests/EmailAddressJsonConverterTest.cs b/tests/EmailAddresses.Json.Tests/EmailAddressJsonConverterTest.cs new file mode 100644 index 0000000..8b3a064 --- /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; } + } + } +} \ No newline at end of file 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..7e3f218 --- /dev/null +++ b/tests/EmailAddresses.Json.Tests/EmailAddresses.Json.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/EmailAddresses.Json.Tests/EmailAddressesJsonSerializerOptionsExtensionsTest.cs b/tests/EmailAddresses.Json.Tests/EmailAddressesJsonSerializerOptionsExtensionsTest.cs new file mode 100644 index 0000000..8d206a0 --- /dev/null +++ b/tests/EmailAddresses.Json.Tests/EmailAddressesJsonSerializerOptionsExtensionsTest.cs @@ -0,0 +1,42 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace System.Text.Json.Tests +{ + using PosInformatique.Foundations.EmailAddresses.Json; + + public class EmailAddressesJsonSerializerOptionsExtensionsTest + { + [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 = () => + { + EmailAddressesJsonSerializerOptionsExtensions.AddEmailAddressesConverters(null); + }; + + act.Should().ThrowExactly() + .WithParameterName("options"); + } + } +} \ 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..66b10de --- /dev/null +++ b/tests/EmailAddresses.Tests/EmailAddressTest.cs @@ -0,0 +1,379 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.EmailAddresses.Tests +{ + public class EmailAddressTest + { + [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(EmailAddressTestData.InvalidEmailAddresses), MemberType = typeof(EmailAddressTestData))] + 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 = CallParse(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 = () => + { + CallParse(null, default); + }; + + act.Should().ThrowExactly() + .WithParameterName("s"); + } + + [Theory] + [MemberData(nameof(EmailAddressTestData.InvalidEmailAddresses), MemberType = typeof(EmailAddressTestData))] + public void Parse_WithFormatProvider_InvalidEmailAddress(string invalidEmailAdddress) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var act = () => CallParse(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(EmailAddressTestData.InvalidEmailAddresses), MemberType = typeof(EmailAddressTestData))] + [InlineData(null)] + 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 = CallTryParse(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(EmailAddressTestData.InvalidEmailAddresses), MemberType = typeof(EmailAddressTestData))] + [InlineData(null)] + public void TryParse_WithFormatProvider_InvalidEmailAddress(string invalidEmailAdddress) + { + var formatProvider = Mock.Of(MockBehavior.Strict); + + var result = CallTryParse(invalidEmailAdddress, formatProvider, out var address); + + result.Should().BeFalse(); + address.Should().BeNull(); + } + + [Theory] + [MemberData(nameof(EmailAddressTestData.ValidEmailAddresses), MemberType = typeof(EmailAddressTestData))] + public void IsValid_Valid(string emailAddress) + { + EmailAddress.IsValid(emailAddress).Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(EmailAddressTestData.InvalidEmailAddresses), MemberType = typeof(EmailAddressTestData))] + [InlineData(null)] + 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 _ = (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 _ = (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); + } + + 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/EmailAddresses.Tests/EmailAddressTestData.cs b/tests/EmailAddresses.Tests/EmailAddressTestData.cs new file mode 100644 index 0000000..45f5f99 --- /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", + ]; + } +} \ No newline at end of file diff --git a/tests/EmailAddresses.Tests/EmailAddresses.Tests.csproj b/tests/EmailAddresses.Tests/EmailAddresses.Tests.csproj new file mode 100644 index 0000000..2d74a14 --- /dev/null +++ b/tests/EmailAddresses.Tests/EmailAddresses.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/Emailing.Azure.Tests/AzureEmailProviderTest.cs b/tests/Emailing.Azure.Tests/AzureEmailProviderTest.cs new file mode 100644 index 0000000..885c8a2 --- /dev/null +++ b/tests/Emailing.Azure.Tests/AzureEmailProviderTest.cs @@ -0,0 +1,82 @@ +//----------------------------------------------------------------------- +// +// 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"); + } + + [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") + { + 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"); + 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(); + } + + [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.Azure.Tests/AzureEmailingBuilderExtensionsTest.cs b/tests/Emailing.Azure.Tests/AzureEmailingBuilderExtensionsTest.cs new file mode 100644 index 0000000..fd82756 --- /dev/null +++ b/tests/Emailing.Azure.Tests/AzureEmailingBuilderExtensionsTest.cs @@ -0,0 +1,175 @@ +//----------------------------------------------------------------------- +// +// 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==") + .Should().BeSameAs(builder); + + 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; + }) + .Should().BeSameAs(builder); + + 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/")) + .Should().BeSameAs(builder); + + 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; + }) + .Should().BeSameAs(builder); + + 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..a84162b --- /dev/null +++ b/tests/Emailing.Azure.Tests/Emailing.Azure.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + 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..4e2f3cb --- /dev/null +++ b/tests/Emailing.Graph.Tests/GraphBuilderExtensionsTest.cs @@ -0,0 +1,100 @@ +//----------------------------------------------------------------------- +// +// 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) + .Should().BeSameAs(builder); + + 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..706dd2c --- /dev/null +++ b/tests/Emailing.Graph.Tests/GraphEmailProviderTest.cs @@ -0,0 +1,108 @@ +//----------------------------------------------------------------------- +// +// 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"); + } + + [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; + + 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.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"); + 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") + { + Importance = importance, + }; + + 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 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/RazorEmailTemplateBodyTest.cs b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateBodyTest.cs new file mode 100644 index 0000000..da17868 --- /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 + { + } + } +} \ No newline at end of file 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 diff --git a/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateSubjectTest.cs b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateSubjectTest.cs new file mode 100644 index 0000000..5cc13f5 --- /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 + { + } + } +} \ No newline at end of file diff --git a/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateTest.cs b/tests/Emailing.Templates.Razor.Tests/RazorEmailTemplateTest.cs new file mode 100644 index 0000000..bea309f --- /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 + { + } + + private sealed class SubjectComponent : RazorEmailTemplateSubject + { + } + + private sealed class BodyComponent : RazorEmailTemplateBody + { + } + } +} \ No newline at end of file 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..20faabd --- /dev/null +++ b/tests/Emailing.Tests/EmailManagerTest.cs @@ -0,0 +1,197 @@ +//----------------------------------------------------------------------- +// +// 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.Importance.Should().Be(EmailImportance.Normal); + 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) + { + Importance = EmailImportance.High, + 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.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"); + }) + .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.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"); + }) + .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 async Task SendAsync_WithNullIdentifier() + { + var options = new EmailingOptions(); + options.SenderEmailAddress = EmailAddress.Parse("sender@domain.com"); + + var manager = new EmailManager(Options.Create(options), default, default); + + await manager.Invoking(m => m.SendAsync(null, default)) + .Should().ThrowExactlyAsync() + .WithParameterName("email"); + } + + internal sealed class Model + { + } + } +} \ 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..a43cfd6 --- /dev/null +++ b/tests/Emailing.Tests/EmailMessageTest.cs @@ -0,0 +1,105 @@ +//----------------------------------------------------------------------- +// +// 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.Importance.Should().Be(EmailImportance.Normal); + 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"); + } + + [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/EmailRecipientCollectionTest.cs b/tests/Emailing.Tests/EmailRecipientCollectionTest.cs new file mode 100644 index 0000000..7a48f84 --- /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 + { + } + } +} \ 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..675bf63 --- /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 + { + } + } +} \ 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..9c445f0 --- /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 + { + } + } +} \ 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..28c254f --- /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 + { + } + } +} \ 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..b5c3841 --- /dev/null +++ b/tests/Emailing.Tests/EmailTest.cs @@ -0,0 +1,59 @@ +//----------------------------------------------------------------------- +// +// 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.Importance.Should().Be(EmailImportance.Normal); + email.Recipients.Should().BeEmpty(); + email.Template.Should().BeSameAs(template); + } + + [Fact] + public void Constructor_WithNullTemplate() + { + var act = () => + { + new Email(null); + }; + + act.Should().ThrowExactly() + .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 + { + } + } +} \ 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..adde1d7 --- /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 + { + } + } +} \ 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/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 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 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..ab2508d --- /dev/null +++ b/tests/MediaTypes.Tests/MimeTypeExtensionsTest.cs @@ -0,0 +1,92 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.MediaTypes.Tests +{ + public class MimeTypeExtensionsTest + { + [Fact] + public void IsAutoCad() + { + MimeTypes.Application.Docx.IsAutoCad().Should().BeFalse(); + MimeTypes.Application.Pdf.IsAutoCad().Should().BeFalse(); + MimeTypes.Application.OctetStream.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 IsAutoCad_WithNullArgument() + { + var act = () => + { + MimeTypeExtensions.IsAutoCad(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.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(); + } + + [Fact] + public void IsImage_WithNullArgument() + { + var act = () => + { + MimeTypeExtensions.IsImage(null); + }; + + 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/MimeTypeTest.cs b/tests/MediaTypes.Tests/MimeTypeTest.cs new file mode 100644 index 0000000..f6176f6 --- /dev/null +++ b/tests/MediaTypes.Tests/MimeTypeTest.cs @@ -0,0 +1,297 @@ +//----------------------------------------------------------------------- +// +// 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 = CallParse(input, formatProvider); + + mimeType.Type.Should().Be(expectedType); + mimeType.Subtype.Should().Be(expectedSubtype); + } + + [Fact] + public void Parse_WithFormatProvider_WithNullArgument() + { + var act = () => + { + CallParse(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 = () => + { + CallParse(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); + + CallTryParse(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); + + CallTryParse(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"); + } + + [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("."); + + var type = typeof(MimeTypes).GetNestedType(properties[0]); + var propertySubType = type.GetProperty(properties[1]); + + 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 diff --git a/tests/MediaTypes.Tests/MimeTypesTest.cs b/tests/MediaTypes.Tests/MimeTypesTest.cs new file mode 100644 index 0000000..9b8f077 --- /dev/null +++ b/tests/MediaTypes.Tests/MimeTypesTest.cs @@ -0,0 +1,101 @@ +//----------------------------------------------------------------------- +// +// 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_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() + { + 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 diff --git a/tests/People.DataAnnotations.Tests/FirstNameAttributeTest.cs b/tests/People.DataAnnotations.Tests/FirstNameAttributeTest.cs new file mode 100644 index 0000000..c0a9b88 --- /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(); + } + } +} \ No newline at end of file diff --git a/tests/People.DataAnnotations.Tests/LastNameAttributeTest.cs b/tests/People.DataAnnotations.Tests/LastNameAttributeTest.cs new file mode 100644 index 0000000..dbeb13f --- /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(); + } + } +} \ No newline at end of file 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.EntityFramework.Tests/FirstNamePropertyExtensionsTest.cs b/tests/People.EntityFramework.Tests/FirstNamePropertyExtensionsTest.cs new file mode 100644 index 0000000..401d78b --- /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; } + } + } +} \ No newline at end of file diff --git a/tests/People.EntityFramework.Tests/LastNamePropertyExtensionsTest.cs b/tests/People.EntityFramework.Tests/LastNamePropertyExtensionsTest.cs new file mode 100644 index 0000000..af80945 --- /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; } + } + } +} \ No newline at end of file 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 @@ + + + + + + + + + + + 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..91c2465 --- /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); + } + } +} \ No newline at end of file diff --git a/tests/People.FluentValidation.Tests/FirstNameValidatorTest.cs b/tests/People.FluentValidation.Tests/FirstNameValidatorTest.cs new file mode 100644 index 0000000..20c3123 --- /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(); + } + } +} \ No newline at end of file diff --git a/tests/People.FluentValidation.Tests/LastNameValidatorTest.cs b/tests/People.FluentValidation.Tests/LastNameValidatorTest.cs new file mode 100644 index 0000000..0b1e127 --- /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(); + } + } +} \ No newline at end of file diff --git a/tests/People.FluentValidation.Tests/NameValidatorExtensionsTest.cs b/tests/People.FluentValidation.Tests/NameValidatorExtensionsTest.cs new file mode 100644 index 0000000..1c74ce2 --- /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"); + } + } +} \ No newline at end of file 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 @@ + + + + + + + + + + + diff --git a/tests/People.Json.Tests/FirstNameJsonConverterTest.cs b/tests/People.Json.Tests/FirstNameJsonConverterTest.cs new file mode 100644 index 0000000..66553e9 --- /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; } + } + } +} \ No newline at end of file diff --git a/tests/People.Json.Tests/LastNameJsonConverterTest.cs b/tests/People.Json.Tests/LastNameJsonConverterTest.cs new file mode 100644 index 0000000..48ce550 --- /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; } + } + } +} \ No newline at end of file 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..9d81aa5 --- /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"); + } + } +} \ No newline at end of file diff --git a/tests/People.Tests/FirstNameTest.cs b/tests/People.Tests/FirstNameTest.cs new file mode 100644 index 0000000..0b72739 --- /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); + } + } +} \ No newline at end of file diff --git a/tests/People.Tests/LastNameTest.cs b/tests/People.Tests/LastNameTest.cs new file mode 100644 index 0000000..b11306b --- /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); + } + } +} \ No newline at end of file diff --git a/tests/People.Tests/NameNormalizerTest.cs b/tests/People.Tests/NameNormalizerTest.cs new file mode 100644 index 0000000..3101901 --- /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"); + } + } +} \ No newline at end of file diff --git a/tests/People.Tests/NameTestData.cs b/tests/People.Tests/NameTestData.cs new file mode 100644 index 0000000..1a8b99a --- /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, + " ", + }; + } +} \ No newline at end of file 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..46b4632 --- /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"); + } + } +} \ 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 @@ + + + + + + + + + + + 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 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 diff --git a/tests/PhoneNumbers.Tests/PhoneNumberTest.cs b/tests/PhoneNumbers.Tests/PhoneNumberTest.cs new file mode 100644 index 0000000..0faa92c --- /dev/null +++ b/tests/PhoneNumbers.Tests/PhoneNumberTest.cs @@ -0,0 +1,316 @@ +//----------------------------------------------------------------------- +// +// 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))] + [InlineData(null)] + 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))] + [InlineData(null)] + 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 @@ + + + + + + + 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..904f357 --- /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; } + } +} \ No newline at end of file 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..cfa41e4 --- /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 async Task RenderAsync_WithModelArgumentNull() + { + var template = new RazorTextTemplate(typeof(string)); + + await template.Invoking(t => t.RenderAsync(null, default, default, default)) + .Should().ThrowExactlyAsync() + .WithParameterName("model"); + } + + [Fact] + public async Task RenderAsync_WithOutputArgumentNull() + { + var model = new Model(); + + var template = new RazorTextTemplate(typeof(string)); + + await template.Invoking(t => t.RenderAsync(model, default, default, default)) + .Should().ThrowExactlyAsync() + .WithParameterName("output"); + } + + [Fact] + public async Task RenderAsync_WithContextArgumentNull() + { + var model = new Model(); + var output = new StringWriter(); + + var template = new RazorTextTemplate(typeof(string)); + + await 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 @@ + + + + + + + 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 @@ + + + + + + + +