Skip to content

Commit 934ac5c

Browse files
authored
Add @validate() decorator for custom validation (#17804)
Resolves #2922 and resolves #16988 ###### Microsoft Reviewers: [Open in CodeFlow](https://microsoft.github.io/open-pr/?codeflow=https://github.com/Azure/bicep/pull/17804)
1 parent 9206267 commit 934ac5c

31 files changed

+629
-341
lines changed

Bicep.sln

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_SolutionItems", "_Solution
5252
CONTRIBUTING.md = CONTRIBUTING.md
5353
src\Directory.Build.props = src\Directory.Build.props
5454
src\Directory.Build.targets = src\Directory.Build.targets
55+
src\Directory.Packages.props = src\Directory.Packages.props
5556
global.json = global.json
5657
LICENSE = LICENSE
5758
README.md = README.md

docs/experimental-features.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ Allows the ARM template layer to use a new schema to represent resources as an o
7171

7272
Should be enabled in tandem with `assertions` experimental feature flag for expected functionality. Allows you to author client-side, offline unit-test test blocks that reference Bicep files and mock deployment parameters in a separate `test.bicep` file using the new `test` keyword. Test blocks can be run with the command *bicep test <filepath_to_file_with_test_blocks>* which runs all `assert` statements in the Bicep files referenced by the test blocks. For more information, see [Bicep Experimental Test Framework](https://github.com/Azure/bicep/issues/11967).
7373

74+
### `userDefinedConstraints`
75+
76+
Enables the `@validate()` decorator on types, type properties, parameters, and outputs. The decorator takes two arguments: 1) a lambda function that accepts the decorator target's value and returns a boolean indicating if the value is valid (`true`) or not (`false`), and 2) an optional error message to use if the lambda returns `false`.
77+
78+
```bicep
79+
@validate(x => startsWith(x, 'foo')) // <-- Accepts 'food' or 'fool' but causes the deployment to fail if 'booed' was supplied
80+
param p string
81+
```
82+
7483
### `waitAndRetry`
7584

7685
The feature introduces waitUntil and retryOn decorators on resource data type. waitUnitl() decorator waits for the resource until its usable based on the desired property's state. retryOn() will retry the deployment if one if the listed exception codes are encountered.

src/Bicep.Core.IntegrationTests/Decorators/DecoratorTests.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,5 +340,47 @@ public void UnfinishedDecoratorInResourceBody_ShouldPromptForNamespaceOrDecorato
340340
template.Should().BeNull();
341341
}
342342
}
343+
344+
[TestMethod]
345+
public void Constant_decorator_arguments_must_be_block_expressions()
346+
{
347+
var result = CompilationHelper.Compile(
348+
new ServiceBuilder().WithFeatureOverrides(new(TestContext, UserDefinedConstraintsEnabled: true)),
349+
"""
350+
param env 'dev'|'prod'
351+
352+
@allowed([
353+
'f${'o'}o'
354+
])
355+
@description(format('Le {0} est sur la {1}', 'singe', 'branche'))
356+
@minLength(1 + 2)
357+
@maxLength(2 + 1)
358+
@validate(x => x == 'foo', 'Must be \'${'foo'}\'.')
359+
@metadata({
360+
env: env
361+
})
362+
param foo string
363+
364+
@minValue(1 + 1)
365+
@maxValue(2 + 2)
366+
param bar int
367+
368+
@discriminator('k${'i'}n${'d'}')
369+
param baz {kind: 'a', prop: string} | {kind: 'b', prop: int}
370+
""");
371+
372+
result.ExcludingDiagnostics("no-unused-params").Should().HaveDiagnostics(
373+
[
374+
("BCP032", DiagnosticLevel.Error, "The value must be a compile-time constant."),
375+
("BCP032", DiagnosticLevel.Error, "The value must be a compile-time constant."),
376+
("BCP032", DiagnosticLevel.Error, "The value must be a compile-time constant."),
377+
("BCP032", DiagnosticLevel.Error, "The value must be a compile-time constant."),
378+
("BCP032", DiagnosticLevel.Error, "The value must be a compile-time constant."),
379+
("BCP032", DiagnosticLevel.Error, "The value must be a compile-time constant."),
380+
("BCP032", DiagnosticLevel.Error, "The value must be a compile-time constant."),
381+
("BCP032", DiagnosticLevel.Error, "The value must be a compile-time constant."),
382+
("BCP032", DiagnosticLevel.Error, "The value must be a compile-time constant."),
383+
]);
384+
}
343385
}
344386
}

src/Bicep.Core.IntegrationTests/UserDefinedTypeTests.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1889,4 +1889,72 @@ param p recursiveType
18891889

18901890
result.Should().NotHaveAnyDiagnostics();
18911891
}
1892+
1893+
[TestMethod]
1894+
public void User_defined_validators_are_blocked_if_feature_flag_is_not_enabled()
1895+
{
1896+
var result = CompilationHelper.Compile("""
1897+
@validate(x => startsWith(x, 'foo'))
1898+
param foo string
1899+
""");
1900+
1901+
result.ExcludingLinterDiagnostics().Should().HaveDiagnostics(
1902+
[
1903+
("BCP057", DiagnosticLevel.Error, "The name \"validate\" does not exist in the current context."),
1904+
]);
1905+
}
1906+
1907+
[TestMethod]
1908+
public void User_defined_validator_can_be_attached_to_a_parameter_statement()
1909+
{
1910+
var result = CompilationHelper.Compile(
1911+
new ServiceBuilder().WithFeatureOverrides(new(TestContext, UserDefinedConstraintsEnabled: true)),
1912+
"""
1913+
@validate(x => startsWith(x, 'foo'), 'Should have started with \'foo\'')
1914+
param foo string
1915+
""");
1916+
1917+
result.ExcludingLinterDiagnostics().Should().NotHaveAnyDiagnostics();
1918+
result.Template.Should().NotBeNull();
1919+
result.Template.Should().HaveJsonAtPath("$.parameters.foo.validate",
1920+
"""["[lambda('x', startsWith(lambdaVariables('x'), 'foo'))]", "Should have started with 'foo'"]""");
1921+
}
1922+
1923+
[TestMethod]
1924+
public void User_defined_validator_checks_lambda_type_against_declared_type()
1925+
{
1926+
var result = CompilationHelper.Compile(
1927+
new ServiceBuilder().WithFeatureOverrides(new(TestContext, UserDefinedConstraintsEnabled: true)),
1928+
"""
1929+
@validate(x => startsWith(x, 'foo'))
1930+
param foo int
1931+
""");
1932+
1933+
result.ExcludingLinterDiagnostics().Should().HaveDiagnostics(
1934+
[
1935+
("BCP070", DiagnosticLevel.Error, "Argument of type \"int => error\" is not assignable to parameter of type \"any => bool\"."),
1936+
]);
1937+
}
1938+
1939+
[TestMethod]
1940+
public void User_defined_validator_disallows_runtime_expressions()
1941+
{
1942+
var result = CompilationHelper.Compile(
1943+
new ServiceBuilder().WithFeatureOverrides(new(TestContext, UserDefinedConstraintsEnabled: true)),
1944+
"""
1945+
resource sa 'Microsoft.Storage/storageAccounts@2025-01-01' existing = {
1946+
name: 'acct'
1947+
}
1948+
1949+
var indirection = sa.properties.allowBlobPublicAccess
1950+
1951+
@validate(x => x == !indirection)
1952+
param foo bool
1953+
""");
1954+
1955+
result.ExcludingLinterDiagnostics().Should().HaveDiagnostics(
1956+
[
1957+
("BCP432", DiagnosticLevel.Error, "This expression is being used in parameter \"predicate\" of the function \"validate\", which requires a value that can be calculated at the start of the deployment. You are referencing a variable which cannot be calculated at the start (\"indirection\" -> \"sa\"). Properties of sa which can be calculated at the start include \"apiVersion\", \"id\", \"name\", \"type\"."),
1958+
]);
1959+
}
18921960
}

src/Bicep.Core.UnitTests/Configuration/ConfigurationManagerTests.cs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ public void GetBuiltInConfiguration_NoParameter_ReturnsBuiltInConfigurationWithA
108108
"resourceInfoCodegen": false,
109109
"desiredStateConfiguration": false,
110110
"onlyIfNotExists": false,
111-
"moduleIdentity": false
111+
"moduleIdentity": false,
112+
"userDefinedConstraints": false
112113
},
113114
"formatting": {
114115
"indentKind": "Space",
@@ -190,7 +191,8 @@ public void GetBuiltInConfiguration_DisableAllAnalyzers_ReturnsBuiltInConfigurat
190191
"moduleExtensionConfigs": false,
191192
"desiredStateConfiguration": false,
192193
"onlyIfNotExists": false,
193-
"moduleIdentity": false
194+
"moduleIdentity": false,
195+
"userDefinedConstraints": false
194196
},
195197
"formatting": {
196198
"indentKind": "Space",
@@ -294,7 +296,8 @@ public void GetBuiltInConfiguration_DisableAnalyzers_ReturnsBuiltInConfiguration
294296
"moduleExtensionConfigs": false,
295297
"desiredStateConfiguration": false,
296298
"onlyIfNotExists": false,
297-
"moduleIdentity": false
299+
"moduleIdentity": false,
300+
"userDefinedConstraints": false
298301
},
299302
"formatting": {
300303
"indentKind": "Space",
@@ -387,7 +390,8 @@ public void GetBuiltInConfiguration_EnableExperimentalFeature_ReturnsBuiltInConf
387390
ModuleExtensionConfigs: false,
388391
DesiredStateConfiguration: false,
389392
OnlyIfNotExists: false,
390-
ModuleIdentity: false);
393+
ModuleIdentity: false,
394+
UserDefinedConstraints: false);
391395

392396
configuration.WithExperimentalFeaturesEnabled(experimentalFeaturesEnabled).Should().HaveContents(/*lang=json,strict*/ """
393397
{
@@ -470,7 +474,8 @@ public void GetBuiltInConfiguration_EnableExperimentalFeature_ReturnsBuiltInConf
470474
"moduleExtensionConfigs": false,
471475
"desiredStateConfiguration": false,
472476
"onlyIfNotExists": false,
473-
"moduleIdentity": false
477+
"moduleIdentity": false,
478+
"userDefinedConstraints": false
474479
},
475480
"formatting": {
476481
"indentKind": "Space",
@@ -837,7 +842,8 @@ public void GetConfiguration_ValidCustomConfiguration_OverridesBuiltInConfigurat
837842
"moduleExtensionConfigs": false,
838843
"desiredStateConfiguration": false,
839844
"onlyIfNotExists": false,
840-
"moduleIdentity": false
845+
"moduleIdentity": false,
846+
"userDefinedConstraints": false
841847
},
842848
"formatting": {
843849
"indentKind": "Space",

src/Bicep.Core.UnitTests/Features/FeatureProviderOverrides.cs

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ public record FeatureProviderOverrides(
2525
bool? ModuleExtensionConfigsEnabled = default,
2626
bool? DesiredStateConfigurationEnabled = default,
2727
bool? OnlyIfNotExistsEnabled = default,
28-
bool? ModuleIdentityEnabled = default)
28+
bool? ModuleIdentityEnabled = default,
29+
bool? UserDefinedConstraintsEnabled = default)
2930
{
3031
public FeatureProviderOverrides(
3132
TestContext testContext,
@@ -45,25 +46,25 @@ public FeatureProviderOverrides(
4546
bool? ModuleExtensionConfigsEnabled = default,
4647
bool? DesiredStateConfigurationEnabled = default,
4748
bool? OnlyIfNotExistsEnabled = default,
48-
bool? ModuleIdentityEnabled = default
49-
) : this(
50-
FileHelper.GetCacheRootDirectory(testContext),
51-
RegistryEnabled,
52-
SymbolicNameCodegenEnabled,
53-
AdvancedListComprehensionEnabled,
54-
ResourceTypedParamsAndOutputsEnabled,
55-
SourceMappingEnabled,
56-
LegacyFormatterEnabled,
57-
TestFrameworkEnabled,
58-
AssertsEnabled,
59-
WaitAndRetryEnabled,
60-
LocalDeployEnabled,
61-
ResourceInfoCodegenEnabled,
62-
ExtendableParamFilesEnabled,
63-
AssemblyVersion,
64-
ModuleExtensionConfigsEnabled,
65-
DesiredStateConfigurationEnabled,
66-
OnlyIfNotExistsEnabled,
67-
ModuleIdentityEnabled)
68-
{ }
49+
bool? ModuleIdentityEnabled = default,
50+
bool? UserDefinedConstraintsEnabled = default) : this(
51+
FileHelper.GetCacheRootDirectory(testContext),
52+
RegistryEnabled,
53+
SymbolicNameCodegenEnabled,
54+
AdvancedListComprehensionEnabled,
55+
ResourceTypedParamsAndOutputsEnabled,
56+
SourceMappingEnabled,
57+
LegacyFormatterEnabled,
58+
TestFrameworkEnabled,
59+
AssertsEnabled,
60+
WaitAndRetryEnabled,
61+
LocalDeployEnabled,
62+
ResourceInfoCodegenEnabled,
63+
ExtendableParamFilesEnabled,
64+
AssemblyVersion,
65+
ModuleExtensionConfigsEnabled,
66+
DesiredStateConfigurationEnabled,
67+
OnlyIfNotExistsEnabled,
68+
ModuleIdentityEnabled,
69+
UserDefinedConstraintsEnabled) { }
6970
}

src/Bicep.Core.UnitTests/Features/OverriddenFeatureProvider.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,6 @@ public OverriddenFeatureProvider(IFeatureProvider features, FeatureProviderOverr
4848
public bool DesiredStateConfigurationEnabled => overrides.DesiredStateConfigurationEnabled ?? features.DesiredStateConfigurationEnabled;
4949

5050
public bool ModuleIdentityEnabled => overrides.ModuleIdentityEnabled ?? features.ModuleIdentityEnabled;
51+
52+
public bool UserDefinedConstraintsEnabled => overrides.UserDefinedConstraintsEnabled ?? features.UserDefinedConstraintsEnabled;
5153
}

src/Bicep.Core/Configuration/ExperimentalFeaturesEnabled.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ public record ExperimentalFeaturesEnabled(
2323
bool ModuleExtensionConfigs,
2424
bool DesiredStateConfiguration,
2525
bool OnlyIfNotExists,
26-
bool ModuleIdentity)
26+
bool ModuleIdentity,
27+
bool UserDefinedConstraints)
2728
{
2829
public static ExperimentalFeaturesEnabled Bind(JsonElement element)
2930
=> element.ToNonNullObject<ExperimentalFeaturesEnabled>();
@@ -44,5 +45,6 @@ public static ExperimentalFeaturesEnabled Bind(JsonElement element)
4445
ModuleExtensionConfigs: false,
4546
DesiredStateConfiguration: false,
4647
OnlyIfNotExists: false,
47-
ModuleIdentity: false);
48+
ModuleIdentity: false,
49+
UserDefinedConstraints: false);
4850
}

src/Bicep.Core/Diagnostics/DiagnosticBuilder.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1962,6 +1962,16 @@ public Diagnostic FoundFileInsteadOfDirectory(string filePath) => CoreError(
19621962
public Diagnostic InvalidModuleExtensionConfigAssignmentExpression(string propertyName) => CoreError(
19631963
"BCP431",
19641964
$"The value of the \"{propertyName}\" property must be an object literal or a valid extension config inheritance expression.");
1965+
1966+
public Diagnostic RuntimeValueNotAllowedInFunctionArgument(string? functionName, string? parameterName, string? accessedSymbolName, IEnumerable<string>? accessiblePropertyNames, IEnumerable<string>? variableDependencyChain)
1967+
{
1968+
var variableDependencyChainClause = BuildVariableDependencyChainClause(variableDependencyChain);
1969+
var accessiblePropertiesClause = BuildAccessiblePropertiesClause(accessedSymbolName, accessiblePropertyNames);
1970+
1971+
return CoreError(
1972+
"BCP432",
1973+
$"This expression is being used in parameter \"{parameterName ?? "unknown"}\" of the function \"{functionName ?? "unknown"}\", which requires a value that can be calculated at the start of the deployment.{variableDependencyChainClause}{accessiblePropertiesClause}");
1974+
}
19651975
}
19661976

19671977
public static DiagnosticBuilderInternal ForPosition(TextSpan span)

src/Bicep.Core/Emit/TemplateWriter.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ private static ObjectExpression ApplyTypeModifiers(TypeDeclaringExpression expre
246246
(expression.MaxLength, LanguageConstants.ParameterMaxLengthPropertyName),
247247
(expression.MinValue, LanguageConstants.ParameterMinValuePropertyName),
248248
(expression.MaxValue, LanguageConstants.ParameterMaxValuePropertyName),
249+
(expression.UserDefinedConstraint, LanguageConstants.ParameterUserDefinedConstraintPropertyName),
249250
})
250251
{
251252
if (modifier is not null)

0 commit comments

Comments
 (0)