From 98ca3c88669479a76aa209cd2aa1367df61101db Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Mon, 23 Feb 2026 14:20:26 +0300 Subject: [PATCH 1/4] Add MicroElements.AspNetCore.OpenApi.FluentValidation package (Issue #149) New package integrating FluentValidation with Microsoft.AspNetCore.OpenApi (IOpenApiSchemaTransformer) for .NET 9 and .NET 10, without Swashbuckle dependency. - Implement FluentValidationSchemaTransformer (IOpenApiSchemaTransformer) - Support all rule types: Required, NotEmpty, Length, Pattern, Email, Comparison, Between - Handle AllOf/OneOf/AnyOf sub-schemas for polymorphic models - .NET 10: full nested validator support via GetOrCreateSchemaAsync - .NET 9: limited nested validator support (fallback to empty schema) - Add sample project SampleAspNetCoreOpenApi - Add ADR-001 documenting architectural decision - Bump version to 7.1.0-beta.1 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 12 + ...oElements.Swashbuckle.FluentValidation.sln | 94 +++- .../adr/ADR-001-aspnetcore-openapi-support.md | 236 +++++++++ samples/SampleAspNetCoreOpenApi/Program.cs | 114 +++++ .../SampleAspNetCoreOpenApi.csproj | 18 + .../AspNetCore/AspNetJsonSerializerOptions.cs | 27 + .../AspNetCore/OpenApiOptionsExtensions.cs | 25 + ...ReflectionDependencyInjectionExtensions.cs | 129 +++++ .../AspNetCore/ServiceCollectionExtensions.cs | 68 +++ .../AspNetCoreSchemaGenerationContext.cs | 83 +++ .../AspNetCoreSchemaProvider.cs | 60 +++ .../DefaultFluentValidationRuleProvider.cs | 194 +++++++ .../FluentValidationRule.cs | 80 +++ .../FluentValidationSchemaTransformer.cs | 162 ++++++ .../Generation/SystemTextJsonNameResolver.cs | 44 ++ .../GlobalUsings.cs | 10 + ...AspNetCore.OpenApi.FluentValidation.csproj | 36 ++ .../OpenApi/OpenApiExtensions.cs | 97 ++++ .../OpenApi/OpenApiSchemaCompatibility.cs | 484 ++++++++++++++++++ .../OpenApiRuleContext.cs | 86 ++++ .../AssemblyAttributes.cs | 1 + version.props | 4 +- 22 files changed, 2061 insertions(+), 3 deletions(-) create mode 100644 docs/adr/ADR-001-aspnetcore-openapi-support.md create mode 100644 samples/SampleAspNetCoreOpenApi/Program.cs create mode 100644 samples/SampleAspNetCoreOpenApi/SampleAspNetCoreOpenApi.csproj create mode 100644 src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/AspNetJsonSerializerOptions.cs create mode 100644 src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/OpenApiOptionsExtensions.cs create mode 100644 src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ReflectionDependencyInjectionExtensions.cs create mode 100644 src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs create mode 100644 src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCoreSchemaGenerationContext.cs create mode 100644 src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCoreSchemaProvider.cs create mode 100644 src/MicroElements.AspNetCore.OpenApi.FluentValidation/DefaultFluentValidationRuleProvider.cs create mode 100644 src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationRule.cs create mode 100644 src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationSchemaTransformer.cs create mode 100644 src/MicroElements.AspNetCore.OpenApi.FluentValidation/Generation/SystemTextJsonNameResolver.cs create mode 100644 src/MicroElements.AspNetCore.OpenApi.FluentValidation/GlobalUsings.cs create mode 100644 src/MicroElements.AspNetCore.OpenApi.FluentValidation/MicroElements.AspNetCore.OpenApi.FluentValidation.csproj create mode 100644 src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApi/OpenApiExtensions.cs create mode 100644 src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs create mode 100644 src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApiRuleContext.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index cb77ce9..e512f3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# Changes in 7.1.0-beta.1 +- Added: New package `MicroElements.AspNetCore.OpenApi.FluentValidation` for Microsoft.AspNetCore.OpenApi support (Issue #149) + - Implements `IOpenApiSchemaTransformer` for .NET 9 and .NET 10 + - Supports all FluentValidation rules: Required, NotEmpty, Length, Pattern, Email, Comparison, Between + - Handles AllOf/OneOf/AnyOf sub-schemas for polymorphic models + - No dependency on Swashbuckle + - User-facing API: `services.AddFluentValidationRulesToOpenApi()` + `options.AddFluentValidationRules()` + - .NET 10: full nested validator support via `GetOrCreateSchemaAsync` + - .NET 9: limited nested validator support (fallback to empty schema) +- Added: Sample project `SampleAspNetCoreOpenApi` demonstrating Microsoft.AspNetCore.OpenApi integration +- Added: ADR-001 documenting the architectural decision for AspNetCore.OpenApi support + # Changes in 7.0.4 - Fixed: `[AsParameters]` types in minimal API and `[FromQuery]` container types create unused schemas in `components/schemas` (Issue #180) - Added: Support for keyed DI services (Issue #165) diff --git a/MicroElements.Swashbuckle.FluentValidation.sln b/MicroElements.Swashbuckle.FluentValidation.sln index aef3baf..b84676e 100644 --- a/MicroElements.Swashbuckle.FluentValidation.sln +++ b/MicroElements.Swashbuckle.FluentValidation.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36109.1 d17.14 +VisualStudioVersion = 17.14.36109.1 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MicroElements.Swashbuckle.FluentValidation", "src\MicroElements.Swashbuckle.FluentValidation\MicroElements.Swashbuckle.FluentValidation.csproj", "{433D1CD9-A091-43C1-B230-7E25954DA621}" EndProject @@ -32,40 +32,130 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{9ED7 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalApi", "samples\MinimalApi\MinimalApi.csproj", "{6F4B88CA-B550-4C5E-981A-4608EC254976}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MicroElements.AspNetCore.OpenApi.FluentValidation", "src\MicroElements.AspNetCore.OpenApi.FluentValidation\MicroElements.AspNetCore.OpenApi.FluentValidation.csproj", "{FC318D02-FA03-4D3E-92F8-A37E41947DC9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleAspNetCoreOpenApi", "samples\SampleAspNetCoreOpenApi\SampleAspNetCoreOpenApi.csproj", "{1BD071E8-AB99-4DD7-B4AD-2D7BBA1848FD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {433D1CD9-A091-43C1-B230-7E25954DA621}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {433D1CD9-A091-43C1-B230-7E25954DA621}.Debug|Any CPU.Build.0 = Debug|Any CPU + {433D1CD9-A091-43C1-B230-7E25954DA621}.Debug|x64.ActiveCfg = Debug|Any CPU + {433D1CD9-A091-43C1-B230-7E25954DA621}.Debug|x64.Build.0 = Debug|Any CPU + {433D1CD9-A091-43C1-B230-7E25954DA621}.Debug|x86.ActiveCfg = Debug|Any CPU + {433D1CD9-A091-43C1-B230-7E25954DA621}.Debug|x86.Build.0 = Debug|Any CPU {433D1CD9-A091-43C1-B230-7E25954DA621}.Release|Any CPU.ActiveCfg = Release|Any CPU {433D1CD9-A091-43C1-B230-7E25954DA621}.Release|Any CPU.Build.0 = Release|Any CPU + {433D1CD9-A091-43C1-B230-7E25954DA621}.Release|x64.ActiveCfg = Release|Any CPU + {433D1CD9-A091-43C1-B230-7E25954DA621}.Release|x64.Build.0 = Release|Any CPU + {433D1CD9-A091-43C1-B230-7E25954DA621}.Release|x86.ActiveCfg = Release|Any CPU + {433D1CD9-A091-43C1-B230-7E25954DA621}.Release|x86.Build.0 = Release|Any CPU {FEDDFEAF-E8D0-4E7D-BCDC-AEFE3517BEAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FEDDFEAF-E8D0-4E7D-BCDC-AEFE3517BEAD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FEDDFEAF-E8D0-4E7D-BCDC-AEFE3517BEAD}.Debug|x64.ActiveCfg = Debug|Any CPU + {FEDDFEAF-E8D0-4E7D-BCDC-AEFE3517BEAD}.Debug|x64.Build.0 = Debug|Any CPU + {FEDDFEAF-E8D0-4E7D-BCDC-AEFE3517BEAD}.Debug|x86.ActiveCfg = Debug|Any CPU + {FEDDFEAF-E8D0-4E7D-BCDC-AEFE3517BEAD}.Debug|x86.Build.0 = Debug|Any CPU {FEDDFEAF-E8D0-4E7D-BCDC-AEFE3517BEAD}.Release|Any CPU.ActiveCfg = Release|Any CPU {FEDDFEAF-E8D0-4E7D-BCDC-AEFE3517BEAD}.Release|Any CPU.Build.0 = Release|Any CPU + {FEDDFEAF-E8D0-4E7D-BCDC-AEFE3517BEAD}.Release|x64.ActiveCfg = Release|Any CPU + {FEDDFEAF-E8D0-4E7D-BCDC-AEFE3517BEAD}.Release|x64.Build.0 = Release|Any CPU + {FEDDFEAF-E8D0-4E7D-BCDC-AEFE3517BEAD}.Release|x86.ActiveCfg = Release|Any CPU + {FEDDFEAF-E8D0-4E7D-BCDC-AEFE3517BEAD}.Release|x86.Build.0 = Release|Any CPU {AAA88B0E-AA41-402F-B41B-AE95DBBD746C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AAA88B0E-AA41-402F-B41B-AE95DBBD746C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AAA88B0E-AA41-402F-B41B-AE95DBBD746C}.Debug|x64.ActiveCfg = Debug|Any CPU + {AAA88B0E-AA41-402F-B41B-AE95DBBD746C}.Debug|x64.Build.0 = Debug|Any CPU + {AAA88B0E-AA41-402F-B41B-AE95DBBD746C}.Debug|x86.ActiveCfg = Debug|Any CPU + {AAA88B0E-AA41-402F-B41B-AE95DBBD746C}.Debug|x86.Build.0 = Debug|Any CPU {AAA88B0E-AA41-402F-B41B-AE95DBBD746C}.Release|Any CPU.ActiveCfg = Release|Any CPU {AAA88B0E-AA41-402F-B41B-AE95DBBD746C}.Release|Any CPU.Build.0 = Release|Any CPU + {AAA88B0E-AA41-402F-B41B-AE95DBBD746C}.Release|x64.ActiveCfg = Release|Any CPU + {AAA88B0E-AA41-402F-B41B-AE95DBBD746C}.Release|x64.Build.0 = Release|Any CPU + {AAA88B0E-AA41-402F-B41B-AE95DBBD746C}.Release|x86.ActiveCfg = Release|Any CPU + {AAA88B0E-AA41-402F-B41B-AE95DBBD746C}.Release|x86.Build.0 = Release|Any CPU {7E43FAC3-6039-49B7-B9DA-69F21423BE94}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7E43FAC3-6039-49B7-B9DA-69F21423BE94}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E43FAC3-6039-49B7-B9DA-69F21423BE94}.Debug|x64.ActiveCfg = Debug|Any CPU + {7E43FAC3-6039-49B7-B9DA-69F21423BE94}.Debug|x64.Build.0 = Debug|Any CPU + {7E43FAC3-6039-49B7-B9DA-69F21423BE94}.Debug|x86.ActiveCfg = Debug|Any CPU + {7E43FAC3-6039-49B7-B9DA-69F21423BE94}.Debug|x86.Build.0 = Debug|Any CPU {7E43FAC3-6039-49B7-B9DA-69F21423BE94}.Release|Any CPU.ActiveCfg = Release|Any CPU {7E43FAC3-6039-49B7-B9DA-69F21423BE94}.Release|Any CPU.Build.0 = Release|Any CPU + {7E43FAC3-6039-49B7-B9DA-69F21423BE94}.Release|x64.ActiveCfg = Release|Any CPU + {7E43FAC3-6039-49B7-B9DA-69F21423BE94}.Release|x64.Build.0 = Release|Any CPU + {7E43FAC3-6039-49B7-B9DA-69F21423BE94}.Release|x86.ActiveCfg = Release|Any CPU + {7E43FAC3-6039-49B7-B9DA-69F21423BE94}.Release|x86.Build.0 = Release|Any CPU {A713B613-8E56-4069-BB22-C1BCFA7C6465}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A713B613-8E56-4069-BB22-C1BCFA7C6465}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A713B613-8E56-4069-BB22-C1BCFA7C6465}.Debug|x64.ActiveCfg = Debug|Any CPU + {A713B613-8E56-4069-BB22-C1BCFA7C6465}.Debug|x64.Build.0 = Debug|Any CPU + {A713B613-8E56-4069-BB22-C1BCFA7C6465}.Debug|x86.ActiveCfg = Debug|Any CPU + {A713B613-8E56-4069-BB22-C1BCFA7C6465}.Debug|x86.Build.0 = Debug|Any CPU {A713B613-8E56-4069-BB22-C1BCFA7C6465}.Release|Any CPU.ActiveCfg = Release|Any CPU {A713B613-8E56-4069-BB22-C1BCFA7C6465}.Release|Any CPU.Build.0 = Release|Any CPU + {A713B613-8E56-4069-BB22-C1BCFA7C6465}.Release|x64.ActiveCfg = Release|Any CPU + {A713B613-8E56-4069-BB22-C1BCFA7C6465}.Release|x64.Build.0 = Release|Any CPU + {A713B613-8E56-4069-BB22-C1BCFA7C6465}.Release|x86.ActiveCfg = Release|Any CPU + {A713B613-8E56-4069-BB22-C1BCFA7C6465}.Release|x86.Build.0 = Release|Any CPU {2E20C501-F63A-4CA2-9A9F-8F1BE7BC5E14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2E20C501-F63A-4CA2-9A9F-8F1BE7BC5E14}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E20C501-F63A-4CA2-9A9F-8F1BE7BC5E14}.Debug|x64.ActiveCfg = Debug|Any CPU + {2E20C501-F63A-4CA2-9A9F-8F1BE7BC5E14}.Debug|x64.Build.0 = Debug|Any CPU + {2E20C501-F63A-4CA2-9A9F-8F1BE7BC5E14}.Debug|x86.ActiveCfg = Debug|Any CPU + {2E20C501-F63A-4CA2-9A9F-8F1BE7BC5E14}.Debug|x86.Build.0 = Debug|Any CPU {2E20C501-F63A-4CA2-9A9F-8F1BE7BC5E14}.Release|Any CPU.ActiveCfg = Release|Any CPU {2E20C501-F63A-4CA2-9A9F-8F1BE7BC5E14}.Release|Any CPU.Build.0 = Release|Any CPU + {2E20C501-F63A-4CA2-9A9F-8F1BE7BC5E14}.Release|x64.ActiveCfg = Release|Any CPU + {2E20C501-F63A-4CA2-9A9F-8F1BE7BC5E14}.Release|x64.Build.0 = Release|Any CPU + {2E20C501-F63A-4CA2-9A9F-8F1BE7BC5E14}.Release|x86.ActiveCfg = Release|Any CPU + {2E20C501-F63A-4CA2-9A9F-8F1BE7BC5E14}.Release|x86.Build.0 = Release|Any CPU {6F4B88CA-B550-4C5E-981A-4608EC254976}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6F4B88CA-B550-4C5E-981A-4608EC254976}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F4B88CA-B550-4C5E-981A-4608EC254976}.Debug|x64.ActiveCfg = Debug|Any CPU + {6F4B88CA-B550-4C5E-981A-4608EC254976}.Debug|x64.Build.0 = Debug|Any CPU + {6F4B88CA-B550-4C5E-981A-4608EC254976}.Debug|x86.ActiveCfg = Debug|Any CPU + {6F4B88CA-B550-4C5E-981A-4608EC254976}.Debug|x86.Build.0 = Debug|Any CPU {6F4B88CA-B550-4C5E-981A-4608EC254976}.Release|Any CPU.ActiveCfg = Release|Any CPU {6F4B88CA-B550-4C5E-981A-4608EC254976}.Release|Any CPU.Build.0 = Release|Any CPU + {6F4B88CA-B550-4C5E-981A-4608EC254976}.Release|x64.ActiveCfg = Release|Any CPU + {6F4B88CA-B550-4C5E-981A-4608EC254976}.Release|x64.Build.0 = Release|Any CPU + {6F4B88CA-B550-4C5E-981A-4608EC254976}.Release|x86.ActiveCfg = Release|Any CPU + {6F4B88CA-B550-4C5E-981A-4608EC254976}.Release|x86.Build.0 = Release|Any CPU + {FC318D02-FA03-4D3E-92F8-A37E41947DC9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC318D02-FA03-4D3E-92F8-A37E41947DC9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC318D02-FA03-4D3E-92F8-A37E41947DC9}.Debug|x64.ActiveCfg = Debug|Any CPU + {FC318D02-FA03-4D3E-92F8-A37E41947DC9}.Debug|x64.Build.0 = Debug|Any CPU + {FC318D02-FA03-4D3E-92F8-A37E41947DC9}.Debug|x86.ActiveCfg = Debug|Any CPU + {FC318D02-FA03-4D3E-92F8-A37E41947DC9}.Debug|x86.Build.0 = Debug|Any CPU + {FC318D02-FA03-4D3E-92F8-A37E41947DC9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC318D02-FA03-4D3E-92F8-A37E41947DC9}.Release|Any CPU.Build.0 = Release|Any CPU + {FC318D02-FA03-4D3E-92F8-A37E41947DC9}.Release|x64.ActiveCfg = Release|Any CPU + {FC318D02-FA03-4D3E-92F8-A37E41947DC9}.Release|x64.Build.0 = Release|Any CPU + {FC318D02-FA03-4D3E-92F8-A37E41947DC9}.Release|x86.ActiveCfg = Release|Any CPU + {FC318D02-FA03-4D3E-92F8-A37E41947DC9}.Release|x86.Build.0 = Release|Any CPU + {1BD071E8-AB99-4DD7-B4AD-2D7BBA1848FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1BD071E8-AB99-4DD7-B4AD-2D7BBA1848FD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1BD071E8-AB99-4DD7-B4AD-2D7BBA1848FD}.Debug|x64.ActiveCfg = Debug|Any CPU + {1BD071E8-AB99-4DD7-B4AD-2D7BBA1848FD}.Debug|x64.Build.0 = Debug|Any CPU + {1BD071E8-AB99-4DD7-B4AD-2D7BBA1848FD}.Debug|x86.ActiveCfg = Debug|Any CPU + {1BD071E8-AB99-4DD7-B4AD-2D7BBA1848FD}.Debug|x86.Build.0 = Debug|Any CPU + {1BD071E8-AB99-4DD7-B4AD-2D7BBA1848FD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1BD071E8-AB99-4DD7-B4AD-2D7BBA1848FD}.Release|Any CPU.Build.0 = Release|Any CPU + {1BD071E8-AB99-4DD7-B4AD-2D7BBA1848FD}.Release|x64.ActiveCfg = Release|Any CPU + {1BD071E8-AB99-4DD7-B4AD-2D7BBA1848FD}.Release|x64.Build.0 = Release|Any CPU + {1BD071E8-AB99-4DD7-B4AD-2D7BBA1848FD}.Release|x86.ActiveCfg = Release|Any CPU + {1BD071E8-AB99-4DD7-B4AD-2D7BBA1848FD}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -74,6 +164,8 @@ Global {FEDDFEAF-E8D0-4E7D-BCDC-AEFE3517BEAD} = {9ED7D819-FC90-4504-A46D-D38E3BE107B7} {2E20C501-F63A-4CA2-9A9F-8F1BE7BC5E14} = {9ED7D819-FC90-4504-A46D-D38E3BE107B7} {6F4B88CA-B550-4C5E-981A-4608EC254976} = {9ED7D819-FC90-4504-A46D-D38E3BE107B7} + {FC318D02-FA03-4D3E-92F8-A37E41947DC9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {1BD071E8-AB99-4DD7-B4AD-2D7BBA1848FD} = {9ED7D819-FC90-4504-A46D-D38E3BE107B7} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {1AA0A677-C642-44C8-A6CE-495E7B7074B8} diff --git a/docs/adr/ADR-001-aspnetcore-openapi-support.md b/docs/adr/ADR-001-aspnetcore-openapi-support.md new file mode 100644 index 0000000..949ba3c --- /dev/null +++ b/docs/adr/ADR-001-aspnetcore-openapi-support.md @@ -0,0 +1,236 @@ +# ADR-001: Поддержка Microsoft.AspNetCore.OpenApi (IOpenApiSchemaTransformer) + +**Статус:** Принято +**Дата:** 2026-02-23 +**Issue:** [#149](https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation/issues/149) +**Milestone:** v7.1.0 + +--- + +## 1. Контекст и проблема + +С .NET 9 Microsoft предоставляет встроенную поддержку OpenAPI (`Microsoft.AspNetCore.OpenApi`): +- `builder.Services.AddOpenApi()` + `app.MapOpenApi()` +- Трансформеры: `IOpenApiSchemaTransformer`, `IOpenApiDocumentTransformer`, `IOpenApiOperationTransformer` + +Пользователи мигрируют с Swashbuckle на встроенное решение. Наша библиотека должна поддерживать оба варианта. + +**Ни .NET 9, ни .NET 10, ни будущие версии .NET НЕ включают маппинг FluentValidation на OpenAPI из коробки.** Microsoft предоставляет только инфраструктуру трансформеров, но не интеграцию с FluentValidation. Наша библиотека необходима для обоих версий. + +### Референсная реализация (saithis) + +Пользователь [saithis](https://github.com/saithis/dotnet-playground/tree/main/OpenApiFluentValidationApi) создал proof-of-concept: +- ~200 строк, standalone `FluentValidationSchemaTransformer : IOpenApiSchemaTransformer` +- Поддерживает: NotNull, NotEmpty, Length, MinLength, MaxLength, Between, Comparison, Regex, Email, CreditCard +- НЕ поддерживает: вложенные валидаторы (SetValidator), Include(), RuleForEach(), When/Unless, AllOf, кэширование, кастомизацию правил + +### Различия .NET 9 vs .NET 10 + +| Аспект | .NET 9 | .NET 10 | +|--------|--------|---------| +| `IOpenApiSchemaTransformer` | Есть | Есть | +| `GetOrCreateSchemaAsync()` | Нет | Есть | +| `context.Document` | Нет | Есть | +| Microsoft.OpenApi версия | v1.x | v2.x (ломающий API) | +| `OPENAPI_V2` нужен | Нет | Да | + +Наша библиотека нужна для обоих версий. Различия только в API модели `OpenApiSchema`. + +--- + +## 2. Рассмотренные варианты + +### Вариант A: Новый отдельный пакет (ВЫБРАН) + +``` +MicroElements.OpenApi.FluentValidation (ядро, generic абстракции) + ^ ^ + | | +Swashbuckle пакет НОВЫЙ: AspNetCore.OpenApi пакет +(ISchemaFilter) (IOpenApiSchemaTransformer) +``` + +- `MicroElements.AspNetCore.OpenApi.FluentValidation` +- Targets: `net9.0;net10.0` +- Зависимости: ядро + `Microsoft.AspNetCore.OpenApi` (БЕЗ Swashbuckle) +- Дублирует ~630 строк OpenApiSchema-специфичного кода +- Планируется извлечение в Фазе 2 (v7.2) + +### Вариант B: Извлечение общего OpenApi-слоя (отложен на v7.2) + +``` +MicroElements.OpenApi.FluentValidation (ядро, generic) + ^ +MicroElements.OpenApi.FluentValidation.Rules (НОВЫЙ: общие OpenApiSchema правила) + ^ ^ +Swashbuckle пакет НОВЫЙ: AspNetCore.OpenApi пакет +``` + +- Извлекает общий код в shared пакет +- `[TypeForwardedTo]` для совместимости +- Ноль дублирования, но сложнее и риск breaking changes + +### Вариант C: Минимальная интеграция (отклонён) + +- Только net9.0, без OPENAPI_V2 +- Максимум дублирования, нет net10.0 + +--- + +## 3. Решение: Вариант A (Поэтапный) + +**Фаза 1 (v7.1.0):** Новый пакет с контролируемым дублированием +**Фаза 2 (v7.2):** Извлечение общего слоя, очистка неймспейсов + +### Обоснование +- Быстрый выпуск без breaking changes для существующих пользователей +- Дублирование управляемо (~630 строк, определённый набор файлов) +- Следует прецеденту NSwag пакета +- Извлечение общего слоя запланировано на v7.2 + +--- + +## 4. Архитектура нового пакета + +### 4.1 Граф зависимостей + +``` +MicroElements.AspNetCore.OpenApi.FluentValidation + -> MicroElements.OpenApi.FluentValidation (ядро) + -> FluentValidation >= 12.0.0 + -> Microsoft.Extensions.Logging.Abstractions + -> Microsoft.Extensions.Options + -> Microsoft.AspNetCore.OpenApi (>= 9.0.0 для net9.0, >= 10.0.0 для net10.0) + [НЕТ зависимости от Swashbuckle] +``` + +### 4.2 Структура файлов + +``` +src/MicroElements.AspNetCore.OpenApi.FluentValidation/ +│ +├── MicroElements.AspNetCore.OpenApi.FluentValidation.csproj +├── GlobalUsings.cs +│ +├── FluentValidationSchemaTransformer.cs # НОВЫЙ: IOpenApiSchemaTransformer +├── AspNetCoreSchemaGenerationContext.cs # НОВЫЙ: ISchemaGenerationContext +├── AspNetCoreSchemaProvider.cs # НОВЫЙ: ISchemaProvider +│ +├── FluentValidationRule.cs # КОПИЯ из Swashbuckle +├── DefaultFluentValidationRuleProvider.cs # КОПИЯ из Swashbuckle +├── OpenApiRuleContext.cs # КОПИЯ из Swashbuckle +│ +├── OpenApi/ +│ ├── OpenApiSchemaCompatibility.cs # КОПИЯ из Swashbuckle +│ └── OpenApiExtensions.cs # КОПИЯ из Swashbuckle +│ +├── Generation/ +│ └── SystemTextJsonNameResolver.cs # КОПИЯ из Swashbuckle +│ +└── AspNetCore/ + ├── AspNetJsonSerializerOptions.cs # КОПИЯ из Swashbuckle + ├── ReflectionDependencyInjectionExtensions.cs # КОПИЯ из Swashbuckle + ├── ServiceCollectionExtensions.cs # НОВЫЙ: DI регистрация + └── OpenApiOptionsExtensions.cs # НОВЫЙ: AddFluentValidationRules() +``` + +### 4.3 Классификация файлов + +| Файл | Тип | Источник | +|------|-----|----------| +| `.csproj` | Новый | - | +| `GlobalUsings.cs` | Копия | Swashbuckle GlobalUsings.cs | +| `FluentValidationSchemaTransformer.cs` | **Новый** | По паттерну FluentValidationRules.cs | +| `AspNetCoreSchemaGenerationContext.cs` | **Новый** | По паттерну SchemaGenerationContext.cs | +| `AspNetCoreSchemaProvider.cs` | **Новый** | net9: stub, net10: GetOrCreateSchemaAsync | +| `FluentValidationRule.cs` | Копия | Swashbuckle FluentValidationRule.cs | +| `DefaultFluentValidationRuleProvider.cs` | Копия | Swashbuckle DefaultFluentValidationRuleProvider.cs | +| `OpenApiRuleContext.cs` | Копия | Swashbuckle OpenApiRuleContext.cs | +| `OpenApiSchemaCompatibility.cs` | Копия | Swashbuckle OpenApiSchemaCompatibility.cs | +| `OpenApiExtensions.cs` | Копия | Swashbuckle OpenApiExtensions.cs | +| `SystemTextJsonNameResolver.cs` | Копия | Swashbuckle SystemTextJsonNameResolver.cs | +| `AspNetJsonSerializerOptions.cs` | Копия | Swashbuckle AspNetJsonSerializerOptions.cs | +| `ReflectionDependencyInjectionExtensions.cs` | Копия | Swashbuckle ReflectionDependencyInjectionExtensions.cs | +| `ServiceCollectionExtensions.cs` | **Новый** | По паттерну Swashbuckle ServiceCollectionExtensions.cs | +| `OpenApiOptionsExtensions.cs` | **Новый** | - | + +--- + +## 5. User-Facing API + +### Регистрация в Program.cs + +```csharp +var builder = WebApplication.CreateBuilder(args); + +// Register FluentValidation validators +builder.Services.AddValidatorsFromAssemblyContaining(); + +// Register FluentValidation OpenAPI support +builder.Services.AddFluentValidationRulesToOpenApi(); + +// Add OpenApi with the FluentValidation transformer +builder.Services.AddOpenApi(options => +{ + options.AddFluentValidationRules(); +}); + +var app = builder.Build(); +app.MapOpenApi(); +app.Run(); +``` + +### Миграция со Swashbuckle + +```diff +// NuGet +- MicroElements.Swashbuckle.FluentValidation ++ MicroElements.AspNetCore.OpenApi.FluentValidation + +// Program.cs +- services.AddSwaggerGen(); +- services.AddFluentValidationRulesToSwagger(); ++ services.AddFluentValidationRulesToOpenApi(); ++ services.AddOpenApi(options => options.AddFluentValidationRules()); + +// Namespace +- using MicroElements.Swashbuckle.FluentValidation.AspNetCore; ++ using MicroElements.AspNetCore.OpenApi.FluentValidation; +``` + +--- + +## 6. Известные ограничения + +1. **Вложенные валидаторы на .NET 9**: `SetValidator()` sub-schema resolution ограничен (нет `GetOrCreateSchemaAsync`). Полная поддержка на .NET 10. +2. **Гранулярность трансформера**: `IOpenApiSchemaTransformer` вызывается per-schema (включая property schemas). Нужно фильтровать по `context.JsonPropertyInfo == null`. +3. **Дублирование кода**: ~630 строк дублированы из Swashbuckle пакета. Баг-фиксы нужно применять в обоих местах до v7.2 (Фаза 2). + +--- + +## 7. Верификация + +### 7.1 Сборка +```bash +dotnet build MicroElements.Swashbuckle.FluentValidation.sln +``` +- Все проекты компилируются без ошибок + +### 7.2 Тесты +```bash +dotnet test MicroElements.Swashbuckle.FluentValidation.sln +``` +- Существующие тесты проходят (нет регрессий) +- Новые тесты для всех типов правил проходят + +### 7.3 Sample приложение +```bash +cd samples/SampleAspNetCoreOpenApi +dotnet run +# Открыть /openapi/v1.json +``` +- OpenAPI документ содержит validation constraints + +### 7.4 Зависимости +- НЕТ транзитивной зависимости от Swashbuckle +- Есть зависимость на MicroElements.OpenApi.FluentValidation и Microsoft.AspNetCore.OpenApi diff --git a/samples/SampleAspNetCoreOpenApi/Program.cs b/samples/SampleAspNetCoreOpenApi/Program.cs new file mode 100644 index 0000000..0f84806 --- /dev/null +++ b/samples/SampleAspNetCoreOpenApi/Program.cs @@ -0,0 +1,114 @@ +using FluentValidation; +using MicroElements.AspNetCore.OpenApi.FluentValidation; + +var builder = WebApplication.CreateBuilder(args); +var services = builder.Services; + +// Add FV validators +services.AddValidatorsFromAssemblyContaining(); + +// Add FV Rules to OpenApi +services.AddFluentValidationRulesToOpenApi(); + +// Add OpenApi with FluentValidation transformer +services.AddOpenApi(options => +{ + options.AddFluentValidationRules(); +}); + +var app = builder.Build(); + +// Map OpenApi endpoint +app.MapOpenApi(); + +// Map sample endpoints +app.MapPost("/api/customers", (Customer customer) => Results.Ok(customer)); +app.MapPost("/api/orders", (Order order) => Results.Ok(order)); +app.MapGet("/api/search", ([AsParameters] SearchQuery query) => Results.Ok(query)); + +app.Run(); + +// ---- Models ---- + +/// +/// Sample customer model. +/// +public class Customer +{ + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public int Age { get; set; } + public string? Phone { get; set; } + public Address? Address { get; set; } +} + +/// +/// Sample address model. +/// +public class Address +{ + public string Street { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string ZipCode { get; set; } = string.Empty; +} + +/// +/// Sample order model. +/// +public class Order +{ + public string OrderNumber { get; set; } = string.Empty; + public decimal Amount { get; set; } + public int Quantity { get; set; } + public List Items { get; set; } = new(); +} + +/// +/// Sample search query. +/// +public record SearchQuery(string? Query, int Page); + +// ---- Validators ---- + +public class CustomerValidator : AbstractValidator +{ + public CustomerValidator() + { + RuleFor(x => x.FirstName).NotEmpty().MaximumLength(50); + RuleFor(x => x.LastName).NotEmpty().MaximumLength(50); + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + RuleFor(x => x.Age).GreaterThanOrEqualTo(0).LessThanOrEqualTo(150); + RuleFor(x => x.Phone).Matches(@"^\+?[\d\s\-]+$").When(x => x.Phone != null); + } +} + +public class AddressValidator : AbstractValidator
+{ + public AddressValidator() + { + RuleFor(x => x.Street).NotEmpty().MaximumLength(200); + RuleFor(x => x.City).NotEmpty().MaximumLength(100); + RuleFor(x => x.ZipCode).NotEmpty().Length(5, 10); + } +} + +public class OrderValidator : AbstractValidator +{ + public OrderValidator() + { + RuleFor(x => x.OrderNumber).NotEmpty().MaximumLength(20); + RuleFor(x => x.Amount).GreaterThan(0).LessThanOrEqualTo(1_000_000); + RuleFor(x => x.Quantity).InclusiveBetween(1, 1000); + RuleFor(x => x.Items).NotEmpty(); + } +} + +public class SearchQueryValidator : AbstractValidator +{ + public SearchQueryValidator() + { + RuleFor(x => x.Query).NotEmpty().MaximumLength(200); + RuleFor(x => x.Page).GreaterThan(0); + } +} diff --git a/samples/SampleAspNetCoreOpenApi/SampleAspNetCoreOpenApi.csproj b/samples/SampleAspNetCoreOpenApi/SampleAspNetCoreOpenApi.csproj new file mode 100644 index 0000000..443d1da --- /dev/null +++ b/samples/SampleAspNetCoreOpenApi/SampleAspNetCoreOpenApi.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + latest + enable + + + + + + + + + + + diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/AspNetJsonSerializerOptions.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/AspNetJsonSerializerOptions.cs new file mode 100644 index 0000000..74a3836 --- /dev/null +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/AspNetJsonSerializerOptions.cs @@ -0,0 +1,27 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; + +namespace MicroElements.AspNetCore.OpenApi.FluentValidation.AspNetCore +{ + /// + /// AspNetCore Mvc wrapper that can be used in netstandard. + /// + public class AspNetJsonSerializerOptions + { + /// + /// Initializes a new instance of the class. + /// + /// from AspNet host. + public AspNetJsonSerializerOptions(JsonSerializerOptions value) + { + Value = value; + } + + /// + /// Gets the from AspNet host. + /// + public JsonSerializerOptions Value { get; } + } +} diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/OpenApiOptionsExtensions.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/OpenApiOptionsExtensions.cs new file mode 100644 index 0000000..bba9b70 --- /dev/null +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/OpenApiOptionsExtensions.cs @@ -0,0 +1,25 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.OpenApi; + +namespace MicroElements.AspNetCore.OpenApi.FluentValidation +{ + /// + /// Extensions for . + /// + public static class OpenApiOptionsExtensions + { + /// + /// Adds FluentValidation schema transformer to the OpenAPI options. + /// Call this after AddFluentValidationRulesToOpenApi() on the service collection. + /// + /// OpenApi options. + /// The same options instance for chaining. + public static OpenApiOptions AddFluentValidationRules(this OpenApiOptions options) + { + options.AddSchemaTransformer(); + return options; + } + } +} diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ReflectionDependencyInjectionExtensions.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ReflectionDependencyInjectionExtensions.cs new file mode 100644 index 0000000..3a64b9f --- /dev/null +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ReflectionDependencyInjectionExtensions.cs @@ -0,0 +1,129 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace MicroElements.AspNetCore.OpenApi.FluentValidation.AspNetCore +{ + /// + /// DependencyInjection through Reflection. + /// + internal static class ReflectionDependencyInjectionExtensions + { + private static Type? GetByFullName(string typeName) + { + Type? type = AppDomain + .CurrentDomain + .GetAssemblies() + .Where(assembly => assembly.FullName.Contains("Microsoft")) + .SelectMany(GetLoadableTypes) + .FirstOrDefault(type => type.FullName == typeName); + + return type; + } + + /// + /// Gets loadable Types from an Assembly, not throwing when some Types can't be loaded. + /// + private static IEnumerable GetLoadableTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException e) + { + return e.Types.Where(t => t != null); + } + } + + /// + /// Calls through reflection: services.Configure<JsonOptions>(options => configureJson(options));. + /// Can be used from netstandard. + /// + /// Services. + /// Action to configure in JsonOptions. + public static void ConfigureJsonOptionsForAspNetCore(this IServiceCollection services, Action configureJson) + { + Action configureJsonOptionsUntyped = options => + { + PropertyInfo? propertyInfo = options.GetType().GetProperty("JsonSerializerOptions"); + + if (propertyInfo?.GetValue(options) is JsonSerializerOptions jsonSerializerOptions) + { + configureJson(jsonSerializerOptions); + } + }; + + Type? jsonOptionsType = GetByFullName("Microsoft.AspNetCore.Mvc.JsonOptions"); + if (jsonOptionsType != null) + { + Type? extensionsType = GetByFullName("Microsoft.Extensions.DependencyInjection.OptionsServiceCollectionExtensions"); + + MethodInfo? configureMethodGeneric = extensionsType + ?.GetTypeInfo() + .DeclaredMethods + .FirstOrDefault(info => info.Name == "Configure" && info.GetParameters().Length == 2); + + MethodInfo? configureMethod = configureMethodGeneric?.MakeGenericMethod(jsonOptionsType); + + if (configureMethod != null) + { + // services.Configure(options => configureJson(options)); + configureMethod.Invoke(services, new object?[] { services, configureJsonOptionsUntyped }); + } + } + } + + /// + /// Gets from JsonOptions registered in AspNetCore. + /// Uses reflection to call code: + /// serviceProvider.GetService<IOptions<JsonOptions>>()?.Value?.JsonSerializerOptions; + /// + /// Source service provider. + /// Optional . + public static JsonSerializerOptions? GetJsonSerializerOptions(this IServiceProvider serviceProvider) + { + JsonSerializerOptions? jsonSerializerOptions = null; + + Type? jsonOptionsType = GetByFullName("Microsoft.AspNetCore.Mvc.JsonOptions"); + if (jsonOptionsType != null) + { + // IOptions + Type jsonOptionsInterfaceType = typeof(IOptions<>).MakeGenericType(jsonOptionsType); + object? jsonOptionsOption = serviceProvider.GetService(jsonOptionsInterfaceType); + + if (jsonOptionsOption != null) + { + PropertyInfo? valueProperty = jsonOptionsInterfaceType.GetProperty("Value", BindingFlags.Instance | BindingFlags.Public); + PropertyInfo? jsonSerializerOptionsProperty = jsonOptionsType.GetProperty("JsonSerializerOptions", BindingFlags.Instance | BindingFlags.Public); + + if (valueProperty != null && jsonSerializerOptionsProperty != null) + { + // JsonOptions + var jsonOptions = valueProperty.GetValue(jsonOptionsOption); + + // JsonSerializerOptions + if (jsonOptions != null) + { + jsonSerializerOptions = jsonSerializerOptionsProperty.GetValue(jsonOptions) as JsonSerializerOptions; + } + } + } + } + + return jsonSerializerOptions; + } + + public static JsonSerializerOptions GetJsonSerializerOptionsOrDefault(this IServiceProvider serviceProvider) + { + return serviceProvider.GetJsonSerializerOptions() ?? new JsonSerializerOptions(); + } + } +} diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..76fb263 --- /dev/null +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs @@ -0,0 +1,68 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using MicroElements.AspNetCore.OpenApi.FluentValidation.AspNetCore; +using MicroElements.AspNetCore.OpenApi.FluentValidation.Generation; +using MicroElements.OpenApi.FluentValidation; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +#if !OPENAPI_V2 +using Microsoft.OpenApi.Models; +#endif + +namespace MicroElements.AspNetCore.OpenApi.FluentValidation +{ + /// + /// ServiceCollection extensions for Microsoft.AspNetCore.OpenApi integration. + /// + [SuppressMessage("ReSharper", "RedundantTypeArgumentsOfMethod", Justification = "More obvious")] + public static class ServiceCollectionExtensions + { + /// + /// Adds FluentValidation rules to OpenAPI documents generated by Microsoft.AspNetCore.OpenApi. + /// + /// Services. + /// Optional configure action for . + /// The same service collection. + public static IServiceCollection AddFluentValidationRulesToOpenApi( + this IServiceCollection services, + Action? configure = null) + { + // Register the schema transformer for DI activation + services.TryAddScoped(); + + // Register JsonSerializerOptions (reference to Microsoft.AspNetCore.Mvc.JsonOptions.Value) + services.TryAddTransient(provider => new AspNetJsonSerializerOptions(provider.GetJsonSerializerOptionsOrDefault())); + services.TryAddTransient(provider => provider.GetService()?.Value!); + + // Adds name resolver. For example when property name in schema differs from property name in dotnet class. + services.TryAddSingleton(); + + // Adds default IValidatorRegistry + services.TryAddScoped(); + + // Issue #165: Register IServiceCollection for keyed validator discovery at resolution time + services.TryAddSingleton(services); + + // Adds IFluentValidationRuleProvider + services.TryAddSingleton, DefaultFluentValidationRuleProvider>(); + + // DI injected services + services.TryAddTransient(); + + // Schema generation configuration + if (configure != null) + services.Configure(configure); + + // PostConfigure SchemaGenerationOptions + services.TryAddEnumerable( + ServiceDescriptor.Transient, MicroElements.OpenApi.AspNetCore.FillDefaultValuesPostConfigureOptions>()); + + return services; + } + } +} diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCoreSchemaGenerationContext.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCoreSchemaGenerationContext.cs new file mode 100644 index 0000000..329f4ab --- /dev/null +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCoreSchemaGenerationContext.cs @@ -0,0 +1,83 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using FluentValidation.Validators; +using MicroElements.OpenApi.FluentValidation; +using Microsoft.AspNetCore.OpenApi; +#if !OPENAPI_V2 +using Microsoft.OpenApi.Models; +#endif + +namespace MicroElements.AspNetCore.OpenApi.FluentValidation +{ + /// + /// Schema generation context for Microsoft.AspNetCore.OpenApi. + /// + public record AspNetCoreSchemaGenerationContext : ISchemaGenerationContext + { + /// + public Type SchemaType { get; } + + /// + IReadOnlyList ISchemaGenerationContext.Rules => Rules; + + /// + public IReadOnlyList> Rules { get; } + + /// + public ISchemaGenerationOptions SchemaGenerationOptions { get; } + + /// + public OpenApiSchema Schema { get; } + + /// + public ISchemaProvider SchemaProvider { get; } + + /// + public IEnumerable Properties => Schema.Properties?.Keys ?? Array.Empty(); + + /// + public ISchemaGenerationContext With(OpenApiSchema schema) + { + return new AspNetCoreSchemaGenerationContext( + schema: schema, + schemaType: SchemaType, + rules: Rules, + schemaGenerationOptions: SchemaGenerationOptions, + schemaProvider: SchemaProvider); + } + + /// + public IRuleContext Create( + string schemaPropertyName, + ValidationRuleContext validationRuleContext, + IPropertyValidator propertyValidator) + { + return new OpenApiRuleContext(Schema, schemaPropertyName, validationRuleContext, propertyValidator); + } + + /// + /// Initializes a new instance of the class. + /// + /// OpenApi schema. + /// Schema .NET type. + /// Validation rules. + /// Schema generation options. + /// Schema provider. + public AspNetCoreSchemaGenerationContext( + OpenApiSchema schema, + Type schemaType, + IReadOnlyList> rules, + ISchemaGenerationOptions schemaGenerationOptions, + ISchemaProvider? schemaProvider = null) + { + Schema = schema; + SchemaType = schemaType; + Rules = rules; + SchemaGenerationOptions = schemaGenerationOptions; + SchemaProvider = schemaProvider ?? new AspNetCoreSchemaProvider(); + } + } +} diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCoreSchemaProvider.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCoreSchemaProvider.cs new file mode 100644 index 0000000..e594b93 --- /dev/null +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCoreSchemaProvider.cs @@ -0,0 +1,60 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Threading; +using MicroElements.OpenApi.FluentValidation; +using Microsoft.AspNetCore.OpenApi; +#if !OPENAPI_V2 +using Microsoft.OpenApi.Models; +#endif + +namespace MicroElements.AspNetCore.OpenApi.FluentValidation +{ + /// + /// Schema provider for Microsoft.AspNetCore.OpenApi. + /// .NET 9: returns empty schema (limited nested validator support). + /// .NET 10+: uses GetOrCreateSchemaAsync for full sub-schema resolution. + /// + internal class AspNetCoreSchemaProvider : ISchemaProvider + { + private readonly OpenApiSchemaTransformerContext? _context; + + /// + /// Initializes a new instance of the class. + /// + /// Optional transformer context (used for GetOrCreateSchemaAsync on .NET 10+). + public AspNetCoreSchemaProvider(OpenApiSchemaTransformerContext? context = null) + { + _context = context; + } + + /// + public OpenApiSchema GetSchemaForType(Type type) + { +#if NET10_0_OR_GREATER + if (_context != null) + { + // NOTE: Sync-over-async via GetAwaiter().GetResult(). + // This is safe in ASP.NET Core because it does not use a SynchronizationContext + // (removed since ASP.NET Core 1.0), so there is no deadlock risk. + // The ISchemaProvider interface is synchronous by contract (defined in core package). + // A future async variant of ISchemaProvider may be introduced to avoid this pattern. + try + { + return _context.GetOrCreateSchemaAsync(type, null, CancellationToken.None) + .GetAwaiter().GetResult(); + } + catch (Exception) + { + // Fallback to empty schema if sub-schema resolution fails. + return new OpenApiSchema(); + } + } +#endif + // .NET 9 fallback: return empty schema. + // Nested validator support is limited in .NET 9. + return new OpenApiSchema(); + } + } +} diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/DefaultFluentValidationRuleProvider.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/DefaultFluentValidationRuleProvider.cs new file mode 100644 index 0000000..8690a3f --- /dev/null +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/DefaultFluentValidationRuleProvider.cs @@ -0,0 +1,194 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Validators; +using MicroElements.OpenApi; +using MicroElements.OpenApi.Core; +using MicroElements.OpenApi.FluentValidation; +using Microsoft.Extensions.Options; +#if !OPENAPI_V2 +using Microsoft.OpenApi.Models; +#endif + +namespace MicroElements.AspNetCore.OpenApi.FluentValidation +{ + /// + /// Default rule provider. + /// + public class DefaultFluentValidationRuleProvider : IFluentValidationRuleProvider + { + private readonly IOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// Schema generation options. + public DefaultFluentValidationRuleProvider(IOptions? options = null) + { + _options = options ?? new OptionsWrapper(new SchemaGenerationOptions()); + } + + /// + public IEnumerable> GetRules() + { + yield return new FluentValidationRule("Required") + .WithCondition(validator => validator is INotNullValidator || validator is INotEmptyValidator) + .WithApply(context => + { + OpenApiSchemaCompatibility.AddRequired(context.Schema, context.PropertyKey); + OpenApiSchemaCompatibility.SetNotNullable(context.Property); + }); + + yield return new FluentValidationRule("NotEmpty") + .WithCondition(validator => validator is INotEmptyValidator) + .WithApply(context => + { + if (OpenApiSchemaCompatibility.IsStringType(context.Property)) + context.Property.SetNewMin(p => p.MinLength, 1, _options.Value.SetNotNullableIfMinLengthGreaterThenZero); + + if (OpenApiSchemaCompatibility.IsArrayType(context.Property)) + context.Property.SetNewMin(p => p.MinItems, 1, _options.Value.SetNotNullableIfMinLengthGreaterThenZero); + }); + + yield return new FluentValidationRule("Length") + .WithCondition(validator => validator is ILengthValidator) + .WithApply(context => + { + var lengthValidator = (ILengthValidator) context.PropertyValidator; + var schemaProperty = context.Property; + + if (OpenApiSchemaCompatibility.IsArrayType(schemaProperty)) + { + if (lengthValidator.Max > 0) + schemaProperty.SetNewMax(p => p.MaxItems, lengthValidator.Max); + + if (lengthValidator.Min > 0) + schemaProperty.SetNewMin(p => p.MinItems, lengthValidator.Min, _options.Value.SetNotNullableIfMinLengthGreaterThenZero); + } + else + { + if (lengthValidator.Max > 0) + schemaProperty.SetNewMax(p => p.MaxLength, lengthValidator.Max); + + if (lengthValidator.Min > 0) + schemaProperty.SetNewMin(p => p.MinLength, lengthValidator.Min, _options.Value.SetNotNullableIfMinLengthGreaterThenZero); + } + }); + + yield return new FluentValidationRule("Pattern") + .WithCondition(validator => validator is IRegularExpressionValidator) + .WithApply(context => + { + var regularExpressionValidator = (IRegularExpressionValidator) context.PropertyValidator; + var schemaProperty = context.Property; + + if (_options.Value.UseAllOfForMultipleRules) + { + if (schemaProperty.Pattern != null || + OpenApiSchemaCompatibility.AllOfCountWhere(schemaProperty, schema => schema.Pattern != null) > 0) + { + if (OpenApiSchemaCompatibility.AllOfCountWhere(schemaProperty, schema => schema.Pattern != null) == 0) + { + // Add first pattern as AllOf + OpenApiSchemaCompatibility.AddAllOf(schemaProperty, new OpenApiSchema() + { + Pattern = schemaProperty.Pattern, + }); + } + + // Add another pattern as AllOf + OpenApiSchemaCompatibility.AddAllOf(schemaProperty, new OpenApiSchema() + { + Pattern = regularExpressionValidator.Expression, + }); + + schemaProperty.Pattern = null; + } + else + { + // First and only pattern + schemaProperty.Pattern = regularExpressionValidator.Expression; + } + } + else + { + // Set new pattern + schemaProperty.Pattern = regularExpressionValidator.Expression; + } + }); + + yield return new FluentValidationRule("EMail") + .WithCondition(propertyValidator => propertyValidator.GetType().Name.Contains("EmailValidator")) + .WithApply(context => context.Property.Format = "email"); + + yield return new FluentValidationRule("Comparison") + .WithCondition(validator => validator is IComparisonValidator) + .WithApply(context => + { + var comparisonValidator = (IComparisonValidator)context.PropertyValidator; + if (comparisonValidator.ValueToCompare.IsNumeric()) + { + var valueToCompare = comparisonValidator.ValueToCompare.NumericToDecimal(); + var schemaProperty = context.Property; + + if (comparisonValidator.Comparison == Comparison.GreaterThanOrEqual) + { + OpenApiSchemaCompatibility.SetNewMinimum(schemaProperty, valueToCompare); + if (_options.Value.SetNotNullableIfMinLengthGreaterThenZero) + OpenApiSchemaCompatibility.SetNotNullable(schemaProperty); + } + else if (comparisonValidator.Comparison == Comparison.GreaterThan) + { + OpenApiSchemaCompatibility.SetNewMinimum(schemaProperty, valueToCompare); + OpenApiSchemaCompatibility.SetExclusiveMinimum(schemaProperty, true); + if (_options.Value.SetNotNullableIfMinLengthGreaterThenZero) + OpenApiSchemaCompatibility.SetNotNullable(schemaProperty); + } + else if (comparisonValidator.Comparison == Comparison.LessThanOrEqual) + { + OpenApiSchemaCompatibility.SetNewMaximum(schemaProperty, valueToCompare); + } + else if (comparisonValidator.Comparison == Comparison.LessThan) + { + OpenApiSchemaCompatibility.SetNewMaximum(schemaProperty, valueToCompare); + OpenApiSchemaCompatibility.SetExclusiveMaximum(schemaProperty, true); + } + } + }); + + yield return new FluentValidationRule("Between") + .WithCondition(validator => validator is IBetweenValidator) + .WithApply(context => + { + var betweenValidator = (IBetweenValidator)context.PropertyValidator; + var schemaProperty = context.Property; + + //OpenApi: date-time has not support range validations see: https://github.com/json-schema-org/json-schema-spec/issues/116 + + if (betweenValidator.From.IsNumeric()) + { + OpenApiSchemaCompatibility.SetNewMinimum(schemaProperty, betweenValidator.From.NumericToDecimal()); + if (_options.Value.SetNotNullableIfMinLengthGreaterThenZero) + OpenApiSchemaCompatibility.SetNotNullable(schemaProperty); + + if (betweenValidator.Name == "ExclusiveBetweenValidator") + { + OpenApiSchemaCompatibility.SetExclusiveMinimum(schemaProperty, true); + } + } + + if (betweenValidator.To.IsNumeric()) + { + OpenApiSchemaCompatibility.SetNewMaximum(schemaProperty, betweenValidator.To.NumericToDecimal()); + + if (betweenValidator.Name == "ExclusiveBetweenValidator") + { + OpenApiSchemaCompatibility.SetExclusiveMaximum(schemaProperty, true); + } + } + }); + } + } +} diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationRule.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationRule.cs new file mode 100644 index 0000000..98107e9 --- /dev/null +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationRule.cs @@ -0,0 +1,80 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Validators; +using MicroElements.OpenApi.FluentValidation; +#if !OPENAPI_V2 +using Microsoft.OpenApi.Models; +#endif + +namespace MicroElements.AspNetCore.OpenApi.FluentValidation +{ + /// + /// FluentValidationRule. + /// + public class FluentValidationRule : IFluentValidationRule + { + /// + /// Gets rule name. + /// + public string Name { get; } + + /// + /// Gets predicates that checks validator is matches rule. + /// + public IReadOnlyCollection> Conditions { get; } + + /// + /// Gets action that modifies swagger schema. + /// + public Action>? Apply { get; } + + /// + /// Initializes a new instance of the class. + /// + /// Rule name. + /// Validator predicates. + /// Apply rule to schema action. + public FluentValidationRule( + string name, + IReadOnlyCollection>? matches = null, + Action>? apply = null) + { + Name = name; + Conditions = matches ?? Array.Empty>(); + Apply = apply; + } + } + + /// + /// extensions. + /// + public static class FluentValidationRuleExtensions + { + /// + /// Adds match predicate. + /// + /// Source rule. + /// Validator selector. + /// New rule instance. + public static FluentValidationRule WithCondition(this FluentValidationRule rule, Func validatorPredicate) + { + var matches = rule.Conditions.Append(validatorPredicate).ToArray(); + return new FluentValidationRule(rule.Name, matches, rule.Apply); + } + + /// + /// Sets action. + /// + /// Source rule. + /// New apply action. + /// New rule instance. + public static FluentValidationRule WithApply(this FluentValidationRule rule, Action> applyAction) + { + return new FluentValidationRule(rule.Name, rule.Conditions, applyAction); + } + } +} diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationSchemaTransformer.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationSchemaTransformer.cs new file mode 100644 index 0000000..83a7054 --- /dev/null +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationSchemaTransformer.cs @@ -0,0 +1,162 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentValidation; +using MicroElements.OpenApi; +using MicroElements.OpenApi.Core; +using MicroElements.OpenApi.FluentValidation; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +#if !OPENAPI_V2 +using Microsoft.OpenApi.Models; +#endif + +namespace MicroElements.AspNetCore.OpenApi.FluentValidation +{ + /// + /// that applies FluentValidation rules to OpenAPI schemas + /// generated by Microsoft.AspNetCore.OpenApi. + /// + public class FluentValidationSchemaTransformer : IOpenApiSchemaTransformer + { + private readonly ILogger _logger; + + private readonly IValidatorRegistry _validatorRegistry; + + private readonly IReadOnlyList> _rules; + private readonly SchemaGenerationOptions _schemaGenerationOptions; + + /// + /// Initializes a new instance of the class. + /// + /// for logging. Can be null. + /// Gets validators for a particular type. + /// Rules provider. + /// External FluentValidation rules. External rule overrides default rule with the same name. + /// Schema generation options. + public FluentValidationSchemaTransformer( + /* System services */ + ILoggerFactory? loggerFactory = null, + + /* MicroElements services */ + IValidatorRegistry? validatorRegistry = null, + IFluentValidationRuleProvider? fluentValidationRuleProvider = null, + IEnumerable? rules = null, + IOptions? schemaGenerationOptions = null) + { + // System services + _logger = loggerFactory?.CreateLogger(typeof(FluentValidationSchemaTransformer)) ?? NullLogger.Instance; + + // FluentValidation services + _validatorRegistry = validatorRegistry ?? throw new ArgumentNullException(nameof(validatorRegistry)); + + // MicroElements services + fluentValidationRuleProvider ??= new DefaultFluentValidationRuleProvider(schemaGenerationOptions); + _rules = fluentValidationRuleProvider.GetRules().ToArray().OverrideRules(rules); + _schemaGenerationOptions = schemaGenerationOptions?.Value ?? new SchemaGenerationOptions(); + + _logger.LogDebug("FluentValidationSchemaTransformer Created"); + } + + /// + public Task TransformAsync( + OpenApiSchema schema, + OpenApiSchemaTransformerContext context, + CancellationToken cancellationToken) + { + var type = context.JsonTypeInfo.Type; + + // Skip primitive/simple types. + if (type.IsPrimitiveType()) + return Task.CompletedTask; + + // Skip property-level schemas (we process only type-level schemas). + // IOpenApiSchemaTransformer is called for each schema including property schemas. + if (context.JsonPropertyInfo != null) + return Task.CompletedTask; + + var typeContext = new TypeContext(type, _schemaGenerationOptions); + + var (validators, _) = Functional + .Try(() => _validatorRegistry.GetValidators(type).ToArray()) + .OnError(e => _logger.LogWarning(0, e, "GetValidators for type '{ModelType}' failed", type)); + + if (validators == null) + return Task.CompletedTask; + + // Collect root schema and any embedded schemas from AllOf/OneOf/AnyOf. + // This handles polymorphic/inheritance models where properties may be in sub-schemas. + var allSchemas = new List(); + ProcessAllSchemas(schema, allSchemas); + + var schemaProvider = new AspNetCoreSchemaProvider(context); + + foreach (var validator in validators) + { + foreach (var currentSchema in allSchemas) + { + var validatorContext = new ValidatorContext(typeContext, validator); + var schemaContext = new AspNetCoreSchemaGenerationContext( + schema: currentSchema, + schemaType: type, + rules: _rules, + schemaGenerationOptions: _schemaGenerationOptions, + schemaProvider: schemaProvider); + + FluentValidationSchemaBuilder.ApplyRulesToSchema( + schemaType: type, + schemaPropertyNames: schemaContext.Properties, + validator: validator, + logger: _logger, + schemaGenerationContext: schemaContext); + + try + { + FluentValidationSchemaBuilder.AddRulesFromIncludedValidators( + validatorContext: validatorContext, + logger: _logger, + schemaGenerationContext: schemaContext); + } + catch (Exception e) + { + _logger.LogWarning(0, e, "Applying IncludeRules for type '{ModelType}' failed", type); + } + } + } + + return Task.CompletedTask; + } + + private static void ProcessAllSchemas(OpenApiSchema schema, List schemas) + { + schemas.Add(schema); + + var collectionsOfSchemas = new IEnumerable[] + { + OpenApiSchemaCompatibility.GetAllOf(schema), + OpenApiSchemaCompatibility.GetOneOf(schema), + OpenApiSchemaCompatibility.GetAnyOf(schema), + }; + + foreach (var collectionOfSchemas in collectionsOfSchemas) + { + foreach (var embeddedSchema in collectionOfSchemas) + { + if (embeddedSchema.Properties == null || embeddedSchema.Properties.Count == 0) + { + continue; + } + + schemas.Add(embeddedSchema); + } + } + } + } +} diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/Generation/SystemTextJsonNameResolver.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/Generation/SystemTextJsonNameResolver.cs new file mode 100644 index 0000000..939ad79 --- /dev/null +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/Generation/SystemTextJsonNameResolver.cs @@ -0,0 +1,44 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using MicroElements.OpenApi.FluentValidation; +using MicroElements.AspNetCore.OpenApi.FluentValidation.AspNetCore; + +namespace MicroElements.AspNetCore.OpenApi.FluentValidation.Generation +{ + /// + /// Resolves name according System.Text.Json or . + /// + public class SystemTextJsonNameResolver : INameResolver + { + private readonly JsonSerializerOptions? _serializerOptions; + + /// + /// Initializes a new instance of the class. + /// + /// . + public SystemTextJsonNameResolver(AspNetJsonSerializerOptions? serializerOptions = null) + { + _serializerOptions = serializerOptions?.Value ?? new JsonSerializerOptions(); + } + + /// + public string GetPropertyName(PropertyInfo propertyInfo) + { + if (propertyInfo.GetCustomAttribute() is { Name: { } jsonPropertyName }) + { + return jsonPropertyName; + } + + if (_serializerOptions?.PropertyNamingPolicy is { } jsonNamingPolicy) + { + return jsonNamingPolicy.ConvertName(propertyInfo.Name); + } + + return propertyInfo.Name; + } + } +} diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/GlobalUsings.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/GlobalUsings.cs new file mode 100644 index 0000000..b5c1f19 --- /dev/null +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/GlobalUsings.cs @@ -0,0 +1,10 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// OpenApi 2.x uses Microsoft.OpenApi namespace for models (no .Models sub-namespace) +// OpenApi 1.x uses Microsoft.OpenApi.Models namespace +#if OPENAPI_V2 +global using Microsoft.OpenApi; +#else +global using Microsoft.OpenApi.Models; +#endif diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/MicroElements.AspNetCore.OpenApi.FluentValidation.csproj b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/MicroElements.AspNetCore.OpenApi.FluentValidation.csproj new file mode 100644 index 0000000..c86e856 --- /dev/null +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/MicroElements.AspNetCore.OpenApi.FluentValidation.csproj @@ -0,0 +1,36 @@ + + + + Library + net9.0;net10.0 + enable + latest + true + false + Applies FluentValidation rules to OpenAPI schemas generated by Microsoft.AspNetCore.OpenApi (IOpenApiSchemaTransformer). + OpenApi FluentValidation aspnetcore Microsoft.AspNetCore.OpenApi + + + + $(DefineConstants);OPENAPI_V2 + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApi/OpenApiExtensions.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApi/OpenApiExtensions.cs new file mode 100644 index 0000000..d59ad4b --- /dev/null +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApi/OpenApiExtensions.cs @@ -0,0 +1,97 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Linq.Expressions; +using System.Reflection; +#if !OPENAPI_V2 +using Microsoft.OpenApi.Models; +#endif + +namespace MicroElements.OpenApi +{ + /// + /// Extensions for . + /// + public static class OpenApiExtensions + { + /// + /// Sets Nullable to false if MinLength > 0. + /// + internal static void SetNotNullableIfMinLengthGreaterThenZero(this OpenApiSchema schemaProperty) + { + if (schemaProperty.MinLength.HasValue && schemaProperty.MinLength > 0) + { + OpenApiSchemaCompatibility.SetNotNullable(schemaProperty); + } + } + + internal static void SetNewMax(this OpenApiSchema schemaProperty, Expression> prop, int? newValue) + { + if (newValue.HasValue) + { + var current = prop.Compile()(schemaProperty); + newValue = NewMaxValue(current, newValue.Value); + SetPropertyValue(schemaProperty, prop, newValue); + } + } + + internal static void SetNewMax(this OpenApiSchema schemaProperty, Expression> prop, decimal? newValue) + { + if (newValue.HasValue) + { + var current = prop.Compile()(schemaProperty); + newValue = NewMaxValue(current, newValue.Value); + SetPropertyValue(schemaProperty, prop, newValue); + } + } + + internal static void SetNewMin(this OpenApiSchema schemaProperty, Expression> prop, int? newValue, bool setNotNullableIfMinLengthGreaterThenZero = true) + { + if (newValue.HasValue) + { + var current = prop.Compile()(schemaProperty); + newValue = NewMinValue(current, newValue.Value); + SetPropertyValue(schemaProperty, prop, newValue); + } + + // SetNotNullableIfMinLengthGreaterThenZero should be optionated because FV allows nulls for MinLength validator + if (setNotNullableIfMinLengthGreaterThenZero) + schemaProperty.SetNotNullableIfMinLengthGreaterThenZero(); + } + + internal static void SetNewMin(this OpenApiSchema schemaProperty, Expression> prop, decimal? newValue, bool setNotNullableIfMinLengthGreaterThenZero = true) + { + if (newValue.HasValue) + { + var current = prop.Compile()(schemaProperty); + newValue = NewMinValue(current, newValue.Value); + SetPropertyValue(schemaProperty, prop, newValue); + } + + // SetNotNullableIfMinLengthGreaterThenZero should be optionated because FV allows nulls for MinLength validator + if (setNotNullableIfMinLengthGreaterThenZero) + schemaProperty.SetNotNullableIfMinLengthGreaterThenZero(); + } + + private static int NewMaxValue(int? current, int newValue) => current.HasValue ? Math.Min(current.Value, newValue) : newValue; + + private static decimal NewMaxValue(decimal? current, decimal newValue) => current.HasValue ? Math.Min(current.Value, newValue) : newValue; + + private static int NewMinValue(int? current, int newValue) => current.HasValue ? Math.Max(current.Value, newValue) : newValue; + + private static decimal NewMinValue(decimal? current, decimal newValue) => current.HasValue ? Math.Max(current.Value, newValue) : newValue; + + private static void SetPropertyValue(this T target, Expression> propertyLambda, TValue value) + { + if (propertyLambda.Body is MemberExpression memberSelectorExpression) + { + var property = memberSelectorExpression.Member as PropertyInfo; + if (property != null) + { + property.SetValue(target, value, null); + } + } + } + } +} diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs new file mode 100644 index 0000000..d3d9f5c --- /dev/null +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs @@ -0,0 +1,484 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +#if OPENAPI_V2 +using Microsoft.OpenApi; +#else +using Microsoft.OpenApi.Models; +#endif + +namespace MicroElements.OpenApi +{ + /// + /// Compatibility layer for Microsoft.OpenApi 1.x and 2.x differences. + /// + internal static class OpenApiSchemaCompatibility + { + /// + /// Checks if schema type is string. + /// + public static bool IsStringType(OpenApiSchema schema) + { +#if OPENAPI_V2 + return schema.Type.HasValue && schema.Type.Value.HasFlag(JsonSchemaType.String); +#else + return schema.Type == "string"; +#endif + } + + /// + /// Checks if schema type is array. + /// + public static bool IsArrayType(OpenApiSchema schema) + { +#if OPENAPI_V2 + return schema.Type.HasValue && schema.Type.Value.HasFlag(JsonSchemaType.Array); +#else + return schema.Type == "array"; +#endif + } + + /// + /// Sets schema as not nullable. + /// + public static void SetNotNullable(OpenApiSchema schema) + { +#if OPENAPI_V2 + if (schema.Type.HasValue) + { + schema.Type &= ~JsonSchemaType.Null; + } +#else + schema.Nullable = false; +#endif + } + + /// + /// Gets nullable value from schema. + /// + public static bool GetNullable(OpenApiSchema schema) + { +#if OPENAPI_V2 + return schema.Type.HasValue && schema.Type.Value.HasFlag(JsonSchemaType.Null); +#else + return schema.Nullable; +#endif + } + +#if OPENAPI_V2 + /// + /// Gets nullable value from IOpenApiSchema interface. + /// + public static bool GetNullable(IOpenApiSchema schema) + { + if (schema is OpenApiSchema openApiSchema) + return GetNullable(openApiSchema); + return false; + } +#endif + + /// + /// Sets nullable value on schema. + /// + public static void SetNullable(OpenApiSchema schema, bool value) + { +#if OPENAPI_V2 + if (value) + { + if (schema.Type.HasValue) + schema.Type |= JsonSchemaType.Null; + } + else + { + SetNotNullable(schema); + } +#else + schema.Nullable = value; +#endif + } + + /// + /// Ensures Required collection is initialized. + /// + public static void EnsureRequiredInitialized(OpenApiSchema schema) + { +#if OPENAPI_V2 + schema.Required ??= new HashSet(); +#endif + // In v1, Required is always initialized + } + + /// + /// Adds property to Required collection safely. + /// + public static void AddRequired(OpenApiSchema schema, string propertyName) + { + EnsureRequiredInitialized(schema); + if (!schema.Required.Contains(propertyName)) + { + schema.Required.Add(propertyName); + } + } + + /// + /// Checks if Required collection contains property. + /// + public static bool RequiredContains(OpenApiSchema schema, string propertyName) + { +#if OPENAPI_V2 + return schema.Required?.Contains(propertyName) ?? false; +#else + return schema.Required.Contains(propertyName); +#endif + } + + /// + /// Ensures AllOf collection is initialized. + /// + public static void EnsureAllOfInitialized(OpenApiSchema schema) + { +#if OPENAPI_V2 + schema.AllOf ??= new List(); +#endif + } + + /// + /// Gets count of AllOf items matching predicate. + /// + public static int AllOfCountWhere(OpenApiSchema schema, Func predicate) + { +#if OPENAPI_V2 + return schema.AllOf?.OfType().Count(predicate) ?? 0; +#else + return schema.AllOf.Count(predicate); +#endif + } + + /// + /// Adds schema to AllOf collection safely. + /// + public static void AddAllOf(OpenApiSchema schema, OpenApiSchema child) + { + EnsureAllOfInitialized(schema); + schema.AllOf.Add(child); + } + + /// + /// Checks if Properties dictionary contains key. + /// + public static bool PropertiesContainsKey(OpenApiSchema schema, string key) + { + return schema.Properties?.ContainsKey(key) ?? false; + } + + /// + /// Gets Properties count safely. + /// + public static int PropertiesCount(OpenApiSchema schema) + { + return schema.Properties?.Count ?? 0; + } + + /// + /// Gets property from schema by key. + /// + public static OpenApiSchema? GetProperty(OpenApiSchema schema, string key) + { +#if OPENAPI_V2 + if (schema.Properties?.TryGetValue(key, out var property) == true) + return property as OpenApiSchema; + return null; +#else + if (schema.Properties?.TryGetValue(key, out var property) == true) + return property; + return null; +#endif + } + + /// + /// Tries to get property from schema by key. + /// + public static bool TryGetProperty(OpenApiSchema schema, string key, out OpenApiSchema? property) + { +#if OPENAPI_V2 + if (schema.Properties?.TryGetValue(key, out var prop) == true) + { + property = prop as OpenApiSchema; + return property != null; + } + property = null; + return false; +#else + if (schema.Properties?.TryGetValue(key, out var prop) == true) + { + property = prop; + return true; + } + property = null; + return false; +#endif + } + + /// + /// Sets ExclusiveMinimum on schema. + /// + public static void SetExclusiveMinimum(OpenApiSchema schema, bool value) + { +#if OPENAPI_V2 + // In OpenAPI 3.1 / OpenApi 2.x, exclusiveMinimum is a number (string), not a boolean + // When exclusive, the minimum value is stored in exclusiveMinimum instead + if (value && !string.IsNullOrEmpty(schema.Minimum)) + { + schema.ExclusiveMinimum = schema.Minimum; + schema.Minimum = null; + } +#else + schema.ExclusiveMinimum = value; +#endif + } + + /// + /// Sets ExclusiveMaximum on schema. + /// + public static void SetExclusiveMaximum(OpenApiSchema schema, bool value) + { +#if OPENAPI_V2 + // In OpenAPI 3.1 / OpenApi 2.x, exclusiveMaximum is a number (string), not a boolean + if (value && !string.IsNullOrEmpty(schema.Maximum)) + { + schema.ExclusiveMaximum = schema.Maximum; + schema.Maximum = null; + } +#else + schema.ExclusiveMaximum = value; +#endif + } + + /// + /// Sets Minimum on schema. + /// + public static void SetMinimum(OpenApiSchema schema, decimal value) + { +#if OPENAPI_V2 + schema.Minimum = value.ToString(CultureInfo.InvariantCulture); +#else + schema.Minimum = value; +#endif + } + + /// + /// Sets Maximum on schema. + /// + public static void SetMaximum(OpenApiSchema schema, decimal value) + { +#if OPENAPI_V2 + schema.Maximum = value.ToString(CultureInfo.InvariantCulture); +#else + schema.Maximum = value; +#endif + } + + /// + /// Gets Minimum from schema. + /// + public static decimal? GetMinimum(OpenApiSchema schema) + { +#if OPENAPI_V2 + if (decimal.TryParse(schema.Minimum, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + return result; + return null; +#else + return schema.Minimum; +#endif + } + + /// + /// Gets Maximum from schema. + /// + public static decimal? GetMaximum(OpenApiSchema schema) + { +#if OPENAPI_V2 + if (decimal.TryParse(schema.Maximum, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + return result; + return null; +#else + return schema.Maximum; +#endif + } + + /// + /// Sets Minimum with comparison logic (takes the larger of existing and new value). + /// + public static void SetNewMinimum(OpenApiSchema schema, decimal newValue) + { + var current = GetMinimum(schema); + var finalValue = current.HasValue ? Math.Max(current.Value, newValue) : newValue; + SetMinimum(schema, finalValue); + } + + /// + /// Sets Maximum with comparison logic (takes the smaller of existing and new value). + /// + public static void SetNewMaximum(OpenApiSchema schema, decimal newValue) + { + var current = GetMaximum(schema); + var finalValue = current.HasValue ? Math.Min(current.Value, newValue) : newValue; + SetMaximum(schema, finalValue); + } + + /// + /// Copies ExclusiveMinimum from source to target schema. + /// + public static void CopyExclusiveMinimum(OpenApiSchema target, OpenApiSchema source) + { + target.ExclusiveMinimum = source.ExclusiveMinimum; + } + + /// + /// Copies ExclusiveMaximum from source to target schema. + /// + public static void CopyExclusiveMaximum(OpenApiSchema target, OpenApiSchema source) + { + target.ExclusiveMaximum = source.ExclusiveMaximum; + } + + /// + /// Copies Minimum from source to target schema. + /// + public static void CopyMinimum(OpenApiSchema target, OpenApiSchema source) + { + target.Minimum = source.Minimum; + } + + /// + /// Copies Maximum from source to target schema. + /// + public static void CopyMaximum(OpenApiSchema target, OpenApiSchema source) + { + target.Maximum = source.Maximum; + } + +#if OPENAPI_V2 + /// + /// Copies Minimum from IOpenApiSchema source to target schema. + /// + public static void CopyMinimum(OpenApiSchema target, IOpenApiSchema source) + { + target.Minimum = source.Minimum; + } + + /// + /// Copies Maximum from IOpenApiSchema source to target schema. + /// + public static void CopyMaximum(OpenApiSchema target, IOpenApiSchema source) + { + target.Maximum = source.Maximum; + } + + /// + /// Copies ExclusiveMinimum from IOpenApiSchema source to target schema. + /// + public static void CopyExclusiveMinimum(OpenApiSchema target, IOpenApiSchema source) + { + target.ExclusiveMinimum = source.ExclusiveMinimum; + } + + /// + /// Copies ExclusiveMaximum from IOpenApiSchema source to target schema. + /// + public static void CopyExclusiveMaximum(OpenApiSchema target, IOpenApiSchema source) + { + target.ExclusiveMaximum = source.ExclusiveMaximum; + } +#endif + + /// + /// Gets Items property from schema. + /// + public static OpenApiSchema? GetItems(OpenApiSchema schema) + { +#if OPENAPI_V2 + return schema.Items as OpenApiSchema; +#else + return schema.Items; +#endif + } + + /// + /// Gets AllOf collection as OpenApiSchema enumerable. + /// + public static IEnumerable GetAllOf(OpenApiSchema schema) + { +#if OPENAPI_V2 + return schema.AllOf?.OfType() ?? Enumerable.Empty(); +#else + return schema.AllOf ?? Enumerable.Empty(); +#endif + } + + /// + /// Gets OneOf collection as OpenApiSchema enumerable. + /// + public static IEnumerable GetOneOf(OpenApiSchema schema) + { +#if OPENAPI_V2 + return schema.OneOf?.OfType() ?? Enumerable.Empty(); +#else + return schema.OneOf ?? Enumerable.Empty(); +#endif + } + + /// + /// Gets AnyOf collection as OpenApiSchema enumerable. + /// + public static IEnumerable GetAnyOf(OpenApiSchema schema) + { +#if OPENAPI_V2 + return schema.AnyOf?.OfType() ?? Enumerable.Empty(); +#else + return schema.AnyOf ?? Enumerable.Empty(); +#endif + } + + /// + /// Gets Properties dictionary values as OpenApiSchema enumerable. + /// + public static IEnumerable> GetProperties(OpenApiSchema schema) + { +#if OPENAPI_V2 + if (schema.Properties == null) + return Enumerable.Empty>(); + // Filter out OpenApiSchemaReference entries (e.g., enum types) to avoid InvalidCastException + // Issue #176: https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation/issues/176 + return schema.Properties + .Where(kvp => kvp.Value is OpenApiSchema) + .Select(kvp => new KeyValuePair(kvp.Key, (OpenApiSchema)kvp.Value)); +#else + return schema.Properties ?? Enumerable.Empty>(); +#endif + } + +#if OPENAPI_V2 + /// + /// Copies all validation-related properties from source to target. + /// + public static void CopyValidationProperties(OpenApiSchema target, IOpenApiSchema source) + { + target.MinLength = source.MinLength; + target.MaxLength = source.MaxLength; + target.Pattern = source.Pattern; + target.Minimum = source.Minimum; + target.Maximum = source.Maximum; + target.ExclusiveMinimum = source.ExclusiveMinimum; + target.ExclusiveMaximum = source.ExclusiveMaximum; + // Can't copy Enum and AllOf directly due to interface limitations + // These need special handling if needed + } +#endif + } + +} diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApiRuleContext.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApiRuleContext.cs new file mode 100644 index 0000000..c500775 --- /dev/null +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApiRuleContext.cs @@ -0,0 +1,86 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using FluentValidation; +using FluentValidation.Validators; +using MicroElements.OpenApi; +using MicroElements.OpenApi.FluentValidation; +#if !OPENAPI_V2 +using Microsoft.OpenApi.Models; +#endif + +namespace MicroElements.AspNetCore.OpenApi.FluentValidation +{ + /// + /// RuleContext. + /// + public class OpenApiRuleContext : IRuleContext + { + /// + /// Gets property name in schema. + /// + public string PropertyKey { get; } + + /// + /// Gets property validator for property in schema. + /// + public IPropertyValidator PropertyValidator { get; } + + /// + /// Gets OpenApi (swagger) schema. + /// + public OpenApiSchema Schema { get; } + + /// + /// Gets target property schema. + /// + public OpenApiSchema Property + { + get + { + if (!OpenApiSchemaCompatibility.PropertiesContainsKey(Schema, PropertyKey)) + { + Type? schemaType = ValidationRuleInfo.GetReflectionContext()?.Type; + throw new ApplicationException($"Schema for type '{schemaType}' does not contain property '{PropertyKey}'.\nRegister {typeof(INameResolver)} if name in type differs from name in json."); + } + + var schemaProperty = OpenApiSchemaCompatibility.GetProperty(Schema, PropertyKey); + + // Property is a schema reference (enum, nested class) - return empty schema to skip validation + // Issue #176: https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation/issues/176 + if (schemaProperty == null) + { + return new OpenApiSchema(); + } + + var items = OpenApiSchemaCompatibility.GetItems(schemaProperty); + return !ValidationRuleInfo.IsCollectionRule() ? schemaProperty : (items ?? schemaProperty); + } + } + + /// + /// Gets with extended information. + /// + private ValidationRuleContext ValidationRuleInfo { get; } + + /// + /// Initializes a new instance of the class. + /// + /// Swagger schema. + /// Property name. + /// ValidationRuleInfo. + /// Property validator. + public OpenApiRuleContext( + OpenApiSchema schema, + string propertyKey, + ValidationRuleContext validationRuleInfo, + IPropertyValidator propertyValidator) + { + Schema = schema; + PropertyKey = propertyKey; + ValidationRuleInfo = validationRuleInfo; + PropertyValidator = propertyValidator; + } + } +} diff --git a/src/MicroElements.OpenApi.FluentValidation/AssemblyAttributes.cs b/src/MicroElements.OpenApi.FluentValidation/AssemblyAttributes.cs index 0a2e6ad..2ee5678 100644 --- a/src/MicroElements.OpenApi.FluentValidation/AssemblyAttributes.cs +++ b/src/MicroElements.OpenApi.FluentValidation/AssemblyAttributes.cs @@ -2,4 +2,5 @@ [assembly: InternalsVisibleTo("MicroElements.Swashbuckle.FluentValidation")] [assembly: InternalsVisibleTo("MicroElements.NSwag.FluentValidation")] +[assembly: InternalsVisibleTo("MicroElements.AspNetCore.OpenApi.FluentValidation")] [assembly: InternalsVisibleTo("MicroElements.Swashbuckle.FluentValidation.Tests")] \ No newline at end of file diff --git a/version.props b/version.props index 4b86969..1934dea 100644 --- a/version.props +++ b/version.props @@ -1,6 +1,6 @@ - 7.0.4 - + 7.1.0 + beta.1 From 1bedf460c006120c1327672e443da77a9e3bf816 Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Mon, 23 Feb 2026 14:30:25 +0300 Subject: [PATCH 2/4] Translate ADR-001 to English Co-Authored-By: Claude Opus 4.6 --- .../adr/ADR-001-aspnetcore-openapi-support.md | 218 +++++++++--------- 1 file changed, 109 insertions(+), 109 deletions(-) diff --git a/docs/adr/ADR-001-aspnetcore-openapi-support.md b/docs/adr/ADR-001-aspnetcore-openapi-support.md index 949ba3c..3daa74d 100644 --- a/docs/adr/ADR-001-aspnetcore-openapi-support.md +++ b/docs/adr/ADR-001-aspnetcore-openapi-support.md @@ -1,164 +1,164 @@ -# ADR-001: Поддержка Microsoft.AspNetCore.OpenApi (IOpenApiSchemaTransformer) +# ADR-001: Microsoft.AspNetCore.OpenApi Support (IOpenApiSchemaTransformer) -**Статус:** Принято -**Дата:** 2026-02-23 +**Status:** Accepted +**Date:** 2026-02-23 **Issue:** [#149](https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation/issues/149) **Milestone:** v7.1.0 --- -## 1. Контекст и проблема +## 1. Context and Problem -С .NET 9 Microsoft предоставляет встроенную поддержку OpenAPI (`Microsoft.AspNetCore.OpenApi`): +Starting with .NET 9, Microsoft provides built-in OpenAPI support (`Microsoft.AspNetCore.OpenApi`): - `builder.Services.AddOpenApi()` + `app.MapOpenApi()` -- Трансформеры: `IOpenApiSchemaTransformer`, `IOpenApiDocumentTransformer`, `IOpenApiOperationTransformer` +- Transformers: `IOpenApiSchemaTransformer`, `IOpenApiDocumentTransformer`, `IOpenApiOperationTransformer` -Пользователи мигрируют с Swashbuckle на встроенное решение. Наша библиотека должна поддерживать оба варианта. +Users are migrating from Swashbuckle to the built-in solution. Our library must support both approaches. -**Ни .NET 9, ни .NET 10, ни будущие версии .NET НЕ включают маппинг FluentValidation на OpenAPI из коробки.** Microsoft предоставляет только инфраструктуру трансформеров, но не интеграцию с FluentValidation. Наша библиотека необходима для обоих версий. +**Neither .NET 9, nor .NET 10, nor future .NET versions include FluentValidation-to-OpenAPI mapping out of the box.** Microsoft provides only the transformer infrastructure, not the FluentValidation integration. Our library is needed for both versions. -### Референсная реализация (saithis) +### Reference Implementation (saithis) -Пользователь [saithis](https://github.com/saithis/dotnet-playground/tree/main/OpenApiFluentValidationApi) создал proof-of-concept: -- ~200 строк, standalone `FluentValidationSchemaTransformer : IOpenApiSchemaTransformer` -- Поддерживает: NotNull, NotEmpty, Length, MinLength, MaxLength, Between, Comparison, Regex, Email, CreditCard -- НЕ поддерживает: вложенные валидаторы (SetValidator), Include(), RuleForEach(), When/Unless, AllOf, кэширование, кастомизацию правил +User [saithis](https://github.com/saithis/dotnet-playground/tree/main/OpenApiFluentValidationApi) created a proof-of-concept: +- ~200 lines, standalone `FluentValidationSchemaTransformer : IOpenApiSchemaTransformer` +- Supports: NotNull, NotEmpty, Length, MinLength, MaxLength, Between, Comparison, Regex, Email, CreditCard +- Does NOT support: nested validators (SetValidator), Include(), RuleForEach(), When/Unless, AllOf, caching, rule customization -### Различия .NET 9 vs .NET 10 +### .NET 9 vs .NET 10 Differences -| Аспект | .NET 9 | .NET 10 | +| Aspect | .NET 9 | .NET 10 | |--------|--------|---------| -| `IOpenApiSchemaTransformer` | Есть | Есть | -| `GetOrCreateSchemaAsync()` | Нет | Есть | -| `context.Document` | Нет | Есть | -| Microsoft.OpenApi версия | v1.x | v2.x (ломающий API) | -| `OPENAPI_V2` нужен | Нет | Да | +| `IOpenApiSchemaTransformer` | Available | Available | +| `GetOrCreateSchemaAsync()` | No | Yes | +| `context.Document` | No | Yes | +| Microsoft.OpenApi version | v1.x | v2.x (breaking API) | +| `OPENAPI_V2` required | No | Yes | -Наша библиотека нужна для обоих версий. Различия только в API модели `OpenApiSchema`. +Our library is needed for both versions. The differences are only in the `OpenApiSchema` model API. --- -## 2. Рассмотренные варианты +## 2. Options Considered -### Вариант A: Новый отдельный пакет (ВЫБРАН) +### Option A: New Separate Package (CHOSEN) ``` -MicroElements.OpenApi.FluentValidation (ядро, generic абстракции) +MicroElements.OpenApi.FluentValidation (core, generic abstractions) ^ ^ | | -Swashbuckle пакет НОВЫЙ: AspNetCore.OpenApi пакет -(ISchemaFilter) (IOpenApiSchemaTransformer) +Swashbuckle package NEW: AspNetCore.OpenApi package +(ISchemaFilter) (IOpenApiSchemaTransformer) ``` - `MicroElements.AspNetCore.OpenApi.FluentValidation` - Targets: `net9.0;net10.0` -- Зависимости: ядро + `Microsoft.AspNetCore.OpenApi` (БЕЗ Swashbuckle) -- Дублирует ~630 строк OpenApiSchema-специфичного кода -- Планируется извлечение в Фазе 2 (v7.2) +- Dependencies: core + `Microsoft.AspNetCore.OpenApi` (NO Swashbuckle) +- Duplicates ~630 lines of OpenApiSchema-specific code +- Extraction to shared layer planned for Phase 2 (v7.2) -### Вариант B: Извлечение общего OpenApi-слоя (отложен на v7.2) +### Option B: Extract Shared OpenApi Layer (deferred to v7.2) ``` -MicroElements.OpenApi.FluentValidation (ядро, generic) +MicroElements.OpenApi.FluentValidation (core, generic) ^ -MicroElements.OpenApi.FluentValidation.Rules (НОВЫЙ: общие OpenApiSchema правила) +MicroElements.OpenApi.FluentValidation.Rules (NEW: shared OpenApiSchema rules) ^ ^ -Swashbuckle пакет НОВЫЙ: AspNetCore.OpenApi пакет +Swashbuckle package NEW: AspNetCore.OpenApi package ``` -- Извлекает общий код в shared пакет -- `[TypeForwardedTo]` для совместимости -- Ноль дублирования, но сложнее и риск breaking changes +- Extracts shared code into a shared package +- `[TypeForwardedTo]` for compatibility +- Zero duplication, but more complex with risk of breaking changes -### Вариант C: Минимальная интеграция (отклонён) +### Option C: Minimal Integration (rejected) -- Только net9.0, без OPENAPI_V2 -- Максимум дублирования, нет net10.0 +- net9.0 only, no OPENAPI_V2 +- Maximum duplication, no net10.0 --- -## 3. Решение: Вариант A (Поэтапный) +## 3. Decision: Option A (Phased) -**Фаза 1 (v7.1.0):** Новый пакет с контролируемым дублированием -**Фаза 2 (v7.2):** Извлечение общего слоя, очистка неймспейсов +**Phase 1 (v7.1.0):** New package with controlled duplication +**Phase 2 (v7.2):** Extract shared layer, clean up namespaces -### Обоснование -- Быстрый выпуск без breaking changes для существующих пользователей -- Дублирование управляемо (~630 строк, определённый набор файлов) -- Следует прецеденту NSwag пакета -- Извлечение общего слоя запланировано на v7.2 +### Rationale +- Fast release without breaking changes for existing users +- Duplication is manageable (~630 lines, well-defined set of files) +- Follows the NSwag package precedent +- Shared layer extraction planned for v7.2 --- -## 4. Архитектура нового пакета +## 4. New Package Architecture -### 4.1 Граф зависимостей +### 4.1 Dependency Graph ``` MicroElements.AspNetCore.OpenApi.FluentValidation - -> MicroElements.OpenApi.FluentValidation (ядро) + -> MicroElements.OpenApi.FluentValidation (core) -> FluentValidation >= 12.0.0 -> Microsoft.Extensions.Logging.Abstractions -> Microsoft.Extensions.Options - -> Microsoft.AspNetCore.OpenApi (>= 9.0.0 для net9.0, >= 10.0.0 для net10.0) - [НЕТ зависимости от Swashbuckle] + -> Microsoft.AspNetCore.OpenApi (>= 9.0.0 for net9.0, >= 10.0.0 for net10.0) + [NO dependency on Swashbuckle] ``` -### 4.2 Структура файлов +### 4.2 File Structure ``` src/MicroElements.AspNetCore.OpenApi.FluentValidation/ -│ +| ├── MicroElements.AspNetCore.OpenApi.FluentValidation.csproj ├── GlobalUsings.cs -│ -├── FluentValidationSchemaTransformer.cs # НОВЫЙ: IOpenApiSchemaTransformer -├── AspNetCoreSchemaGenerationContext.cs # НОВЫЙ: ISchemaGenerationContext -├── AspNetCoreSchemaProvider.cs # НОВЫЙ: ISchemaProvider -│ -├── FluentValidationRule.cs # КОПИЯ из Swashbuckle -├── DefaultFluentValidationRuleProvider.cs # КОПИЯ из Swashbuckle -├── OpenApiRuleContext.cs # КОПИЯ из Swashbuckle -│ +| +├── FluentValidationSchemaTransformer.cs # NEW: IOpenApiSchemaTransformer +├── AspNetCoreSchemaGenerationContext.cs # NEW: ISchemaGenerationContext +├── AspNetCoreSchemaProvider.cs # NEW: ISchemaProvider +| +├── FluentValidationRule.cs # COPY from Swashbuckle +├── DefaultFluentValidationRuleProvider.cs # COPY from Swashbuckle +├── OpenApiRuleContext.cs # COPY from Swashbuckle +| ├── OpenApi/ -│ ├── OpenApiSchemaCompatibility.cs # КОПИЯ из Swashbuckle -│ └── OpenApiExtensions.cs # КОПИЯ из Swashbuckle -│ +│ ├── OpenApiSchemaCompatibility.cs # COPY from Swashbuckle +│ └── OpenApiExtensions.cs # COPY from Swashbuckle +| ├── Generation/ -│ └── SystemTextJsonNameResolver.cs # КОПИЯ из Swashbuckle -│ +│ └── SystemTextJsonNameResolver.cs # COPY from Swashbuckle +| └── AspNetCore/ - ├── AspNetJsonSerializerOptions.cs # КОПИЯ из Swashbuckle - ├── ReflectionDependencyInjectionExtensions.cs # КОПИЯ из Swashbuckle - ├── ServiceCollectionExtensions.cs # НОВЫЙ: DI регистрация - └── OpenApiOptionsExtensions.cs # НОВЫЙ: AddFluentValidationRules() + ├── AspNetJsonSerializerOptions.cs # COPY from Swashbuckle + ├── ReflectionDependencyInjectionExtensions.cs # COPY from Swashbuckle + ├── ServiceCollectionExtensions.cs # NEW: DI registration + └── OpenApiOptionsExtensions.cs # NEW: AddFluentValidationRules() ``` -### 4.3 Классификация файлов - -| Файл | Тип | Источник | -|------|-----|----------| -| `.csproj` | Новый | - | -| `GlobalUsings.cs` | Копия | Swashbuckle GlobalUsings.cs | -| `FluentValidationSchemaTransformer.cs` | **Новый** | По паттерну FluentValidationRules.cs | -| `AspNetCoreSchemaGenerationContext.cs` | **Новый** | По паттерну SchemaGenerationContext.cs | -| `AspNetCoreSchemaProvider.cs` | **Новый** | net9: stub, net10: GetOrCreateSchemaAsync | -| `FluentValidationRule.cs` | Копия | Swashbuckle FluentValidationRule.cs | -| `DefaultFluentValidationRuleProvider.cs` | Копия | Swashbuckle DefaultFluentValidationRuleProvider.cs | -| `OpenApiRuleContext.cs` | Копия | Swashbuckle OpenApiRuleContext.cs | -| `OpenApiSchemaCompatibility.cs` | Копия | Swashbuckle OpenApiSchemaCompatibility.cs | -| `OpenApiExtensions.cs` | Копия | Swashbuckle OpenApiExtensions.cs | -| `SystemTextJsonNameResolver.cs` | Копия | Swashbuckle SystemTextJsonNameResolver.cs | -| `AspNetJsonSerializerOptions.cs` | Копия | Swashbuckle AspNetJsonSerializerOptions.cs | -| `ReflectionDependencyInjectionExtensions.cs` | Копия | Swashbuckle ReflectionDependencyInjectionExtensions.cs | -| `ServiceCollectionExtensions.cs` | **Новый** | По паттерну Swashbuckle ServiceCollectionExtensions.cs | -| `OpenApiOptionsExtensions.cs` | **Новый** | - | +### 4.3 File Classification + +| File | Type | Source | +|------|------|--------| +| `.csproj` | New | - | +| `GlobalUsings.cs` | Copy | Swashbuckle GlobalUsings.cs | +| `FluentValidationSchemaTransformer.cs` | **New** | Based on FluentValidationRules.cs pattern | +| `AspNetCoreSchemaGenerationContext.cs` | **New** | Based on SchemaGenerationContext.cs pattern | +| `AspNetCoreSchemaProvider.cs` | **New** | net9: stub, net10: GetOrCreateSchemaAsync | +| `FluentValidationRule.cs` | Copy | Swashbuckle FluentValidationRule.cs | +| `DefaultFluentValidationRuleProvider.cs` | Copy | Swashbuckle DefaultFluentValidationRuleProvider.cs | +| `OpenApiRuleContext.cs` | Copy | Swashbuckle OpenApiRuleContext.cs | +| `OpenApiSchemaCompatibility.cs` | Copy | Swashbuckle OpenApiSchemaCompatibility.cs | +| `OpenApiExtensions.cs` | Copy | Swashbuckle OpenApiExtensions.cs | +| `SystemTextJsonNameResolver.cs` | Copy | Swashbuckle SystemTextJsonNameResolver.cs | +| `AspNetJsonSerializerOptions.cs` | Copy | Swashbuckle AspNetJsonSerializerOptions.cs | +| `ReflectionDependencyInjectionExtensions.cs` | Copy | Swashbuckle ReflectionDependencyInjectionExtensions.cs | +| `ServiceCollectionExtensions.cs` | **New** | Based on Swashbuckle ServiceCollectionExtensions.cs pattern | +| `OpenApiOptionsExtensions.cs` | **New** | - | --- ## 5. User-Facing API -### Регистрация в Program.cs +### Registration in Program.cs ```csharp var builder = WebApplication.CreateBuilder(args); @@ -180,7 +180,7 @@ app.MapOpenApi(); app.Run(); ``` -### Миграция со Swashbuckle +### Migration from Swashbuckle ```diff // NuGet @@ -200,37 +200,37 @@ app.Run(); --- -## 6. Известные ограничения +## 6. Known Limitations -1. **Вложенные валидаторы на .NET 9**: `SetValidator()` sub-schema resolution ограничен (нет `GetOrCreateSchemaAsync`). Полная поддержка на .NET 10. -2. **Гранулярность трансформера**: `IOpenApiSchemaTransformer` вызывается per-schema (включая property schemas). Нужно фильтровать по `context.JsonPropertyInfo == null`. -3. **Дублирование кода**: ~630 строк дублированы из Swashbuckle пакета. Баг-фиксы нужно применять в обоих местах до v7.2 (Фаза 2). +1. **Nested validators on .NET 9**: `SetValidator()` sub-schema resolution is limited (no `GetOrCreateSchemaAsync`). Full support on .NET 10. +2. **Transformer granularity**: `IOpenApiSchemaTransformer` is called per-schema (including property schemas). Must filter by `context.JsonPropertyInfo == null`. +3. **Code duplication**: ~630 lines duplicated from the Swashbuckle package. Bug fixes must be applied in both places until v7.2 (Phase 2). --- -## 7. Верификация +## 7. Verification -### 7.1 Сборка +### 7.1 Build ```bash dotnet build MicroElements.Swashbuckle.FluentValidation.sln ``` -- Все проекты компилируются без ошибок +- All projects compile without errors -### 7.2 Тесты +### 7.2 Tests ```bash dotnet test MicroElements.Swashbuckle.FluentValidation.sln ``` -- Существующие тесты проходят (нет регрессий) -- Новые тесты для всех типов правил проходят +- Existing tests pass (no regressions) +- New tests for all rule types pass -### 7.3 Sample приложение +### 7.3 Sample Application ```bash cd samples/SampleAspNetCoreOpenApi dotnet run -# Открыть /openapi/v1.json +# Open /openapi/v1.json ``` -- OpenAPI документ содержит validation constraints +- OpenAPI document contains validation constraints -### 7.4 Зависимости -- НЕТ транзитивной зависимости от Swashbuckle -- Есть зависимость на MicroElements.OpenApi.FluentValidation и Microsoft.AspNetCore.OpenApi +### 7.4 Dependencies +- NO transitive dependency on Swashbuckle +- Depends on MicroElements.OpenApi.FluentValidation and Microsoft.AspNetCore.OpenApi From 2b7252906aa51d5b7543af6a7a166392853ad7be Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Mon, 23 Feb 2026 14:35:50 +0300 Subject: [PATCH 3/4] Fix CI review findings for AspNetCore.OpenApi package - Fix potential NullRef on assembly.FullName (null-conditional check) - Cache GetByFullName results in static ConcurrentDictionary (avoid per-request AppDomain scan) - Remove dead code ConfigureJsonOptionsForAspNetCore method - Add logger to AspNetCoreSchemaProvider for exception logging in catch block - Change exact version pinning to version ranges in .csproj Co-Authored-By: Claude Opus 4.6 --- ...ReflectionDependencyInjectionExtensions.cs | 58 ++++--------------- .../AspNetCoreSchemaProvider.cs | 11 +++- .../FluentValidationSchemaTransformer.cs | 2 +- ...AspNetCore.OpenApi.FluentValidation.csproj | 4 +- 4 files changed, 23 insertions(+), 52 deletions(-) diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ReflectionDependencyInjectionExtensions.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ReflectionDependencyInjectionExtensions.cs index 3a64b9f..731fcb7 100644 --- a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ReflectionDependencyInjectionExtensions.cs +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ReflectionDependencyInjectionExtensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -16,16 +17,19 @@ namespace MicroElements.AspNetCore.OpenApi.FluentValidation.AspNetCore /// internal static class ReflectionDependencyInjectionExtensions { + private static readonly ConcurrentDictionary _typeCache = new(); + private static Type? GetByFullName(string typeName) { - Type? type = AppDomain - .CurrentDomain - .GetAssemblies() - .Where(assembly => assembly.FullName.Contains("Microsoft")) - .SelectMany(GetLoadableTypes) - .FirstOrDefault(type => type.FullName == typeName); - - return type; + return _typeCache.GetOrAdd(typeName, static name => + { + return AppDomain + .CurrentDomain + .GetAssemblies() + .Where(assembly => assembly.FullName?.Contains("Microsoft") == true) + .SelectMany(GetLoadableTypes) + .FirstOrDefault(type => type.FullName == name); + }); } /// @@ -43,44 +47,6 @@ private static IEnumerable GetLoadableTypes(Assembly assembly) } } - /// - /// Calls through reflection: services.Configure<JsonOptions>(options => configureJson(options));. - /// Can be used from netstandard. - /// - /// Services. - /// Action to configure in JsonOptions. - public static void ConfigureJsonOptionsForAspNetCore(this IServiceCollection services, Action configureJson) - { - Action configureJsonOptionsUntyped = options => - { - PropertyInfo? propertyInfo = options.GetType().GetProperty("JsonSerializerOptions"); - - if (propertyInfo?.GetValue(options) is JsonSerializerOptions jsonSerializerOptions) - { - configureJson(jsonSerializerOptions); - } - }; - - Type? jsonOptionsType = GetByFullName("Microsoft.AspNetCore.Mvc.JsonOptions"); - if (jsonOptionsType != null) - { - Type? extensionsType = GetByFullName("Microsoft.Extensions.DependencyInjection.OptionsServiceCollectionExtensions"); - - MethodInfo? configureMethodGeneric = extensionsType - ?.GetTypeInfo() - .DeclaredMethods - .FirstOrDefault(info => info.Name == "Configure" && info.GetParameters().Length == 2); - - MethodInfo? configureMethod = configureMethodGeneric?.MakeGenericMethod(jsonOptionsType); - - if (configureMethod != null) - { - // services.Configure(options => configureJson(options)); - configureMethod.Invoke(services, new object?[] { services, configureJsonOptionsUntyped }); - } - } - } - /// /// Gets from JsonOptions registered in AspNetCore. /// Uses reflection to call code: diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCoreSchemaProvider.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCoreSchemaProvider.cs index e594b93..d254c0f 100644 --- a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCoreSchemaProvider.cs +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCoreSchemaProvider.cs @@ -5,6 +5,8 @@ using System.Threading; using MicroElements.OpenApi.FluentValidation; using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; #if !OPENAPI_V2 using Microsoft.OpenApi.Models; #endif @@ -19,14 +21,17 @@ namespace MicroElements.AspNetCore.OpenApi.FluentValidation internal class AspNetCoreSchemaProvider : ISchemaProvider { private readonly OpenApiSchemaTransformerContext? _context; + private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// Optional transformer context (used for GetOrCreateSchemaAsync on .NET 10+). - public AspNetCoreSchemaProvider(OpenApiSchemaTransformerContext? context = null) + /// Optional logger. + public AspNetCoreSchemaProvider(OpenApiSchemaTransformerContext? context = null, ILogger? logger = null) { _context = context; + _logger = logger ?? NullLogger.Instance; } /// @@ -45,9 +50,9 @@ public OpenApiSchema GetSchemaForType(Type type) return _context.GetOrCreateSchemaAsync(type, null, CancellationToken.None) .GetAwaiter().GetResult(); } - catch (Exception) + catch (Exception ex) { - // Fallback to empty schema if sub-schema resolution fails. + _logger.LogWarning(ex, "GetOrCreateSchemaAsync failed for type '{SchemaType}'. Falling back to empty schema.", type); return new OpenApiSchema(); } } diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationSchemaTransformer.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationSchemaTransformer.cs index 83a7054..f873a10 100644 --- a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationSchemaTransformer.cs +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationSchemaTransformer.cs @@ -96,7 +96,7 @@ public Task TransformAsync( var allSchemas = new List(); ProcessAllSchemas(schema, allSchemas); - var schemaProvider = new AspNetCoreSchemaProvider(context); + var schemaProvider = new AspNetCoreSchemaProvider(context, _logger); foreach (var validator in validators) { diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/MicroElements.AspNetCore.OpenApi.FluentValidation.csproj b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/MicroElements.AspNetCore.OpenApi.FluentValidation.csproj index c86e856..dcea95f 100644 --- a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/MicroElements.AspNetCore.OpenApi.FluentValidation.csproj +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/MicroElements.AspNetCore.OpenApi.FluentValidation.csproj @@ -22,11 +22,11 @@ - + - + From 8b11a86a99b7480e76723f0dd77ae74015a39fdb Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Mon, 23 Feb 2026 14:42:11 +0300 Subject: [PATCH 4/4] Fix remaining CI review findings (5, 6, 9) for AspNetCore.OpenApi package - Finding 5: Add comment explaining TryAdd guard on bare JsonSerializerOptions - Finding 6: Add to ProcessAllSchemas noting one-level-deep traversal - Finding 9: Add namespace preservation comment to copied compatibility files Co-Authored-By: @claude --- .../AspNetCore/ServiceCollectionExtensions.cs | 4 ++++ .../FluentValidationSchemaTransformer.cs | 9 +++++++++ .../OpenApi/OpenApiExtensions.cs | 2 ++ .../OpenApi/OpenApiSchemaCompatibility.cs | 2 ++ 4 files changed, 17 insertions(+) diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs index 76fb263..c5f0566 100644 --- a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs @@ -37,6 +37,10 @@ public static IServiceCollection AddFluentValidationRulesToOpenApi( // Register JsonSerializerOptions (reference to Microsoft.AspNetCore.Mvc.JsonOptions.Value) services.TryAddTransient(provider => new AspNetJsonSerializerOptions(provider.GetJsonSerializerOptionsOrDefault())); + + // TryAdd ensures we only register a bare JsonSerializerOptions fallback when the host + // hasn't already registered one (e.g. via AddControllers or AddJsonOptions). + // This keeps the library non-intrusive while still providing a default for minimal APIs. services.TryAddTransient(provider => provider.GetService()?.Value!); // Adds name resolver. For example when property name in schema differs from property name in dotnet class. diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationSchemaTransformer.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationSchemaTransformer.cs index f873a10..7c2be7d 100644 --- a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationSchemaTransformer.cs +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationSchemaTransformer.cs @@ -134,6 +134,15 @@ public Task TransformAsync( return Task.CompletedTask; } + /// + /// Collects the root schema and any embedded schemas from AllOf/OneOf/AnyOf into . + /// + /// + /// Traversal is one level deep: only direct children of AllOf/OneOf/AnyOf that contain + /// properties are included. Deeply nested composition hierarchies are not recursed. + /// This is sufficient for the polymorphic/inheritance models produced by Microsoft.AspNetCore.OpenApi + /// but may need to be extended if deeper nesting is encountered. + /// private static void ProcessAllSchemas(OpenApiSchema schema, List schemas) { schemas.Add(schema); diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApi/OpenApiExtensions.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApi/OpenApiExtensions.cs index d59ad4b..d76fafe 100644 --- a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApi/OpenApiExtensions.cs +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApi/OpenApiExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) MicroElements. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +// Copied from Swashbuckle package; namespace preserved for Phase 2 (v7.2) unification. + using System; using System.Linq.Expressions; using System.Reflection; diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs index d3d9f5c..e2ba844 100644 --- a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs @@ -1,6 +1,8 @@ // Copyright (c) MicroElements. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +// Copied from Swashbuckle package; namespace preserved for Phase 2 (v7.2) unification. + using System; using System.Collections.Generic; using System.Globalization;