Skip to content

Commit 0554d6c

Browse files
authored
fix: enable validation rules to also be placed on classes (#1128)
fixes #1122
1 parent feaad9f commit 0554d6c

4 files changed

Lines changed: 116 additions & 13 deletions

File tree

docs/docs/operator/building-blocks/entities.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ public class EntitySpec
137137
- `[RangeMinimum]` and `[RangeMaximum]`: Defines numeric value ranges
138138
- `[MultipleOf]`: Specifies that a number must be a multiple of a given value
139139
- `[Items]`: Defines minimum and maximum items for arrays
140-
- `[ValidationRule]`: Defines custom validation rules using CEL expressions
140+
- `[ValidationRule]`: Defines custom validation rules using CEL expressions. Can be applied to properties and to class types — in both cases it emits `x-kubernetes-validations` on the corresponding schema node. When applied to both a class and a property of that type, the rules are merged (class rules first, then property rules)
141141

142142
### Documentation Attributes
143143

src/KubeOps.Abstractions/Entities/Attributes/ValidationRuleAttribute.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ namespace KubeOps.Abstractions.Entities.Attributes;
3737
/// <param name="reason">
3838
/// reason provides a machine-readable validation failure reason that is returned to the caller when a request fails this validation rule.
3939
/// </param>
40-
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
40+
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Class, AllowMultiple = true)]
4141
public sealed class ValidationRuleAttribute(
4242
string rule,
4343
string? fieldPath = null,

src/KubeOps.Transpiler/Crds.cs

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ public static V1CustomResourceDefinition Transpile(this MetadataLoadContext cont
111111
{ Count: > 0 } list => list,
112112
_ => null,
113113
},
114+
XKubernetesValidations = context.MapValidationRules(
115+
type.GetCustomAttributesData<ValidationRuleAttribute>()),
114116
},
115117
};
116118

@@ -309,18 +311,12 @@ private static V1JSONSchemaProps Map(this MetadataLoadContext context, PropertyI
309311
props.Properties = null;
310312
}
311313

312-
if (prop.GetCustomAttributesData<ValidationRuleAttribute>().ToArray() is { Length: > 0 } validations)
314+
var propValidations = context.MapValidationRules(prop.GetCustomAttributesData<ValidationRuleAttribute>());
315+
if (propValidations != null)
313316
{
314-
props.XKubernetesValidations = validations
315-
.Select(validation => new V1ValidationRule
316-
{
317-
Rule = validation.GetCustomAttributeCtorArg<string>(context, 0),
318-
FieldPath = validation.GetCustomAttributeCtorArg<string?>(context, 1),
319-
Message = validation.GetCustomAttributeCtorArg<string?>(context, 2),
320-
MessageExpression = validation.GetCustomAttributeCtorArg<string?>(context, 3),
321-
Reason = validation.GetCustomAttributeCtorArg<string?>(context, 4),
322-
})
323-
.ToList();
317+
props.XKubernetesValidations = props.XKubernetesValidations is { } existing
318+
? [.. existing, .. propValidations]
319+
: propValidations;
324320
}
325321

326322
return props;
@@ -411,6 +407,24 @@ static Type GetRootBaseType(Type type)
411407
};
412408
}
413409

410+
private static List<V1ValidationRule>? MapValidationRules(
411+
this MetadataLoadContext context,
412+
IEnumerable<CustomAttributeData> attributesData)
413+
{
414+
var rules = attributesData
415+
.Select(v => new V1ValidationRule
416+
{
417+
Rule = v.GetCustomAttributeCtorArg<string>(context, 0),
418+
FieldPath = v.GetCustomAttributeCtorArg<string?>(context, 1),
419+
Message = v.GetCustomAttributeCtorArg<string?>(context, 2),
420+
MessageExpression = v.GetCustomAttributeCtorArg<string?>(context, 3),
421+
Reason = v.GetCustomAttributeCtorArg<string?>(context, 4),
422+
})
423+
.ToList();
424+
425+
return rules.Count > 0 ? rules : null;
426+
}
427+
414428
private static IList<object> GetEnumNames(this MetadataLoadContext context, Type type)
415429
{
416430
#if NET9_0_OR_GREATER
@@ -484,6 +498,8 @@ private static V1JSONSchemaProps MapObjectType(this MetadataLoadContext context,
484498
},
485499
XKubernetesPreserveUnknownFields =
486500
type.GetCustomAttributeData<PreserveUnknownFieldsAttribute>() != null ? true : null,
501+
XKubernetesValidations = context.MapValidationRules(
502+
type.GetCustomAttributesData<ValidationRuleAttribute>()),
487503
};
488504
}
489505
}

test/KubeOps.Transpiler.Test/Crds.Mlc.ValidationRule.Test.cs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,53 @@ public void Should_Omit_Validations()
6464
specProperties.XKubernetesValidations.Should().BeNull();
6565
}
6666

67+
[Fact]
68+
public void Should_Set_Validations_On_Root_Class()
69+
{
70+
var crd = _mlc.Transpile(typeof(RootClassValidateAttrEntity));
71+
72+
var schema = crd.Spec.Versions[0].Schema.OpenAPIV3Schema;
73+
schema.XKubernetesValidations.Should().HaveCount(1);
74+
schema.XKubernetesValidations[0].Rule.Should().Be(Rule1);
75+
schema.XKubernetesValidations[0].Message.Should().Be(Message1);
76+
schema.XKubernetesValidations[0].MessageExpression.Should().BeNull();
77+
schema.XKubernetesValidations[0].FieldPath.Should().BeNull();
78+
schema.XKubernetesValidations[0].Reason.Should().BeNull();
79+
}
80+
81+
[Fact]
82+
public void Should_Set_Multiple_Validations_On_Root_Class()
83+
{
84+
var crd = _mlc.Transpile(typeof(MultiRootClassValidateAttrEntity));
85+
86+
var schema = crd.Spec.Versions[0].Schema.OpenAPIV3Schema;
87+
schema.XKubernetesValidations.Should().HaveCount(2);
88+
schema.XKubernetesValidations[0].Rule.Should().Be(Rule1);
89+
schema.XKubernetesValidations[1].Rule.Should().Be(Rule2);
90+
}
91+
92+
[Fact]
93+
public void Should_Set_Validations_On_Nested_Class()
94+
{
95+
var crd = _mlc.Transpile(typeof(NestedClassValidateAttrEntity));
96+
97+
var nestedSchema = crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["nested"];
98+
nestedSchema.XKubernetesValidations.Should().HaveCount(1);
99+
nestedSchema.XKubernetesValidations[0].Rule.Should().Be(Rule1);
100+
nestedSchema.XKubernetesValidations[0].Message.Should().Be(Message1);
101+
}
102+
103+
[Fact]
104+
public void Should_Merge_Class_And_Property_Validations()
105+
{
106+
var crd = _mlc.Transpile(typeof(ClassAndPropertyValidateAttrEntity));
107+
108+
var nestedSchema = crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["nested"];
109+
nestedSchema.XKubernetesValidations.Should().HaveCount(2);
110+
nestedSchema.XKubernetesValidations[0].Rule.Should().Be(Rule1);
111+
nestedSchema.XKubernetesValidations[1].Rule.Should().Be(Rule2);
112+
}
113+
67114
[Fact]
68115
public void Should_Set_ValidationFields()
69116
{
@@ -86,6 +133,46 @@ public sealed class NoValidateAttrEntity : CustomKubernetesEntity
86133
public string Property { get; set; } = null!;
87134
}
88135

136+
[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
137+
[ValidationRule(Rule1, message: Message1)]
138+
public sealed class RootClassValidateAttrEntity : CustomKubernetesEntity
139+
{
140+
public string Property { get; set; } = null!;
141+
}
142+
143+
[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
144+
[ValidationRule(Rule1, message: Message1)]
145+
[ValidationRule(Rule2, message: Message2)]
146+
public sealed class MultiRootClassValidateAttrEntity : CustomKubernetesEntity
147+
{
148+
public string Property { get; set; } = null!;
149+
}
150+
151+
[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
152+
public sealed class NestedClassValidateAttrEntity : CustomKubernetesEntity
153+
{
154+
public NestedObject Nested { get; set; } = new();
155+
156+
[ValidationRule(Rule1, message: Message1)]
157+
public sealed class NestedObject
158+
{
159+
public string Name { get; set; } = null!;
160+
}
161+
}
162+
163+
[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
164+
public sealed class ClassAndPropertyValidateAttrEntity : CustomKubernetesEntity
165+
{
166+
[ValidationRule(Rule2, message: Message2)]
167+
public NestedWithClassRule Nested { get; set; } = new();
168+
169+
[ValidationRule(Rule1, message: Message1)]
170+
public sealed class NestedWithClassRule
171+
{
172+
public string Name { get; set; } = null!;
173+
}
174+
}
175+
89176
[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
90177
public sealed class SingleValidateAttrEntity : CustomKubernetesEntity
91178
{

0 commit comments

Comments
 (0)