Skip to content

Commit 160998b

Browse files
authored
Azure.Provisioning: Move name validation to Infrastructure and expose for Aspire (#46437)
Azure.Provisioning: Move name validation to Infrastructure and expose for Aspire
1 parent c1be034 commit 160998b

File tree

4 files changed

+146
-36
lines changed

4 files changed

+146
-36
lines changed

sdk/provisioning/Azure.Provisioning/api/Azure.Provisioning.netstandard2.0.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,12 @@ public virtual void Add(Azure.Provisioning.Primitives.Provisionable resource) {
9898
protected internal override System.Collections.Generic.IEnumerable<Azure.Provisioning.Expressions.Statement> Compile() { throw null; }
9999
protected internal System.Collections.Generic.IDictionary<string, System.Collections.Generic.IEnumerable<Azure.Provisioning.Expressions.Statement>> CompileModules(Azure.Provisioning.ProvisioningContext? context = null) { throw null; }
100100
public override System.Collections.Generic.IEnumerable<Azure.Provisioning.Primitives.Provisionable> GetResources() { throw null; }
101+
public static bool IsValidIdentifierName(string? identifierName) { throw null; }
102+
public static string NormalizeIdentifierName(string? identifierName) { throw null; }
101103
public virtual void Remove(Azure.Provisioning.Primitives.Provisionable resource) { }
102104
protected internal override void Resolve(Azure.Provisioning.ProvisioningContext? context = null) { }
103105
protected internal override void Validate(Azure.Provisioning.ProvisioningContext? context = null) { }
106+
public static void ValidateIdentifierName(string? identifierName, string? paramName = null) { }
104107
}
105108
public partial class ProvisioningContext
106109
{

sdk/provisioning/Azure.Provisioning/src/Infrastructure.cs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System;
55
using System.Collections.Generic;
66
using System.Diagnostics;
7+
using System.Text;
78
using Azure.Provisioning.Expressions;
89
using Azure.Provisioning.Primitives;
910

@@ -93,6 +94,109 @@ public virtual void Remove(Provisionable resource)
9394
}
9495
}
9596

97+
private static bool IsAsciiLetterOrDigit(char ch) =>
98+
'a' <= ch && ch <= 'z' ||
99+
'A' <= ch && ch <= 'Z' ||
100+
'0' <= ch && ch <= '9';
101+
102+
/// <summary>
103+
/// Checks whether an name is a valid bicep identifier name comprised of
104+
/// letters, digits, and underscores.
105+
/// </summary>
106+
/// <param name="identifierName">The proposed identifier name.</param>
107+
/// <returns>Whether the name is a valid bicep identifier name.</returns>
108+
public static bool IsValidIdentifierName(string? identifierName)
109+
{
110+
if (string.IsNullOrEmpty(identifierName)) { return false; }
111+
if (char.IsDigit(identifierName![0])) { return false; }
112+
foreach (char ch in identifierName)
113+
{
114+
if (!IsAsciiLetterOrDigit(ch) && ch != '_')
115+
{
116+
return false;
117+
}
118+
}
119+
return true;
120+
}
121+
122+
/// <summary>
123+
/// Validates whether a given bicep identifier name is correctly formed of
124+
/// letters, numbers, and underscores.
125+
/// </summary>
126+
/// <param name="identifierName">The proposed bicep identifier name.</param>
127+
/// <param name="paramName">Optional parameter name to use for exceptions.</param>
128+
/// <exception cref="ArgumentNullException">Throws if null.</exception>
129+
/// <exception cref="ArgumentException">Throws if empty or invalid.</exception>
130+
public static void ValidateIdentifierName(string? identifierName, string? paramName = default)
131+
{
132+
paramName ??= nameof(identifierName);
133+
if (identifierName is null)
134+
{
135+
throw new ArgumentNullException(paramName, $"{paramName} cannot be null.");
136+
}
137+
else if (identifierName.Length == 0)
138+
{
139+
throw new ArgumentException($"{paramName} cannot be empty.", paramName);
140+
}
141+
else if (char.IsDigit(identifierName[0]))
142+
{
143+
throw new ArgumentException($"{paramName} cannot start with a number: \"{identifierName}\"", paramName);
144+
}
145+
146+
foreach (var ch in identifierName)
147+
{
148+
if (!IsAsciiLetterOrDigit(ch) && ch != '_')
149+
{
150+
throw new ArgumentException($"{paramName} should only contain letters, numbers, and underscores: \"{identifierName}\"", paramName);
151+
}
152+
}
153+
}
154+
155+
/// <summary>
156+
/// Normalizes a proposed bicep identifier name. Any invalid characters
157+
/// will be replaced with underscores.
158+
/// </summary>
159+
/// <param name="identifierName">The proposed bicep identifier name.</param>
160+
/// <returns>A valid bicep identifier name.</returns>
161+
/// <exception cref="ArgumentNullException">Throws if null.</exception>
162+
/// <exception cref="ArgumentException">Throws if empty.</exception>
163+
public static string NormalizeIdentifierName(string? identifierName)
164+
{
165+
if (IsValidIdentifierName(identifierName))
166+
{
167+
return identifierName!;
168+
}
169+
170+
if (identifierName is null)
171+
{
172+
// TODO: This may be relaxed in the future to generate an automatic
173+
// name rather than throwing
174+
throw new ArgumentNullException(nameof(identifierName), $"{nameof(identifierName)} cannot be null.");
175+
}
176+
else if (identifierName.Length == 0)
177+
{
178+
throw new ArgumentException($"{nameof(identifierName)} cannot be empty.", nameof(identifierName));
179+
}
180+
181+
StringBuilder builder = new(identifierName.Length);
182+
183+
// Digits are not allowed as the first character, so prepend an
184+
// underscore if the identifierName starts with a digit
185+
if (char.IsDigit(identifierName[0]))
186+
{
187+
builder.Append('_');
188+
}
189+
190+
foreach (char ch in identifierName)
191+
{
192+
// TODO: Consider opening this up to other naming strategies if
193+
// someone can do something more intelligent for their usage/domain
194+
builder.Append(IsAsciiLetterOrDigit(ch) ? ch : '_');
195+
}
196+
197+
return builder.ToString();
198+
}
199+
96200
/// <inheritdoc/>
97201
protected internal override void Validate(ProvisioningContext? context = null)
98202
{

sdk/provisioning/Azure.Provisioning/src/Primitives/ProvisioningConstruct.cs

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,15 @@ public abstract class NamedProvisioningConstruct : ProvisioningConstruct
2323
public string IdentifierName
2424
{
2525
get => _identifierName;
26-
set => _identifierName = ValidateIdentifierName(value, nameof(value));
26+
set
27+
{
28+
Infrastructure.ValidateIdentifierName(value, nameof(value));
29+
_identifierName = value;
30+
}
2731
}
2832
private string _identifierName;
29-
// TODO: Listen for feedback, but discuss IdentifierName vs. ProvisioningName in the Arch Board
33+
// TODO: Listen for customer feedback and discuss IdentifierName vs.
34+
// ProvisioningName in the Arch Board
3035

3136
/// <summary>
3237
/// Creates a named Bicep entity, like a resource or parameter.
@@ -36,32 +41,12 @@ public string IdentifierName
3641
/// refer to the resource in expressions, but is not the Azure name of the
3742
/// resource. This value can contain letters, numbers, and underscores.
3843
/// </param>
39-
protected NamedProvisioningConstruct(string identifierName) =>
40-
_identifierName = ValidateIdentifierName(identifierName, nameof(identifierName));
41-
42-
// TODO: Relax this in the future when we make identifier names optional
43-
private static string ValidateIdentifierName(string identifierName, string paramName)
44+
protected NamedProvisioningConstruct(string identifierName)
4445
{
45-
// TODO: Enable when Aspire is ready
46-
/*
47-
if (identifierName is null)
48-
{
49-
throw new ArgumentNullException(paramName, $"{nameof(IdentifierName)} cannot be null.");
50-
}
51-
else if (identifierName.Length == 0)
52-
{
53-
throw new ArgumentException($"{nameof(IdentifierName)} cannot be empty.", paramName);
54-
}
55-
56-
foreach (var ch in identifierName)
57-
{
58-
if (!char.IsLetterOrDigit(ch) && ch != '_')
59-
{
60-
throw new ArgumentException($"{nameof(IdentifierName)} \"{identifierName}\" should only contain letters, numbers, and underscores.", paramName);
61-
}
62-
}
63-
/**/
64-
return identifierName;
46+
// TODO: In the near future we'll make this optional and only validate
47+
// if the value passed in isn't null.
48+
Infrastructure.ValidateIdentifierName(identifierName, nameof(identifierName));
49+
_identifierName = identifierName;
6550
}
6651
}
6752

sdk/provisioning/Azure.Provisioning/tests/SampleTests.cs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
using System;
5+
using System.Collections.Generic;
56
using System.Threading.Tasks;
67
using Azure.Core;
78
using Azure.Core.TestFramework;
@@ -348,15 +349,32 @@ await test.Define(
348349
[Test]
349350
public void ValidNames()
350351
{
351-
// TODO: Enable when we turn NamedProvisioningConstruct.ValidateIdentifierName back on
352-
/*
352+
// Check null is invalid
353+
Assert.IsFalse(Infrastructure.IsValidIdentifierName(null));
354+
Assert.Throws<ArgumentNullException>(() => Infrastructure.ValidateIdentifierName(null));
353355
Assert.Throws<ArgumentNullException>(() => new StorageAccount(null!));
354-
Assert.Throws<ArgumentException>(() => new StorageAccount(""));
355-
Assert.Throws<ArgumentException>(() => new StorageAccount("my-storage"));
356-
Assert.Throws<ArgumentException>(() => new StorageAccount("my storage"));
357-
Assert.Throws<ArgumentException>(() => new StorageAccount("my:storage"));
358-
Assert.Throws<ArgumentException>(() => new StorageAccount("storage$"));
359-
/**/
360-
_ = new StorageAccount("ABCdef123_");
356+
357+
// Check invalid names
358+
List<string> invalid = ["", "my-storage", "my storage", "my:storage", "storage$", "1storage", "KforKelvin"];
359+
foreach (string name in invalid)
360+
{
361+
Assert.IsFalse(Infrastructure.IsValidIdentifierName(name));
362+
Assert.Throws<ArgumentException>(() => Infrastructure.ValidateIdentifierName(name));
363+
if (!string.IsNullOrEmpty(name))
364+
{
365+
Assert.AreNotEqual(name, Infrastructure.NormalizeIdentifierName(name));
366+
}
367+
Assert.Throws<ArgumentException>(() => new StorageAccount(name));
368+
}
369+
370+
// Check valid names
371+
List<string> valid = ["foo", "FOO", "Foo", "f", "_foo", "_", "foo123", "ABCdef123_"];
372+
foreach (string name in valid)
373+
{
374+
Assert.IsTrue(Infrastructure.IsValidIdentifierName(name));
375+
Assert.DoesNotThrow(() => Infrastructure.ValidateIdentifierName(name));
376+
Assert.AreEqual(name, Infrastructure.NormalizeIdentifierName(name));
377+
Assert.DoesNotThrow(() => new StorageAccount(name));
378+
}
361379
}
362380
}

0 commit comments

Comments
 (0)