Skip to content

Commit 066c054

Browse files
Add library for interacting with Bicep CLI via JSONRPC (#18151)
Add library for installing and interacting with Bicep CLI via [JSONRPC](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/bicep-cli?tabs=bicep-cli#jsonrpc). This provides a unified and efficient interface for calling any version of Bicep programatically. Features: * Spawns a single executable to minimize cold-start delays for multiple Bicep requests * Minimal NuGet dependencies * Supports caching of binaries under `~/.bicep/bin` * Supports all JSONRPC methods * Supports concurrency Example usage: ```csharp var clientFactory = new BicepClientFactory(new HttpClient()); using var client = await clientFactory.DownloadAndInitialize(new() { BicepVersion: "0.38.3" }, cancellationToken); var result = await Bicep.Compile(new("/path/to/main.bicep")); ```
1 parent 09e7629 commit 066c054

19 files changed

+1325
-14
lines changed

.editorconfig

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,8 @@ dotnet_diagnostic.CA1851.severity = warning
277277
# Override code quality rules for test projects
278278
[{src/Bicep.Core.Samples/*.cs,src/*Test*/*.cs}]
279279
dotnet_diagnostic.CA1851.severity = suggestion
280+
281+
# This library is intended to be consumed by other .NET applications, so re-enable best-practice for threading
282+
[src/Bicep.RpcClient/**/*.cs]
283+
# Do not directly await a Task
284+
dotnet_diagnostic.CA2007.severity = warning

Bicep.sln

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bicep.McpServer.UnitTests",
113113
EndProject
114114
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bicep.Local.Rpc", "src\Bicep.Local.Rpc\Bicep.Local.Rpc.csproj", "{8585C44C-5093-4A32-AABA-9EC7B8A6118C}"
115115
EndProject
116+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bicep.RpcClient", "src\Bicep.RpcClient\Bicep.RpcClient.csproj", "{EFC27293-4DBB-4D58-85E4-4B94A8F50C8B}"
117+
EndProject
118+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bicep.RpcClient.Tests", "src\Bicep.RpcClient.Tests\Bicep.RpcClient.Tests.csproj", "{930BA3F9-160A-4EB6-80DC-AABFDE3BB919}"
119+
EndProject
116120
Global
117121
GlobalSection(SolutionConfigurationPlatforms) = preSolution
118122
Debug|Any CPU = Debug|Any CPU
@@ -243,6 +247,14 @@ Global
243247
{8585C44C-5093-4A32-AABA-9EC7B8A6118C}.Debug|Any CPU.Build.0 = Debug|Any CPU
244248
{8585C44C-5093-4A32-AABA-9EC7B8A6118C}.Release|Any CPU.ActiveCfg = Release|Any CPU
245249
{8585C44C-5093-4A32-AABA-9EC7B8A6118C}.Release|Any CPU.Build.0 = Release|Any CPU
250+
{EFC27293-4DBB-4D58-85E4-4B94A8F50C8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
251+
{EFC27293-4DBB-4D58-85E4-4B94A8F50C8B}.Debug|Any CPU.Build.0 = Debug|Any CPU
252+
{EFC27293-4DBB-4D58-85E4-4B94A8F50C8B}.Release|Any CPU.ActiveCfg = Release|Any CPU
253+
{EFC27293-4DBB-4D58-85E4-4B94A8F50C8B}.Release|Any CPU.Build.0 = Release|Any CPU
254+
{930BA3F9-160A-4EB6-80DC-AABFDE3BB919}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
255+
{930BA3F9-160A-4EB6-80DC-AABFDE3BB919}.Debug|Any CPU.Build.0 = Debug|Any CPU
256+
{930BA3F9-160A-4EB6-80DC-AABFDE3BB919}.Release|Any CPU.ActiveCfg = Release|Any CPU
257+
{930BA3F9-160A-4EB6-80DC-AABFDE3BB919}.Release|Any CPU.Build.0 = Release|Any CPU
246258
EndGlobalSection
247259
GlobalSection(SolutionProperties) = preSolution
248260
HideSolutionNode = FALSE
@@ -279,6 +291,8 @@ Global
279291
{83AC12EE-E6B5-45FA-AADF-68AB652CC804} = {013E98B4-42CE-4009-BC62-2588ED9028CD}
280292
{46780AD4-62F3-4AB4-9B3C-6A8AB305C260} = {013E98B4-42CE-4009-BC62-2588ED9028CD}
281293
{8585C44C-5093-4A32-AABA-9EC7B8A6118C} = {013E98B4-42CE-4009-BC62-2588ED9028CD}
294+
{EFC27293-4DBB-4D58-85E4-4B94A8F50C8B} = {013E98B4-42CE-4009-BC62-2588ED9028CD}
295+
{930BA3F9-160A-4EB6-80DC-AABFDE3BB919} = {013E98B4-42CE-4009-BC62-2588ED9028CD}
282296
EndGlobalSection
283297
GlobalSection(ExtensibilityGlobals) = postSolution
284298
SolutionGuid = {21F77282-91E7-4304-B1EF-FADFA4F39E37}

src/Bicep.Core.UnitTests/Utils/FileHelper.cs

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,32 @@ public static string GetResultFilePath(TestContext testContext, string fileName,
2929

3030
public static string SaveResultFile(TestContext testContext, string fileName, string contents, string? testOutputPath = null, Encoding? encoding = null)
3131
{
32-
var filePath = GetResultFilePath(testContext, fileName, testOutputPath);
32+
var outputPath = SaveResultFiles(testContext, [new ResultFile(fileName, contents, encoding)], testOutputPath);
3333

34-
if (encoding is not null)
35-
{
36-
File.WriteAllText(filePath, contents, encoding);
37-
}
38-
else
34+
return Path.Combine(outputPath, fileName);
35+
}
36+
37+
public record ResultFile(string FileName, string Contents, Encoding? Encoding = null);
38+
39+
public static string SaveResultFiles(TestContext testContext, ResultFile[] files, string? testOutputPath = null)
40+
{
41+
var outputPath = testOutputPath ?? GetUniqueTestOutputPath(testContext);
42+
43+
foreach (var (fileName, contents, encoding) in files)
3944
{
40-
File.WriteAllText(filePath, contents);
45+
var filePath = GetResultFilePath(testContext, fileName, outputPath);
46+
47+
if (encoding is not null)
48+
{
49+
File.WriteAllText(filePath, contents, encoding);
50+
}
51+
else
52+
{
53+
File.WriteAllText(filePath, contents);
54+
}
4155
}
4256

43-
return filePath;
57+
return outputPath;
4458
}
4559

4660
public static string SaveEmbeddedResourcesWithPathPrefix(TestContext testContext, Assembly containingAssembly, string manifestFilePrefix)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<ImplicitUsings>enable</ImplicitUsings>
5+
</PropertyGroup>
6+
7+
<ItemGroup>
8+
<None Remove="./Files/**/*.*" />
9+
<EmbeddedResource Include="./Files/**/*.*" LogicalName="$([System.String]::new('Files/%(RecursiveDir)%(Filename)%(Extension)').Replace('\', '/'))" WithCulture="false" />
10+
</ItemGroup>
11+
12+
<ItemGroup>
13+
<PackageReference Include="FluentAssertions" />
14+
<PackageReference Include="Microsoft.NET.Test.Sdk" />
15+
<PackageReference Include="Moq" />
16+
<PackageReference Include="MSTest" />
17+
<PackageReference Include="PublicApiGenerator" />
18+
</ItemGroup>
19+
20+
<ItemGroup>
21+
<ProjectReference Include="../Bicep.Core.UnitTests/Bicep.Core.UnitTests.csproj" />
22+
<ProjectReference Include="../Bicep.Cli/Bicep.Cli.csproj" />
23+
<ProjectReference Include="../Bicep.RpcClient/Bicep.RpcClient.csproj" />
24+
</ItemGroup>
25+
26+
<ItemGroup>
27+
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
28+
</ItemGroup>
29+
30+
<Target Name="PublishCli" AfterTargets="Build">
31+
<Exec Command="dotnet publish --no-restore --configuration $(Configuration) --self-contained true ../Bicep.Cli --output $(OutDir)/PublishedCli"
32+
WorkingDirectory="$(MSBuildProjectDirectory)" />
33+
</Target>
34+
35+
</Project>
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Runtime.InteropServices;
5+
using FluentAssertions;
6+
using Bicep.RpcClient.Helpers;
7+
using Bicep.Core.UnitTests.Utils;
8+
using Bicep.Core.FileSystem;
9+
using RichardSzalay.MockHttp;
10+
using System.Threading.Tasks;
11+
12+
namespace Bicep.RpcClient.Tests;
13+
14+
[TestClass]
15+
public class BicepClientTests
16+
{
17+
public TestContext TestContext { get; set; } = null!;
18+
19+
public required IBicepClient Bicep { get; set; }
20+
21+
[TestInitialize]
22+
public async Task TestInitialize()
23+
{
24+
MockHttpMessageHandler mockHandler = new();
25+
26+
mockHandler.When(HttpMethod.Get, "https://downloads.bicep.azure.com/releases/latest")
27+
.Respond("application/json", """
28+
{
29+
"tag_name": "v0.0.0-dev"
30+
}
31+
""");
32+
33+
mockHandler.When(HttpMethod.Get, "https://downloads.bicep.azure.com/v0.0.0-dev/bicep-*-*")
34+
.Respond(req => {
35+
var cliName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "bicep.exe" : "bicep";
36+
var cliPath = Path.GetFullPath(Path.Combine(typeof(BicepClientTests).Assembly.Location, $"../PublishedCli/{cliName}"));
37+
38+
return new(System.Net.HttpStatusCode.OK)
39+
{
40+
Content = new StreamContent(File.OpenRead(cliPath))
41+
};
42+
});
43+
44+
var clientFactory = new BicepClientFactory(new(mockHandler));
45+
46+
Bicep = await clientFactory.DownloadAndInitialize(
47+
new() { InstallPath = FileHelper.GetUniqueTestOutputPath(TestContext) },
48+
TestContext.CancellationTokenSource.Token);
49+
}
50+
51+
[TestCleanup]
52+
public void TestCleanup()
53+
{
54+
Bicep.Dispose();
55+
if (Directory.Exists(TestContext.TestResultsDirectory))
56+
{
57+
// This test creates a new Bicep CLI install for each test run, so we need to clean it up afterwards
58+
Directory.Delete(TestContext.TestResultsDirectory, true);
59+
}
60+
}
61+
62+
[TestMethod]
63+
public async Task DownloadAndInitialize_validates_version_number_format()
64+
{
65+
var clientFactory = new BicepClientFactory(new());
66+
await FluentActions.Invoking(() => clientFactory.DownloadAndInitialize(new() { BicepVersion = "v0.1.1" }, default))
67+
.Should().ThrowAsync<ArgumentException>().WithMessage("Invalid Bicep version format 'v0.1.1'. Expected format: 'x.y.z' where x, y, and z are integers.");
68+
}
69+
70+
[TestMethod]
71+
public async Task DownloadAndInitialize_validates_path_existence()
72+
{
73+
var nonExistentPath = FileHelper.GetUniqueTestOutputPath(TestContext);
74+
var clientFactory = new BicepClientFactory(new());
75+
await FluentActions.Invoking(() => clientFactory.InitializeFromPath(nonExistentPath, default))
76+
.Should().ThrowAsync<FileNotFoundException>().WithMessage($"The specified Bicep CLI path does not exist: '{nonExistentPath}'.");
77+
}
78+
79+
[TestMethod]
80+
public void BuildDownloadUrlForTag_returns_correct_url()
81+
{
82+
BicepInstaller.BuildDownloadUrlForTag(OSPlatform.Linux, Architecture.X64, "v0.24.24")
83+
.Should().Be("https://downloads.bicep.azure.com/v0.24.24/bicep-linux-x64");
84+
}
85+
86+
[TestMethod]
87+
public async Task GetVersion_runs_successfully()
88+
{
89+
var result = await Bicep.GetVersion();
90+
91+
result.Should().MatchRegex(@"^\d+\.\d+\.\d+(-.+)?$");
92+
}
93+
94+
[TestMethod]
95+
public async Task Compile_runs_successfully()
96+
{
97+
var bicepFile = FileHelper.SaveResultFile(TestContext, "main.bicep", """
98+
param location string
99+
""");
100+
101+
var result = await Bicep.Compile(new(bicepFile));
102+
103+
result.Success.Should().BeTrue();
104+
result.Contents.Should().NotBeNullOrEmpty();
105+
result.Diagnostics.Should().Contain(x => x.Code == "no-unused-params");
106+
}
107+
108+
[TestMethod]
109+
public async Task CompileParams_runs_successfully()
110+
{
111+
var outputPath = FileHelper.SaveResultFiles(TestContext, [
112+
new("main.bicep", """
113+
param location string
114+
"""),
115+
new("main.bicepparam", """
116+
using 'main.bicep'
117+
118+
param location = 'westus'
119+
"""),
120+
]);
121+
122+
var result = await Bicep.CompileParams(new(Path.Combine(outputPath, "main.bicepparam"), []));
123+
124+
result.Success.Should().BeTrue();
125+
result.Parameters.Should().NotBeNullOrEmpty();
126+
result.Template.Should().NotBeNullOrEmpty();
127+
result.Diagnostics.Should().Contain(x => x.Code == "no-unused-params");
128+
}
129+
130+
[TestMethod]
131+
public async Task Format_runs_successfully()
132+
{
133+
var bicepFile = FileHelper.SaveResultFile(TestContext, "main.bicep", """
134+
param location string
135+
""");
136+
137+
var result = await Bicep.Format(new(bicepFile));
138+
139+
result.Contents.Should().Be("""
140+
param location string
141+
142+
""");
143+
}
144+
145+
[TestMethod]
146+
public async Task GetSnapshot_runs_successfully()
147+
{
148+
var outputPath = FileHelper.SaveResultFiles(TestContext, [
149+
new("main.bicep", """
150+
param sku string
151+
152+
resource storageaccount 'Microsoft.Storage/storageAccounts@2021-02-01' = {
153+
name: 'myStgAct'
154+
location: resourceGroup().location
155+
kind: 'StorageV2'
156+
sku: {
157+
name: sku
158+
}
159+
}
160+
"""),
161+
new("main.bicepparam", """
162+
using 'main.bicep'
163+
164+
param sku = 'Premium_LRS'
165+
"""),
166+
]);
167+
168+
var result = await Bicep.GetSnapshot(new(Path.Combine(outputPath, "main.bicepparam"), new(
169+
TenantId: null,
170+
SubscriptionId: "0910bc80-1614-479b-a3f4-07178d3ea77b",
171+
ResourceGroup: "ant-test",
172+
Location: "West US",
173+
DeploymentName: "main"), []));
174+
175+
result.Snapshot.Should().Contain("/subscriptions/0910bc80-1614-479b-a3f4-07178d3ea77b/resourceGroups/ant-test/providers/Microsoft.Storage/storageAccounts/myStgAct");
176+
}
177+
178+
[TestMethod]
179+
public async Task GetMetadataResponse_runs_successfully()
180+
{
181+
var bicepFile = FileHelper.SaveResultFile(TestContext, "main.bicep", """
182+
@export()
183+
@description('A foo object')
184+
type foo = {
185+
bar: string
186+
}
187+
""");
188+
189+
var result = await Bicep.GetMetadata(new(bicepFile));
190+
191+
result.Exports[0].Description.Should().Be("A foo object");
192+
}
193+
}

0 commit comments

Comments
 (0)