Skip to content

Commit 634c989

Browse files
author
vp
committed
feat: Implement pipeline context validation with FluentValidation integration
- Added `PipelineContextValidationGenerationModel` and `PipelineContextValidationGenerationModelBuilder` to handle validation metadata for pipeline contexts. - Introduced `PipelineContextValidationSourceEmitter` and `PipelineContextValidationSourceGenerator` to generate FluentValidation validators for pipeline contexts. - Created diagnostics for validation generation, including checks for context type validity and validation method signatures. - Implemented `IPipelineContextValidationInvoker` and `PipelineContextValidationInvoker` to execute validation for pipeline contexts. - Added unit tests for validation execution and generator functionality, ensuring proper validation behavior and error reporting. - Updated existing code to integrate new validation features and ensure compatibility with FluentValidation.
1 parent aa63552 commit 634c989

18 files changed

Lines changed: 1340 additions & 33 deletions

docs/features-pipelines.md

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,15 @@ There are three main ways to define pipelines:
9999
Packaged definitions are a good default when a pipeline is reusable or belongs clearly to one feature.
100100

101101
```csharp
102-
public sealed class OrderImportContext : PipelineContextBase
102+
public partial class OrderImportContext : PipelineContextBase
103103
{
104+
[ValidateNotEmpty("Source file name is required.")]
104105
public string SourceFileName { get; set; }
105106

106107
public int ImportedOrderCount { get; set; }
107108
}
108109

109-
public sealed class OrderImportPipeline : PipelineDefinition<OrderImportContext>
110+
public class OrderImportPipeline : PipelineDefinition<OrderImportContext>
110111
{
111112
protected override void Configure(IPipelineDefinitionBuilder<OrderImportContext> builder)
112113
{
@@ -120,7 +121,7 @@ public sealed class OrderImportPipeline : PipelineDefinition<OrderImportContext>
120121
}
121122
}
122123

123-
public sealed class ValidateOrderImportStep : PipelineStep<OrderImportContext>
124+
public class ValidateOrderImportStep : PipelineStep<OrderImportContext>
124125
{
125126
protected override PipelineControl Execute(
126127
OrderImportContext context,
@@ -137,7 +138,7 @@ public sealed class ValidateOrderImportStep : PipelineStep<OrderImportContext>
137138
}
138139
}
139140

140-
public sealed class LoadOrdersStep : AsyncPipelineStep<OrderImportContext>
141+
public class LoadOrdersStep : AsyncPipelineStep<OrderImportContext>
141142
{
142143
protected override async ValueTask<PipelineControl> ExecuteAsync(
143144
OrderImportContext context,
@@ -153,6 +154,34 @@ public sealed class LoadOrdersStep : AsyncPipelineStep<OrderImportContext>
153154
}
154155
```
155156

157+
### Context Validation
158+
159+
When the context type declares validation attributes or a static `[Validate]` method, the pipeline engine validates the context before hooks, behaviors, and steps run.
160+
161+
```csharp
162+
public partial class OrderImportContext : PipelineContextBase
163+
{
164+
[ValidateNotEmpty("Source file name is required.")]
165+
public string SourceFileName { get; set; }
166+
167+
public int ImportedOrderCount { get; set; }
168+
169+
[Validate]
170+
private static void Validate(InlineValidator<OrderImportContext> validator)
171+
{
172+
validator.RuleFor(x => x.ImportedOrderCount).GreaterThanOrEqualTo(0);
173+
}
174+
}
175+
```
176+
177+
Add the code-generation analyzer package to the project that contains the pipeline context:
178+
179+
```xml
180+
<PackageReference Include="BridgingIT.DevKit.Common.Utilities.CodeGen"
181+
Version="x.y.z"
182+
PrivateAssets="all" />
183+
```
184+
156185
### Authoring and Registration Options
157186

158187
```mermaid
@@ -363,7 +392,7 @@ sequenceDiagram
363392
Use class-based steps for reusable or non-trivial workflow logic.
364393

365394
```csharp
366-
public sealed class PersistOrdersStep : AsyncPipelineStep<OrderImportContext>
395+
public class PersistOrdersStep : AsyncPipelineStep<OrderImportContext>
367396
{
368397
protected override async ValueTask<PipelineControl> ExecuteAsync(
369398
OrderImportContext context,
@@ -492,7 +521,7 @@ Use hooks for:
492521
- step lifecycle observation
493522

494523
```csharp
495-
public sealed class PipelineAuditHook : PipelineHook<PipelineContextBase>
524+
public class PipelineAuditHook : PipelineHook<PipelineContextBase>
496525
{
497526
public override ValueTask OnPipelineStartingAsync(
498527
PipelineContextBase context,
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// MIT-License
2+
// Copyright BridgingIT GmbH - All Rights Reserved
3+
// Use of this source code is governed by an MIT-style license that can be
4+
// found in the LICENSE file at https://github.com/bridgingit/bitdevkit/license
5+
6+
namespace BridgingIT.DevKit.Common;
7+
8+
using System.Collections.Immutable;
9+
using Microsoft.CodeAnalysis;
10+
11+
/// <summary>
12+
/// Represents the validated source-generation model for a pipeline context validator.
13+
/// </summary>
14+
public sealed class PipelineContextValidationGenerationModel(
15+
INamedTypeSymbol classSymbol,
16+
IMethodSymbol validateMethod,
17+
ImmutableArray<ValidationPropertyRuleModel> propertyValidationRules,
18+
string namespaceName,
19+
string accessibilityKeyword)
20+
{
21+
/// <summary>
22+
/// Gets the authored pipeline context type.
23+
/// </summary>
24+
public INamedTypeSymbol ClassSymbol { get; } = classSymbol;
25+
26+
/// <summary>
27+
/// Gets the optional explicit <c>[Validate]</c> method.
28+
/// </summary>
29+
public IMethodSymbol ValidateMethod { get; } = validateMethod;
30+
31+
/// <summary>
32+
/// Gets the property-validation rules inferred from validation attributes.
33+
/// </summary>
34+
public ImmutableArray<ValidationPropertyRuleModel> PropertyValidationRules { get; } = propertyValidationRules;
35+
36+
/// <summary>
37+
/// Gets the namespace of the authored pipeline context.
38+
/// </summary>
39+
public string NamespaceName { get; } = namespaceName;
40+
41+
/// <summary>
42+
/// Gets the generated accessibility keyword mirrored from the authored pipeline context.
43+
/// </summary>
44+
public string AccessibilityKeyword { get; } = accessibilityKeyword;
45+
46+
/// <summary>
47+
/// Gets a value indicating whether the generated partial class should also include the <c>sealed</c> modifier.
48+
/// </summary>
49+
public bool IsSealed { get; } = classSymbol.IsSealed;
50+
51+
/// <summary>
52+
/// Gets the fully qualified pipeline-context type name.
53+
/// </summary>
54+
public string ContextTypeName => this.ClassSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
55+
56+
/// <summary>
57+
/// Gets a value indicating whether any generated validation metadata exists for the pipeline context.
58+
/// </summary>
59+
public bool HasValidation => this.ValidateMethod is not null || this.PropertyValidationRules.Length > 0;
60+
}
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
// MIT-License
2+
// Copyright BridgingIT GmbH - All Rights Reserved
3+
// Use of this source code is governed by an MIT-style license that can be
4+
// found in the LICENSE file at https://github.com/bridgingit/bitdevkit/license
5+
6+
namespace BridgingIT.DevKit.Common;
7+
8+
using Microsoft.CodeAnalysis;
9+
using Microsoft.CodeAnalysis.CSharp.Syntax;
10+
11+
/// <summary>
12+
/// Builds validated semantic models for pipeline-context validation source generation.
13+
/// </summary>
14+
public static class PipelineContextValidationGenerationModelBuilder
15+
{
16+
private const string PipelineContextBaseName = "BridgingIT.DevKit.Common.PipelineContextBase";
17+
private const string ValidateAttributeName = "BridgingIT.DevKit.Common.ValidateAttribute";
18+
private const string InlineValidatorOfTName = "FluentValidation.InlineValidator`1";
19+
20+
/// <summary>
21+
/// Returns the context type symbol when the current syntax node is a pipeline-context validation candidate.
22+
/// </summary>
23+
/// <param name="context">The generator syntax context for the candidate node.</param>
24+
/// <returns>The pipeline-context type symbol, or <see langword="null"/> when the node is not a candidate.</returns>
25+
public static INamedTypeSymbol GetCandidate(GeneratorSyntaxContext context)
26+
{
27+
if (context.Node is not ClassDeclarationSyntax classDeclaration)
28+
{
29+
return null;
30+
}
31+
32+
if (context.SemanticModel.GetDeclaredSymbol(classDeclaration) is not INamedTypeSymbol classSymbol)
33+
{
34+
return null;
35+
}
36+
37+
return InheritsFrom(classSymbol, context.SemanticModel.Compilation.GetTypeByMetadataName(PipelineContextBaseName))
38+
? classSymbol
39+
: null;
40+
}
41+
42+
/// <summary>
43+
/// Creates a validated generation model for a pipeline context with source-generated validation metadata.
44+
/// </summary>
45+
/// <param name="context">The source-production context used to report diagnostics.</param>
46+
/// <param name="compilation">The current compilation.</param>
47+
/// <param name="classSymbol">The pipeline-context type candidate.</param>
48+
/// <returns>A generation model when the context is valid; otherwise <see langword="null"/>.</returns>
49+
public static PipelineContextValidationGenerationModel Create(
50+
SourceProductionContext context,
51+
Compilation compilation,
52+
INamedTypeSymbol classSymbol)
53+
{
54+
if (!IsValidContextType(context, compilation, classSymbol))
55+
{
56+
return null;
57+
}
58+
59+
if (!TryGetValidateMethod(context, compilation, classSymbol, out var validateMethod))
60+
{
61+
return null;
62+
}
63+
64+
if (!ValidationGenerationModelBuilder.TryCreate(
65+
context,
66+
classSymbol,
67+
PipelineContextValidationSourceGeneratorDiagnostics.ValidationGeneration,
68+
out var propertyValidationRules))
69+
{
70+
return null;
71+
}
72+
73+
if (validateMethod is null && propertyValidationRules.Length == 0)
74+
{
75+
return null;
76+
}
77+
78+
if (HasTypeNameCollision(classSymbol, "Validator"))
79+
{
80+
context.ReportDiagnostic(Diagnostic.Create(
81+
PipelineContextValidationSourceGeneratorDiagnostics.GeneratedNameCollision,
82+
classSymbol.Locations.FirstOrDefault(),
83+
classSymbol.Name,
84+
"Validator"));
85+
return null;
86+
}
87+
88+
return new PipelineContextValidationGenerationModel(
89+
classSymbol,
90+
validateMethod,
91+
propertyValidationRules,
92+
classSymbol.ContainingNamespace.IsGlobalNamespace ? string.Empty : classSymbol.ContainingNamespace.ToDisplayString(),
93+
RequesterGeneratorSymbolHelper.GetAccessibilityKeyword(classSymbol));
94+
}
95+
96+
private static bool IsValidContextType(SourceProductionContext context, Compilation compilation, INamedTypeSymbol classSymbol)
97+
{
98+
if (classSymbol.ContainingType is not null)
99+
{
100+
context.ReportDiagnostic(Diagnostic.Create(
101+
PipelineContextValidationSourceGeneratorDiagnostics.NestedContextsNotSupported,
102+
classSymbol.Locations.FirstOrDefault(),
103+
classSymbol.Name));
104+
return false;
105+
}
106+
107+
if (classSymbol.Arity > 0)
108+
{
109+
context.ReportDiagnostic(Diagnostic.Create(
110+
PipelineContextValidationSourceGeneratorDiagnostics.GenericContextsNotSupported,
111+
classSymbol.Locations.FirstOrDefault(),
112+
classSymbol.Name));
113+
return false;
114+
}
115+
116+
if (classSymbol.IsStatic || classSymbol.IsAbstract ||
117+
!InheritsFrom(classSymbol, compilation.GetTypeByMetadataName(PipelineContextBaseName)))
118+
{
119+
context.ReportDiagnostic(Diagnostic.Create(
120+
PipelineContextValidationSourceGeneratorDiagnostics.InvalidAttributedType,
121+
classSymbol.Locations.FirstOrDefault(),
122+
classSymbol.Name));
123+
return false;
124+
}
125+
126+
if (!IsPartial(classSymbol))
127+
{
128+
context.ReportDiagnostic(Diagnostic.Create(
129+
PipelineContextValidationSourceGeneratorDiagnostics.ContextMustBePartial,
130+
classSymbol.Locations.FirstOrDefault(),
131+
classSymbol.Name));
132+
return false;
133+
}
134+
135+
return true;
136+
}
137+
138+
private static bool TryGetValidateMethod(
139+
SourceProductionContext context,
140+
Compilation compilation,
141+
INamedTypeSymbol classSymbol,
142+
out IMethodSymbol validateMethod)
143+
{
144+
var validateMethods = classSymbol.GetMembers()
145+
.OfType<IMethodSymbol>()
146+
.Where(static method => method.GetAttributes().Any(attribute => attribute.AttributeClass?.ToDisplayString() == ValidateAttributeName))
147+
.ToArray();
148+
149+
validateMethod = null;
150+
if (validateMethods.Length == 0)
151+
{
152+
return true;
153+
}
154+
155+
if (validateMethods.Length > 1)
156+
{
157+
context.ReportDiagnostic(Diagnostic.Create(
158+
PipelineContextValidationSourceGeneratorDiagnostics.DuplicateValidateMethod,
159+
validateMethods[1].Locations.FirstOrDefault() ?? classSymbol.Locations.FirstOrDefault(),
160+
classSymbol.Name));
161+
return false;
162+
}
163+
164+
var method = validateMethods[0];
165+
if (!method.IsStatic)
166+
{
167+
context.ReportDiagnostic(Diagnostic.Create(
168+
PipelineContextValidationSourceGeneratorDiagnostics.ValidateMethodMustBeStatic,
169+
method.Locations.FirstOrDefault(),
170+
method.Name));
171+
return false;
172+
}
173+
174+
if (!method.ReturnsVoid)
175+
{
176+
context.ReportDiagnostic(Diagnostic.Create(
177+
PipelineContextValidationSourceGeneratorDiagnostics.InvalidValidateReturnType,
178+
method.Locations.FirstOrDefault(),
179+
method.Name));
180+
return false;
181+
}
182+
183+
if (method.Parameters.Length != 1 || method.Parameters[0].RefKind != RefKind.None)
184+
{
185+
context.ReportDiagnostic(Diagnostic.Create(
186+
PipelineContextValidationSourceGeneratorDiagnostics.InvalidValidateParameter,
187+
method.Locations.FirstOrDefault(),
188+
method.Name,
189+
classSymbol.Name));
190+
return false;
191+
}
192+
193+
var inlineValidatorType = compilation.GetTypeByMetadataName(InlineValidatorOfTName);
194+
var parameterType = method.Parameters[0].Type as INamedTypeSymbol;
195+
if (parameterType is null ||
196+
!parameterType.IsGenericType ||
197+
!SymbolEqualityComparer.Default.Equals(parameterType.OriginalDefinition, inlineValidatorType) ||
198+
!SymbolEqualityComparer.Default.Equals(parameterType.TypeArguments[0], classSymbol))
199+
{
200+
context.ReportDiagnostic(Diagnostic.Create(
201+
PipelineContextValidationSourceGeneratorDiagnostics.InvalidValidateParameter,
202+
method.Locations.FirstOrDefault(),
203+
method.Name,
204+
classSymbol.Name));
205+
return false;
206+
}
207+
208+
validateMethod = method;
209+
return true;
210+
}
211+
212+
private static bool HasTypeNameCollision(INamedTypeSymbol classSymbol, string typeName)
213+
{
214+
return classSymbol.GetTypeMembers(typeName).Length > 0;
215+
}
216+
217+
private static bool InheritsFrom(INamedTypeSymbol symbol, INamedTypeSymbol baseType)
218+
{
219+
for (var current = symbol; current is not null; current = current.BaseType)
220+
{
221+
if (SymbolEqualityComparer.Default.Equals(current, baseType))
222+
{
223+
return true;
224+
}
225+
}
226+
227+
return false;
228+
}
229+
230+
private static bool IsPartial(INamedTypeSymbol classSymbol)
231+
{
232+
return classSymbol.DeclaringSyntaxReferences
233+
.Select(static reference => reference.GetSyntax())
234+
.OfType<ClassDeclarationSyntax>()
235+
.All(static declaration => declaration.Modifiers.Any(modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PartialKeyword)));
236+
}
237+
}

0 commit comments

Comments
 (0)