Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions Generator.Tests/SolutionSelectionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using Generator.DTO;
using Generator.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;

namespace Generator.Tests;

public class SolutionSelectionTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" \t\r\n ")]
[InlineData(", ,,,\t")]
public async Task EmptySelectionIsRejectedBeforeDataverseAccess(string? value)
{
var config = Configuration(value);
var service = new SolutionService(null!, config, NullLogger<SolutionService>.Instance);

var error = await Assert.ThrowsAsync<InvalidOperationException>(() => service.GetSolutionIds());

Assert.Contains("DataverseSolutionNames", error.Message);
Assert.Contains("at least one solution unique name", error.Message);
}

[Fact]
public void SelectionTrimsAndDeduplicatesNamesIgnoringCase()
{
Assert.Equal(new[] { "Example", "Other" }, SolutionSelection.Parse(" Example, ,example, Other,EXAMPLE,"));
}

[Theory]
[InlineData("Typo", "Example", "Typo")]
[InlineData("Example,Typo", "Example", "Typo")]
[InlineData("First,Second", "", "First, Second")]
public void MissingSolutionsStopGenerationEvenWhenOtherNamesMatch(string requested, string matched, string missing)
{
var error = Assert.Throws<InvalidOperationException>(() =>
SolutionSelection.ValidateMatches(SolutionSelection.Parse(requested), matched.Split(',')));

Assert.Contains(missing, error.Message);
Assert.Contains("unique names, not display names", error.Message);
}

[Fact]
public void MatchingIsCaseInsensitiveAndIndependentOfResultOrder()
{
SolutionSelection.ValidateMatches(new[] { "Example", "Other" }, new[] { "OTHER", "example" });
}

[Fact]
public void GeneratedCountUsesDistinctNonemptySolutionNames()
{
var output = Path.Combine(Path.GetTempPath(), "dmv-solutions-" + Guid.NewGuid());
try
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["DataverseSolutionNames"] = " Example,example,, Other, ",
["OutputFolder"] = output
}).Build();
new WebsiteBuilder(config, [], [], new Dictionary<string, GlobalOptionSetUsage>()).AddData();

Assert.Contains("export const SolutionCount: number = 2;", File.ReadAllText(Path.Combine(output, "Data.ts")));
}
finally
{
if (Directory.Exists(output)) Directory.Delete(output, recursive: true);
}
}

private static IConfiguration Configuration(string? value) => new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["DataverseSolutionNames"] = value })
.Build();
}
4 changes: 4 additions & 0 deletions Generator/Generator.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.7" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Generator.Tests" />
</ItemGroup>

<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
Expand Down
3 changes: 3 additions & 0 deletions Generator/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
.Build();
var verbose = configuration.GetValue("Verbosity", LogLevel.Warning);

// Reject an empty selection before creating a Dataverse client or requesting credentials.
SolutionSelection.Parse(configuration["DataverseSolutionNames"]);

// Set up dependency injection
var services = new ServiceCollection();

Expand Down
9 changes: 3 additions & 6 deletions Generator/Services/SolutionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,7 @@ public SolutionService(ServiceClient client, IConfiguration configuration, ILogg
/// </summary>
public async Task<(List<Guid> SolutionIds, List<Entity> SolutionEntities)> GetSolutionIds()
{
var solutionNameArg = configuration["DataverseSolutionNames"];
if (solutionNameArg == null)
{
throw new Exception("Specify one or more solutions");
}
var solutionNames = solutionNameArg.Split(",").Select(x => x.Trim().ToLower()).ToList();
var solutionNames = SolutionSelection.Parse(configuration["DataverseSolutionNames"]);

var entities = await client.RetrieveAllAsync(new QueryExpression("solution")
{
Expand All @@ -46,6 +41,8 @@ public SolutionService(ServiceClient client, IConfiguration configuration, ILogg
}
});

SolutionSelection.ValidateMatches(solutionNames, entities.Select(e => e.GetAttributeValue<string>("uniquename")));

return (entities.Select(e => e.GetAttributeValue<Guid>("solutionid")).ToList(), entities);
}

Expand Down
31 changes: 31 additions & 0 deletions Generator/SolutionSelection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace Generator;

internal static class SolutionSelection
{
public static string[] Parse(string? value)
{
var names = (value ?? string.Empty)
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();

if (names.Length == 0)
{
throw new InvalidOperationException(
"DataverseSolutionNames must contain at least one solution unique name (not its display name).");
}

return names;
}

public static void ValidateMatches(IEnumerable<string> requestedNames, IEnumerable<string> matchedNames)
{
var missing = requestedNames.Except(matchedNames, StringComparer.OrdinalIgnoreCase).ToArray();
if (missing.Length > 0)
{
throw new InvalidOperationException(
$"DataverseSolutionNames contains solutions not found in the configured Dataverse environment: {string.Join(", ", missing)}. " +
"Use solution unique names, not display names. Metadata generation has been stopped.");
}
}
}
2 changes: 1 addition & 1 deletion Generator/WebsiteBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ internal void AddData()
var logoUrl = configuration.GetValue<string?>("Logo", defaultValue: null);
var jsValue = logoUrl != null ? $"\"{logoUrl}\"" : "null";
sb.AppendLine($"export const Logo: string | null = {jsValue};");
sb.AppendLine($"export const SolutionCount: number = {configuration["DataverseSolutionNames"]?.Split(",").Length ?? -1};");
sb.AppendLine($"export const SolutionCount: number = {SolutionSelection.Parse(configuration["DataverseSolutionNames"]).Length};");
sb.AppendLine("");

// ENTITIES
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ The pipeline expects a variable group called `DataModel`. It must have the follo
* AzureLocation: Name of the location for the resource group in Azure (e.g. "westeurope" - not the display name which is "West Europe").
* AzureResourceGroupName: Name of the Resource Group in Azure. If this matches an existing group in the location above that will be used for the DMV resources, if not a new resource group will be created.
* DataverseUrl: URL for the Dataverse environment which the data model will be based on (e.g. "https://mySystem-dev.crm4.dynamics.com/").
* DataverseSolutionNames: Comma-seperated list of solutions to based DMV on. Use the logical names (not display names).
* DataverseSolutionNames: Required comma-separated list of solution unique names (not display names). Whitespace, empty entries between commas, and duplicate names are ignored. Generation stops if no names remain or any requested solution cannot be found in the configured Dataverse environment.
* WebsiteName: Used for the url of the web app presenting the data model to the user. The full URL will be in the format "https://wa-{WebsiteName}.azurewebsites.net/" and must be globally unique.
* WebsitePassword: Password used by DMV users to login to the generated site.
* WebsiteSessionSecret: Key to encrypt the session token with (You can set it to whatever you like, but recommended 32 random characters).
Expand Down Expand Up @@ -167,3 +167,7 @@ openssl rand -base64 32
## Running it
Generate data by running the Generator project from Visual Studio.
Afterwards go into the "Website"-folder from VS Code and open the terminal (of the "Command Prompt" type). If this the first time running it, type `npm install` (you need to have installed node.js first: https://nodejs.org/en/download/). Start the website on localhost by running `npm run dev`. Click the link in the terminal to view the website.

To use a different local port, run `npm run dev -- --port 3017`. Login redirects use the request origin unless `AUTH_URL` or `NEXTAUTH_URL` overrides it (`AUTH_URL` takes precedence). If a redirect points to an old host or port, check these environment settings and restart the website after changing them.

When hosting behind a reverse proxy or using a custom domain, set `AUTH_URL` to the public website origin, including its scheme and any non-default port. Do not use the internal application server address or rely on forwarded host headers alone. The supplied Azure infrastructure sets `NEXTAUTH_URL` to the generated App Service HTTPS address; update the auth URL setting when using a different public domain.
Loading