Skip to content

Commit ee1ea4f

Browse files
authored
feat: add support for Title, PropertyLimits, UniqueItems, XListType, XMapType, and XListMapKeys attributes (#1160)
This PR adds support for several `JSONSchemaProps` fields that were previously missing from the CRD transpiler, narrowing the gap between what Kubernetes supports and what can be expressed via C# attributes. New attributes: | Attribute | JSONSchemaProps field | Notes | |---|---|---| | `[Title]` | `title` | String, property or class level | | `[PropertyLimits]` | `minProperties` / `maxProperties` | Long, uses `-1` sentinel like `[Length]` | | `[UniqueItems]` | `uniqueItems` | Marker attribute (bool) | | `[XListType]` | `x-kubernetes-list-type` | Enum: `Atomic`, `Set`, `Map` | | `[XMapType]` | `x-kubernetes-map-type` | Enum: `Granular`, `Atomic` | | `[XListMapKeys]` | `x-kubernetes-list-map-keys` | `params string[]` | The remaining unmapped fields (`default`, `example`, `allOf`/`oneOf`/`anyOf`/`not`, `patternProperties`, `dependencies`, `definitions`, `additionalItems`) require recursive schema structures or arbitrary JSON, which can't be expressed through C# attributes. Closes #943
1 parent 9c9752e commit ee1ea4f

9 files changed

Lines changed: 438 additions & 2 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,14 @@ 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+
- `[UniqueItems]`: Marks an array property as requiring unique items (`uniqueItems: true`)
141+
- `[PropertyLimits]`: Specifies minimum and maximum number of properties for an object
140142
- `[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). Class-level rules are also collected across the **class inheritance chain**: a rule placed on a base class is inherited by every derived entity or nested type, and all rules found along the chain are merged onto the schema node
141143

142144
### Documentation Attributes
143145

144146
- `[Description]`: Adds a description to a property or entity
147+
- `[Title]`: Adds a title to a property or entity
145148
- `[ExternalDocs]`: Links to external documentation
146149

147150
### Display Attributes
@@ -154,6 +157,9 @@ public class EntitySpec
154157
- `[Ignore]`: Excludes a property or entity from CRD generation
155158
- `[PreserveUnknownFields]`: Allows unknown fields on the annotated object (`x-kubernetes-preserve-unknown-fields: true`). The known fields are still transpiled and validated, so you keep a structural schema for what you model while permitting extra fields. Works the same whether placed on a property or on a class/type: if the type cannot be represented (it contains a circular reference or an otherwise non-transpilable member), it gracefully falls back to an opaque `type: object` with `x-kubernetes-preserve-unknown-fields: true` instead of failing — making this the recommended way to model complex, externally generated, or self-referencing types.
156159
- `[EmbeddedResource]`: Marks a property as an embedded Kubernetes resource. The property type is never traversed; the schema is always an opaque embedded `type: object`.
160+
- `[XListType]`: Annotates an array to describe its topology (`XListType.Atomic`, `XListType.Set`, or `XListType.Map`). Maps to `x-kubernetes-list-type`.
161+
- `[XMapType]`: Annotates an object to describe its topology (`XMapType.Granular` or `XMapType.Atomic`). Maps to `x-kubernetes-map-type`.
162+
- `[XListMapKeys]`: Specifies the keys used as the index for a list with `XListType.Map` (e.g., `[XListMapKeys("name", "port")]`). Maps to `x-kubernetes-list-map-keys`.
157163

158164
:::note
159165
The CRD transpiler maps property types recursively. A **circular type reference** that is not opted out via `[PreserveUnknownFields]` or `[Ignore]` cannot be represented as a finite OpenAPI schema and raises a descriptive `TranspilationFailedException` during generation. Annotate the offending property or type with `[PreserveUnknownFields]` or `[Ignore]`, or restructure the type to remove the cycle.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace KubeOps.Abstractions.Entities.Attributes;
6+
7+
/// <summary>
8+
/// Defines property count limits for object properties.
9+
/// </summary>
10+
[AttributeUsage(AttributeTargets.Property)]
11+
public class PropertyLimitsAttribute(long minProperties = -1, long maxProperties = -1) : Attribute
12+
{
13+
/// <summary>
14+
/// Define the minimum number of properties.
15+
/// </summary>
16+
public long? MinProperties => minProperties switch
17+
{
18+
-1 => null,
19+
_ => minProperties,
20+
};
21+
22+
/// <summary>
23+
/// Define the maximum number of properties.
24+
/// </summary>
25+
public long? MaxProperties => maxProperties switch
26+
{
27+
-1 => null,
28+
_ => maxProperties,
29+
};
30+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace KubeOps.Abstractions.Entities.Attributes;
6+
7+
/// <summary>
8+
/// Defines a title for a property.
9+
/// </summary>
10+
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Class)]
11+
public class TitleAttribute(string title) : Attribute
12+
{
13+
/// <summary>
14+
/// The given title for the property.
15+
/// </summary>
16+
public string Title => title;
17+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace KubeOps.Abstractions.Entities.Attributes;
6+
7+
/// <summary>
8+
/// Defines that array items must be unique.
9+
/// </summary>
10+
[AttributeUsage(AttributeTargets.Property)]
11+
public class UniqueItemsAttribute : Attribute;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace KubeOps.Abstractions.Entities.Attributes;
6+
7+
/// <summary>
8+
/// Annotates an array with x-kubernetes-list-type "map" by specifying the keys used as the index of the map.
9+
/// </summary>
10+
[AttributeUsage(AttributeTargets.Property)]
11+
public class XListMapKeysAttribute(params string[] keys) : Attribute
12+
{
13+
/// <summary>
14+
/// The keys used as the index of the map.
15+
/// </summary>
16+
public string[] Keys => keys;
17+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace KubeOps.Abstractions.Entities.Attributes;
6+
7+
/// <summary>
8+
/// The topology type for an array property.
9+
/// </summary>
10+
public enum XListType
11+
{
12+
/// <summary>
13+
/// The list is treated as a single entity, like a scalar.
14+
/// Atomic lists will be entirely replaced when updated.
15+
/// </summary>
16+
Atomic,
17+
18+
/// <summary>
19+
/// Sets are lists that must not have multiple items with the same value.
20+
/// </summary>
21+
Set,
22+
23+
/// <summary>
24+
/// These lists are like maps in that their elements have a non-index key
25+
/// used to identify them. Order is preserved upon merge.
26+
/// </summary>
27+
Map,
28+
}
29+
30+
/// <summary>
31+
/// Annotates an array to further describe its topology.
32+
/// </summary>
33+
[AttributeUsage(AttributeTargets.Property)]
34+
public class XListTypeAttribute(XListType listType) : Attribute
35+
{
36+
/// <summary>
37+
/// The list type.
38+
/// </summary>
39+
public XListType ListType => listType;
40+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace KubeOps.Abstractions.Entities.Attributes;
6+
7+
/// <summary>
8+
/// The topology type for an object property.
9+
/// </summary>
10+
public enum XMapType
11+
{
12+
/// <summary>
13+
/// These maps are actual maps (key-value pairs) and each field is independent
14+
/// from each other. This is the default behaviour for all maps.
15+
/// </summary>
16+
Granular,
17+
18+
/// <summary>
19+
/// The map is treated as a single entity, like a scalar.
20+
/// Atomic maps will be entirely replaced when updated.
21+
/// </summary>
22+
Atomic,
23+
}
24+
25+
/// <summary>
26+
/// Annotates an object to further describe its topology.
27+
/// </summary>
28+
[AttributeUsage(AttributeTargets.Property)]
29+
public class XMapTypeAttribute(XMapType mapType) : Attribute
30+
{
31+
/// <summary>
32+
/// The map type.
33+
/// </summary>
34+
public XMapType MapType => mapType;
35+
}

src/KubeOps.Transpiler/Crds.cs

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ public static V1CustomResourceDefinition Transpile(
108108
Type = Object,
109109
Description =
110110
type.GetCustomAttributeData<DescriptionAttribute>()?.GetCustomAttributeCtorArg<string>(context, 0),
111+
Title =
112+
type.GetCustomAttributeData<TitleAttribute>()?.GetCustomAttributeCtorArg<string>(context, 0),
111113
Properties = type.GetProperties()
112114
.Where(p => !IgnoredToplevelProperties.Contains(p.Name.ToLowerInvariant())
113115
&& p.GetCustomAttributeData<IgnoreAttribute>() == null)
@@ -337,6 +339,9 @@ private static V1JSONSchemaProps Map(
337339
props.Description ??= prop.GetCustomAttributeData<DescriptionAttribute>()
338340
?.GetCustomAttributeCtorArg<string>(context, 0);
339341

342+
props.Title ??= prop.GetCustomAttributeData<TitleAttribute>()
343+
?.GetCustomAttributeCtorArg<string>(context, 0);
344+
340345
if (prop.IsNullable())
341346
{
342347
// Default to Nullable to null to avoid generating `nullable:false`
@@ -354,8 +359,11 @@ private static V1JSONSchemaProps Map(
354359

355360
if (prop.GetCustomAttributeData<ItemsAttribute>() is { } items)
356361
{
357-
props.MinItems = items.GetCustomAttributeCtorArg<long>(context, 0);
358-
props.MaxItems = items.GetCustomAttributeCtorArg<long>(context, 1);
362+
var minItems = items.GetCustomAttributeCtorArg<long>(context, 0);
363+
props.MinItems = minItems == -1 ? null : minItems;
364+
365+
var maxItems = items.GetCustomAttributeCtorArg<long>(context, 1);
366+
props.MaxItems = maxItems == -1 ? null : maxItems;
359367
}
360368

361369
if (prop.GetCustomAttributeData<LengthAttribute>() is { } length)
@@ -367,6 +375,15 @@ private static V1JSONSchemaProps Map(
367375
props.MaxLength = maxLength == -1 ? null : maxLength;
368376
}
369377

378+
if (prop.GetCustomAttributeData<PropertyLimitsAttribute>() is { } properties)
379+
{
380+
var minProperties = properties.GetCustomAttributeCtorArg<long>(context, 0);
381+
props.MinProperties = minProperties == -1 ? null : minProperties;
382+
383+
var maxProperties = properties.GetCustomAttributeCtorArg<long>(context, 1);
384+
props.MaxProperties = maxProperties == -1 ? null : maxProperties;
385+
}
386+
370387
if (prop.GetCustomAttributeData<MultipleOfAttribute>() is { } multi)
371388
{
372389
props.MultipleOf = multi.GetCustomAttributeCtorArg<double>(context, 0);
@@ -391,6 +408,28 @@ private static V1JSONSchemaProps Map(
391408
rangeMin.GetCustomAttributeCtorArg<bool>(context, 1);
392409
}
393410

411+
if (prop.GetCustomAttributeData<UniqueItemsAttribute>() is not null)
412+
{
413+
props.UniqueItems = true;
414+
}
415+
416+
if (prop.GetCustomAttributeData<XListTypeAttribute>() is { } listType)
417+
{
418+
props.XKubernetesListType = listType.GetCustomAttributeCtorArg<XListType>(context, 0)
419+
.ToString().ToLowerInvariant();
420+
}
421+
422+
if (prop.GetCustomAttributeData<XMapTypeAttribute>() is { } mapType)
423+
{
424+
props.XKubernetesMapType = mapType.GetCustomAttributeCtorArg<XMapType>(context, 0)
425+
.ToString().ToLowerInvariant();
426+
}
427+
428+
if (prop.GetCustomAttributeData<XListMapKeysAttribute>() is { } listMapKeysAttr)
429+
{
430+
props.XKubernetesListMapKeys = listMapKeysAttr.GetCustomAttributeCtorArrayArg<string>(0);
431+
}
432+
394433
if (preservesUnknownFields)
395434
{
396435
props.XKubernetesPreserveUnknownFields = true;
@@ -586,6 +625,9 @@ private static V1JSONSchemaProps MapObjectType(
586625
Description =
587626
type.GetCustomAttributeData<DescriptionAttribute>()
588627
?.GetCustomAttributeCtorArg<string>(context, 0),
628+
Title =
629+
type.GetCustomAttributeData<TitleAttribute>()
630+
?.GetCustomAttributeCtorArg<string>(context, 0),
589631
Properties = type
590632
.GetProperties()
591633
.Where(p => p.GetCustomAttributeData<IgnoreAttribute>() == null)

0 commit comments

Comments
 (0)