diff --git a/tools/code/.editorconfig b/tools/code/.editorconfig new file mode 100644 index 0000000..5478e56 --- /dev/null +++ b/tools/code/.editorconfig @@ -0,0 +1,42 @@ +[*.cs] + +# CA1062: Validate arguments of public methods +dotnet_diagnostic.CA1062.severity = suggestion + +# IDE0022: Use expression body for method +csharp_style_expression_bodied_methods = when_on_single_line + +# IDE0022: Use expression body for method +dotnet_diagnostic.IDE0022.severity = suggestion + +# IDE0058: Expression value is never used +dotnet_diagnostic.IDE0058.severity = suggestion + +# CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2007.severity = suggestion + +# CA1859: Use concrete types when possible for improved performance +dotnet_diagnostic.CA1859.severity = suggestion + +# CA1034: Nested types should not be visible +dotnet_diagnostic.CA1034.severity = suggestion + +# CA1848: Use the LoggerMessage delegates +dotnet_diagnostic.CA1848.severity = suggestion + +# IDE0200: Remove unnecessary lambda expression +dotnet_diagnostic.IDE0200.severity = suggestion + +# CA1707: Identifiers should not contain underscores +dotnet_diagnostic.CA1707.severity = suggestion + +# IDE0008: Use explicit type +csharp_style_var_elsewhere = true +csharp_style_var_for_built_in_types = true +dotnet_diagnostic.IDE0008.severity = suggestion + +# IDE0039: Use local function +dotnet_diagnostic.IDE0039.severity = warning + +# IDE0320: Make anonymous function static +dotnet_diagnostic.IDE0320.severity = suggestion diff --git a/tools/code/code.sln b/tools/code/code.sln index 8dfdd6e..e257614 100644 --- a/tools/code/code.sln +++ b/tools/code/code.sln @@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "integration.tests", "integr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "aspire", "aspire\aspire.csproj", "{C0AD9089-77B8-4E2F-ADD3-1F0846B8D370}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "publisher.unit.tests", "publisher.unit.tests\publisher.unit.tests.csproj", "{C24D7BC6-5BFA-4D11-A903-63049623258D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -45,6 +47,10 @@ Global {C0AD9089-77B8-4E2F-ADD3-1F0846B8D370}.Debug|Any CPU.Build.0 = Debug|Any CPU {C0AD9089-77B8-4E2F-ADD3-1F0846B8D370}.Release|Any CPU.ActiveCfg = Release|Any CPU {C0AD9089-77B8-4E2F-ADD3-1F0846B8D370}.Release|Any CPU.Build.0 = Release|Any CPU + {C24D7BC6-5BFA-4D11-A903-63049623258D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C24D7BC6-5BFA-4D11-A903-63049623258D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C24D7BC6-5BFA-4D11-A903-63049623258D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C24D7BC6-5BFA-4D11-A903-63049623258D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/tools/code/common.tests/CsCheck.cs b/tools/code/common.tests/CsCheck.cs index 212fefb..6227cc6 100644 --- a/tools/code/common.tests/CsCheck.cs +++ b/tools/code/common.tests/CsCheck.cs @@ -8,6 +8,7 @@ using System.Collections.Immutable; using System.IO; using System.Linq; +using System.Text.Json.Nodes; namespace common.tests; @@ -44,6 +45,26 @@ from system in BogusSystem public static Gen NonEmptyString { get; } = Gen.String.Where(x => string.IsNullOrWhiteSpace(x) is false); + public static Gen JsonValue { get; } = + Gen.OneOf(Gen.Int.Select(x => System.Text.Json.Nodes.JsonValue.Create(x)), + Gen.Float.Select(x => System.Text.Json.Nodes.JsonValue.Create(x)), + Gen.String.Select(x => System.Text.Json.Nodes.JsonValue.Create(x)), + Gen.Bool.Select(x => System.Text.Json.Nodes.JsonValue.Create(x)), + Gen.Date.Select(x => System.Text.Json.Nodes.JsonValue.Create(x)), + Gen.DateTime.Select(x => System.Text.Json.Nodes.JsonValue.Create(x)), + Gen.DateTimeOffset.Select(x => System.Text.Json.Nodes.JsonValue.Create(x))); + + public static Gen JsonNode { get; } = + Gen.Recursive((iterations, gen) => iterations < 2 + ? Gen.OneOf(JsonValue.Select(x => (JsonNode)x), + GetJsonArray(gen).Select(x => (JsonNode)x), + GetJsonObject(gen).Select(x => (JsonNode)x)) + : JsonValue.Select(x => (JsonNode)x)); + + public static Gen JsonArray { get; } = GetJsonArray(JsonNode); + + public static Gen JsonObject { get; } = GetJsonObject(JsonNode); + public static Gen AlphaNumericStringBetween(int minimumLength, int maximumLength) => Gen.Char .AlphaNumeric @@ -203,6 +224,14 @@ private sealed record ChangeParameters public Option MaxSize { get; init; } = Option.None; } + + private static Gen GetJsonArray(Gen nodeGen) => + from nodes in nodeGen.Null().Array[0, 5] + select new JsonArray(nodes); + + private static Gen GetJsonObject(Gen nodeGen) => + from properties in Gen.Dictionary(NonEmptyString, nodeGen.Null())[0, 5] + select new JsonObject(properties); } /// diff --git a/tools/code/common.tests/Option.cs b/tools/code/common.tests/Option.cs new file mode 100644 index 0000000..5ed612f --- /dev/null +++ b/tools/code/common.tests/Option.cs @@ -0,0 +1,56 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using FluentAssertions.Primitives; +using LanguageExt; +using LanguageExt.UnsafeValueAccess; + +namespace common.tests; + +public static class OptionExtensions +{ + public static OptionAssertions Should(this Option instance) where T : notnull => + new OptionAssertions(instance); +} + +public sealed class OptionAssertions(Option subject) : ReferenceTypeAssertions, OptionAssertions>(subject) where T : notnull +{ + protected override string Identifier { get; } = "option"; + + [CustomAssertion] + public AndConstraint> BeSome(string because = "", params object[] becauseArgs) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(Subject.IsSome) + .FailWith("Expected {context:option} to be Some{reason}, but it is None."); + + return new AndConstraint>(this); + } + + [CustomAssertion] + public AndConstraint> BeSome(T expected, string because = "", params object[] becauseArgs) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .WithExpectation("Expected {context:option} to be Some {0}{reason}, ", expected) + .ForCondition(Subject.IsSome) + .FailWith("but it is None.") + .Then + .Given(() => Subject.ValueUnsafe()) + .ForCondition(actual => expected.Equals(actual)) + .FailWith("but it is {0}.", t => new[] { t }); + + return new AndConstraint>(this); + } + + [CustomAssertion] + public AndConstraint> BeNone(string because = "", params object[] becauseArgs) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(Subject.IsNone) + .FailWith("Expected {context:option} to be None{reason}, but it is Some."); + + return new AndConstraint>(this); + } +} \ No newline at end of file diff --git a/tools/code/common.tests/common.tests.csproj b/tools/code/common.tests/common.tests.csproj index e1a97d0..f0f13a0 100644 --- a/tools/code/common.tests/common.tests.csproj +++ b/tools/code/common.tests/common.tests.csproj @@ -12,6 +12,7 @@ + diff --git a/tools/code/publisher.unit.tests/ApiDiagnostic.cs b/tools/code/publisher.unit.tests/ApiDiagnostic.cs new file mode 100644 index 0000000..252bd1b --- /dev/null +++ b/tools/code/publisher.unit.tests/ApiDiagnostic.cs @@ -0,0 +1,159 @@ +using common; +using common.tests; +using CsCheck; +using LanguageExt; +using LanguageExt.UnsafeValueAccess; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Linq; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace publisher.unit.tests; + +public class FindApiDiagnosticDtoTests +{ + [Fact] + public async Task Returns_none_if_the_dto_does_not_exist() + { + var generator = from fixture in Fixture.Generate() + where fixture.OriginalDto.IsNone + select fixture; + + await generator.SampleAsync(async fixture => + { + var dtoOption = await fixture.Run(CancellationToken.None); + + dtoOption.Should().BeNone(); + }); + } + + [Fact] + public async Task Returns_the_original_dto_if_there_is_no_override() + { + var generator = from fixture in Fixture.Generate() + where fixture.OriginalDto.IsSome + where fixture.DtoOverride.IsNone + select fixture; + + await generator.SampleAsync(async fixture => + { + var dtoOption = await fixture.Run(CancellationToken.None); + + var expectedDto = fixture.OriginalDto.ValueUnsafe() ?? throw new InvalidOperationException("Expected dto should not be null."); + dtoOption.Should().BeSome(expectedDto); + }); + } + + [Fact] + public async Task Returns_the_overridden_dto_if_there_is_an_override() + { + var generator = from fixture in Fixture.Generate() + where fixture.OriginalDto.IsSome + where fixture.DtoOverride.IsSome + select fixture; + + await generator.SampleAsync(async fixture => + { + var dtoOption = await fixture.Run(CancellationToken.None); + + // Assert + var originalDto = fixture.OriginalDto.ValueUnsafe() ?? throw new InvalidOperationException("Original dto should not be null."); + var dtoOverride = fixture.DtoOverride.ValueUnsafe() ?? throw new InvalidOperationException("Override should not be null."); + var expectedDto = OverrideDtoFactory.Override(originalDto, dtoOverride); + dtoOption.Should().BeSome(expectedDto); + }); + } + + private sealed record Fixture + { + public required ManagementServiceDirectory ServiceDirectory { get; init; } + public required ApiName ApiName { get; init; } + public required ApiDiagnosticName Name { get; init; } + public required Option OriginalDto { get; init; } + public required Option DtoOverride { get; init; } + + public async ValueTask> Run(CancellationToken cancellationToken) + { + var provider = GetServiceProvider(); + + var findDto = ApiDiagnosticModule.GetFindApiDiagnosticDto(provider); + + return await findDto(Name, ApiName, cancellationToken); + } + + private IServiceProvider GetServiceProvider() + { + var services = new ServiceCollection(); + + services.AddSingleton(ServiceDirectory); + + services.AddSingleton(async (file, cancellationToken) => + { + await ValueTask.CompletedTask; + + return OriginalDto.Map(dto => BinaryData.FromObjectAsJson(dto)); + }); + + services.AddSingleton(new ConfigurationJson + { + Value = DtoOverride.Map(@override => new JsonObject + { + ["apis"] = new JsonObject + { + [ApiName.Value] = new JsonObject + { + ["diagnostics"] = new JsonObject + { + [Name.Value] = @override + } + } + } + }).IfNone([]) + }); + + services.AddSingleton(ConfigurationJsonModule.GetFindConfigurationSection); + + return services.BuildServiceProvider(); + } + + public static Gen Generate() => + from serviceDirectory in from directoryInfo in Generator.DirectoryInfo + select ManagementServiceDirectory.From(directoryInfo) + from apiName in from apiType in ApiType.Generate() + from apiName in ApiModel.GenerateName(apiType) + select apiName + from name in ApiDiagnosticModel.GenerateName() + from originalDto in from modelOption in ApiDiagnosticModel.Generate().OptionOf() + select modelOption.Map(ModelToDto) + from dtoOverride in from modelOption in ApiDiagnosticModel.Generate().OptionOf() + select from model in modelOption + let dto = ModelToDto(model) + select JsonObjectExtensions.Parse(dto) + select new Fixture + { + ServiceDirectory = serviceDirectory, + ApiName = apiName, + Name = name, + OriginalDto = originalDto, + DtoOverride = dtoOverride + }; + + private static ApiDiagnosticDto ModelToDto(ApiDiagnosticModel model) => + new() + { + Properties = new ApiDiagnosticDto.DiagnosticContract + { + LoggerId = $"/loggers/{model.LoggerName}", + AlwaysLog = model.AlwaysLog.ValueUnsafe(), + Sampling = model.Sampling.Map(sampling => new ApiDiagnosticDto.SamplingSettings + { + SamplingType = sampling.Type, + Percentage = sampling.Percentage + }).ValueUnsafe() + } + }; + } +} diff --git a/tools/code/publisher.unit.tests/ConfigurationJson.cs b/tools/code/publisher.unit.tests/ConfigurationJson.cs new file mode 100644 index 0000000..4e1aac4 --- /dev/null +++ b/tools/code/publisher.unit.tests/ConfigurationJson.cs @@ -0,0 +1,118 @@ +using common; +using common.tests; +using CsCheck; +using FluentAssertions; +using LanguageExt; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; +using Xunit; + +namespace publisher.unit.tests; + +public class FindConfigurationSectionTests +{ + [Fact] + public void No_argments_always_returns_none() + { + var generator = Fixture.Generate(); + + generator.Sample(fixture => + { + var result = fixture.FindConfigurationSection(); + + result.Should().BeNone(); + }); + } + + [Fact] + public void Return_none_when_section_does_not_exist() + { + var generator = from jsonObject in Generator.JsonObject + from missingSection in Generator.NonEmptyString + where jsonObject.ContainsKey(missingSection) is false + from fixture in Fixture.Generate() + let updatedFixture = fixture with { ConfigurationJson = fixture.ConfigurationJson with { Value = jsonObject } } + select (updatedFixture, missingSection); + + generator.Sample(x => + { + var (fixture, missingSection) = x; + + var result = fixture.FindConfigurationSection(missingSection); + + result.Should().BeNone(); + }); + } + + [Fact] + public void Returns_existing_section() + { + var generator = from sections in Generator.AlphaNumericStringBetween(5, 10).Array[1, 5] + from expectedValue in Generator.JsonObject + from jsonObject in sections.Select(sectionName => from jsonObject in Generator.JsonObject + select KeyValuePair.Create(sectionName, jsonObject)) + .Reverse() + .Aggregate(Gen.Const(expectedValue), + (jsonObjectGen, kvpGen) => from previousJsonObject in jsonObjectGen + from newJsonObject in Generator.JsonObject + from kvp in kvpGen + select newJsonObject.SetProperty(kvp.Key, previousJsonObject)) + from fixture in Fixture.Generate() + let updatedFixture = fixture with { ConfigurationJson = fixture.ConfigurationJson with { Value = jsonObject } } + select (updatedFixture, sections, expectedValue); + + generator.Sample(x => + { + // Arrange + var (fixture, sections, expectedValue) = x; + + // Act + var result = fixture.FindConfigurationSection(sections); + + // Assert + var expectedValueString = expectedValue.ToJsonString(); + var actualValueString = result.Map(json => json.ToJsonString()); + actualValueString.Should().BeSome(expected: expectedValueString); + }); + } + + private sealed record Fixture + { + public required ConfigurationJson ConfigurationJson { get; init; } + + public static Gen Generate() => + from configurationJson in GenerateConfigurationJson() + select new Fixture + { + ConfigurationJson = configurationJson + }; + + private static Gen GenerateConfigurationJson() => + from jsonObject in Generator.JsonObject + select new ConfigurationJson + { + Value = jsonObject + }; + + public Option FindConfigurationSection(params string[] sectionNames) + { + var serviceProvider = GetServiceProvider(); + + var findConfigurationSection = ConfigurationJsonModule.GetFindConfigurationSection(serviceProvider); + + return findConfigurationSection(sectionNames); + } + + private IServiceProvider GetServiceProvider() + { + var services = new ServiceCollection(); + + services.AddSingleton(ConfigurationJson); + + return services.BuildServiceProvider(); + } + } +} \ No newline at end of file diff --git a/tools/code/publisher.unit.tests/publisher.unit.tests.csproj b/tools/code/publisher.unit.tests/publisher.unit.tests.csproj new file mode 100644 index 0000000..59b5a64 --- /dev/null +++ b/tools/code/publisher.unit.tests/publisher.unit.tests.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + latest-all + true + true + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/tools/code/publisher/ApiDiagnostic.cs b/tools/code/publisher/ApiDiagnostic.cs index 5a72661..831441b 100644 --- a/tools/code/publisher/ApiDiagnostic.cs +++ b/tools/code/publisher/ApiDiagnostic.cs @@ -127,14 +127,16 @@ private static void ConfigureFindApiDiagnosticDto(IHostApplicationBuilder builde { AzureModule.ConfigureManagementServiceDirectory(builder); CommonModule.ConfigureTryGetFileContents(builder); + ConfigurationJsonModule.ConfigureFindConfigurationSection(builder); builder.Services.TryAddSingleton(GetFindApiDiagnosticDto); } - private static FindApiDiagnosticDto GetFindApiDiagnosticDto(IServiceProvider provider) + public static FindApiDiagnosticDto GetFindApiDiagnosticDto(IServiceProvider provider) { var serviceDirectory = provider.GetRequiredService(); var tryGetFileContents = provider.GetRequiredService(); + var findConfigurationSection = provider.GetRequiredService(); return async (name, apiName, cancellationToken) => { @@ -142,8 +144,14 @@ private static FindApiDiagnosticDto GetFindApiDiagnosticDto(IServiceProvider pro var contentsOption = await tryGetFileContents(informationFile.ToFileInfo(), cancellationToken); return from contents in contentsOption - select contents.ToObjectFromJson(); + let dto = contents.ToObjectFromJson() + select overrideDto(dto, name, apiName); }; + + ApiDiagnosticDto overrideDto(ApiDiagnosticDto dto, ApiDiagnosticName name, ApiName apiName) => + findConfigurationSection(["apis", apiName.Value, "diagnostics", name.Value]) + .Map(configurationJson => OverrideDtoFactory.Override(dto, configurationJson)) + .IfNone(dto); } private static void ConfigurePutApiDiagnosticInApim(IHostApplicationBuilder builder) diff --git a/tools/code/publisher/ConfigurationJson.cs b/tools/code/publisher/ConfigurationJson.cs new file mode 100644 index 0000000..7f2eeb2 --- /dev/null +++ b/tools/code/publisher/ConfigurationJson.cs @@ -0,0 +1,37 @@ +using common; +using LanguageExt; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using System; +using System.Linq; +using System.Text.Json.Nodes; + +namespace publisher; + +public delegate Option FindConfigurationSection(params string[] sectionNames); + +public static class ConfigurationJsonModule +{ + public static void ConfigureFindConfigurationSection(IHostApplicationBuilder builder) + { + ConfigurationModule.ConfigureConfigurationJson(builder); + + builder.Services.TryAddSingleton(GetFindConfigurationSection); + } + + public static FindConfigurationSection GetFindConfigurationSection(IServiceProvider provider) + { + var configurationJson = provider.GetRequiredService(); + + return sectionNames => + sectionNames.Select((sectionName, index) => (sectionName, index)) + .Aggregate(Option.None, + (option, section) => section.index == 0 + ? configurationJson.Value + .TryGetJsonObjectProperty(section.sectionName) + .ToOption() + : option.Bind(jsonObject => jsonObject.TryGetJsonObjectProperty(section.sectionName) + .ToOption())); + } +} \ No newline at end of file diff --git a/tools/code/publisher/OverrideDto.cs b/tools/code/publisher/OverrideDto.cs index 71695da..a2d4aeb 100644 --- a/tools/code/publisher/OverrideDto.cs +++ b/tools/code/publisher/OverrideDto.cs @@ -1,4 +1,5 @@ using common; +using LanguageExt; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; diff --git a/tools/code/publisher/WorkspaceApi.cs b/tools/code/publisher/WorkspaceApi.cs index 826f8c4..44a6d98 100644 --- a/tools/code/publisher/WorkspaceApi.cs +++ b/tools/code/publisher/WorkspaceApi.cs @@ -2,14 +2,16 @@ using common; using DotNext.Threading; using LanguageExt; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; using Microsoft.OpenApi.Exceptions; +using Microsoft.OpenApi.Extensions; using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Readers; -using Microsoft.OpenApi; using System; using System.Collections.Concurrent; using System.Collections.Frozen; @@ -19,9 +21,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using static Azure.Core.HttpHeader; -using Microsoft.OpenApi.Extensions; -using Microsoft.Extensions.Caching.Memory; namespace publisher; diff --git a/tools/code/publisher/publisher.csproj b/tools/code/publisher/publisher.csproj index 9d8f66b..2be99fd 100644 --- a/tools/code/publisher/publisher.csproj +++ b/tools/code/publisher/publisher.csproj @@ -11,6 +11,10 @@ 6.0.1.1 + + + +