Skip to content
Merged
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
64 changes: 63 additions & 1 deletion src/Confix.Runner/SchemaExport.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Reflection;
Expand Down Expand Up @@ -120,6 +121,7 @@ private static JsonSchemaExporterOptions CreateExporterOptions()
}

ApplyAnnotations(obj, member);
ApplyDefault(obj, member);

// Any value may instead be a variable expression that Confix resolves on build.
return new JsonObject
Expand All @@ -138,7 +140,6 @@ private static void ApplyAnnotations(JsonObject schema, PropertyInfo member)
{
schema["description"] = description.Description;
}

if (member.GetCustomAttribute<RangeAttribute>() is { Minimum: int min, Maximum: int max })
{
schema["minimum"] = min;
Expand Down Expand Up @@ -171,6 +172,67 @@ private static void ApplyAnnotations(JsonObject schema, PropertyInfo member)
}
}

/// <summary>
/// Surfaces property initializers as schema defaults so scaffolding can write real values.
/// </summary>
private static void ApplyDefault(JsonObject schema, PropertyInfo member)
{
var declaringType = member.DeclaringType;

if (declaringType is null)
{
return;
}

var instance = _instances.GetOrAdd(declaringType, static type =>
{
try
{
return Activator.CreateInstance(type);
}
catch
{
return null;
}
});

if (instance is null)
{
return;
}

object? value;

try
{
value = member.GetValue(instance);
}
catch
{
return;
}

// A value equal to the CLR default is indistinguishable from "no initializer".
JsonNode? defaultNode = value switch
{
string s => JsonValue.Create(s),
true => JsonValue.Create(true),
Enum e => JsonValue.Create(e.ToString()),
sbyte or byte or short or ushort or int or uint or long or ulong
when Convert.ToInt64(value) != 0 => JsonValue.Create(Convert.ToInt64(value)),
float or double or decimal
when Convert.ToDecimal(value) != 0 => JsonValue.Create(Convert.ToDecimal(value)),
_ => null
};

if (defaultNode is not null)
{
schema["default"] = defaultNode;
}
}

private static readonly ConcurrentDictionary<Type, object?> _instances = new();

private static void Mount(
JsonObject root,
JsonNode schema,
Expand Down
4 changes: 3 additions & 1 deletion src/Confix.Runner/ValidationRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ private static (List<string> Errors, JsonObject? Schema) Validate(string[] args,
var errors = ContractValidation.Validate(host.Services, configuration, strict).ToList();
CheckSectionObjects(document, contracts, errors);

var schema = errors.Count == 0 && payload["exportSchema"]?.GetValue<bool>() == true
// The schema comes from the contracts alone, so scaffolding tooling can request it
// precisely when the document is still invalid.
var schema = payload["exportSchema"]?.GetValue<bool>() == true
? SchemaExport.Export(contracts, strict)
: null;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Confix.Tool.Entities.Components.DotNet;
using Confix.Tool.Reporting;
using Confix.Tool.Validation;
using Confix.Utilities;
using Microsoft.Extensions.DependencyInjection;

Expand All @@ -15,6 +16,8 @@ public static ConfixCommandLineBuilder RegisterProjectMiddlewares(this ConfixCom
sp.GetRequiredService<ISchemaStore>()))
.AddTransient(sp =>
new InitializeConfigurationDefaultValues(sp.GetRequiredService<ISchemaStore>()))
.AddTransient(sp =>
new RestoreDotnetSchemaMiddleware(sp.GetRequiredService<ISchemaStore>()))
.AddTransient<BuildComponentsOfProjectMiddleware>()
.AddTransient<InitProjectMiddleware>()
.AddTransient<BuildProjectMiddleware>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Confix.Tool.Entities.Components.DotNet;
using Confix.Tool.Middlewares.JsonSchemas;
using Confix.Tool.Schema;
using Confix.Tool.Validation;
using Confix.Variables;

namespace Confix.Tool.Middlewares.Project;
Expand Down Expand Up @@ -41,6 +42,32 @@ public async Task InvokeAsync(IMiddlewareContext context, MiddlewareDelegate nex
var project = configuration.EnsureProject();
var solution = configuration.EnsureSolution();

// Code-first projects derive their schema from the option contracts instead of components.
var validation = DotnetOptionsSchemaComposer.ResolveSettings(context);

if (validation?.EffectiveType == ValidationConfiguration.DotnetOptions)
{
var storedSchema = await DotnetOptionsSchemaComposer.ComposeAndStoreAsync(
context, validation, _schemaStore);

jsonSchemas.Schemas.Add(new JsonSchemaDefinition
{
Project = project,
Solution = solution.Directory!,
FileMatch = files
.Select(x => x.InputFile.RelativeTo(solution.Directory!))
.ToList(),
SchemaFile = storedSchema,
RelativePathToProject = Path.GetRelativePath(
solution.Directory!.FullName,
project.Directory!.FullName)
});

context.Logger.LogSchemaCompositionCompleted(project);

return;
}

context.SetStatus("Loading components...");
var components = await context.Features.Get<ComponentProviderExecutorFeature>()
.Executor.LoadComponents(solution, project, cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ public static async Task DispatchAsync(
await continuePipeline(current);
})
.Use<VariableMiddleware>()
// Build scaffolds missing sections from the contract schema before validating.
.When(_ => write, scaffold => scaffold
.Use<RestoreDotnetSchemaMiddleware>()
.Use<InitializeConfigurationDefaultValues>())
.Use<BuildProjectMiddleware>()
.Use((current, _) => ValidateAsync(current, validator, settings, write, exportSchema)))
.ExecuteAsync(context);
Expand Down
220 changes: 220 additions & 0 deletions src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
using System.Text.Json.Nodes;
using Confix.Tool.Commands.Logging;
using Confix.Tool.Common.Pipelines;

namespace Confix.Tool.Validation;

/// <summary>Builds the code-first host project and exchanges requests with its runner.</summary>
internal static class DotnetOptionsRunner
{
public const int ProtocolVersion = 1;
private const string RunnerAssembly = "Confix.Runner.dll";
private const string DefaultConfiguration = "Debug";

public static string ResolveRunner()
{
var runner = Path.Combine(AppContext.BaseDirectory, RunnerAssembly);

if (!File.Exists(runner))
{
throw new ExitException("The Confix installation is missing its validation runner.");
}

return runner;
}

/// <summary>
/// Builds and resolves the target once per pipeline run; schema derivation and validation
/// share the same build.
/// </summary>
public static async Task<ValidationTarget> EnsureTargetAsync(
IMiddlewareContext context,
string directory,
string? framework)
{
if (context.Features.TryGet(out DotnetValidationTargetFeature? cached))
{
return cached.Target;
}

var projects = Directory.GetFiles(directory, "*.csproj");

if (projects.Length != 1)
{
throw new ExitException(
"Code-first validation requires exactly one project file in the project directory.");
}

context.Parameter.TryGet(
DotnetConfigurationOptions.Instance,
out string? configuration);

if (string.IsNullOrWhiteSpace(configuration))
{
configuration = DefaultConfiguration;
}

await BuildAsync(context, directory, projects[0], configuration, framework);

var target = await ResolveTargetAsync(
context, directory, projects[0], configuration, framework);

context.Features.Set(new DotnetValidationTargetFeature(target));

return target;
}

public static async Task<RunnerResponse> InvokeAsync(
IMiddlewareContext context,
ValidationTarget target,
string directory,
JsonObject request)
{
string[] arguments =
[
"exec",
"--runtimeconfig", target.RuntimeConfig,
"--depsfile", target.Dependencies,
ResolveRunner(),
target.Assembly
];

var result = await DotnetValidationProcess.RunAsync(
arguments,
request.ToJsonString(),
directory,
context.CancellationToken);

JsonNode? response;

try
{
response = JsonNode.Parse(result.Output);
}
catch (System.Text.Json.JsonException)
{
throw new ExitException("The code-first runner returned an invalid response.");
}

if (response?["version"]?.GetValue<int>() != ProtocolVersion)
{
throw new ExitException("Unsupported code-first runner protocol.");
}

var errors = response["errors"]?.AsArray()
?? throw new ExitException("Invalid code-first validation response.");

return new RunnerResponse(
result.ExitCode,
errors.Select(e => e!.GetValue<string>()).ToArray(),
response["schema"]?.DeepClone());
}

private static async Task BuildAsync(
IMiddlewareContext context,
string directory,
string project,
string configuration,
string? framework)
{
var arguments = new List<string>
{
"build",
project,
"--configuration", configuration,
"--nologo",
"-v:q",
"--disable-build-servers"
};

if (framework is not null)
{
arguments.AddRange(["--framework", framework]);
}

context.Logger.Information("Building code-first validation contracts.");

var build = await DotnetValidationProcess.RunAsync(
arguments,
null,
directory,
context.CancellationToken);

if (build.ExitCode != 0)
{
throw new ExitException(
"Code-first project build failed. " +
"Run dotnet build on the project for compiler diagnostics.");
}

context.Logger.Debug("Code-first project build completed.");
}

private static async Task<ValidationTarget> ResolveTargetAsync(
IMiddlewareContext context,
string directory,
string project,
string configuration,
string? framework)
{
var arguments = new List<string>
{
"msbuild",
project,
"-nologo",
"-nodeReuse:false",
"-getProperty:TargetPath,TargetFramework,TargetFrameworks",
"-property:Configuration=" + configuration
};

if (framework is not null)
{
arguments.Add("-property:TargetFramework=" + framework);
}

var result = await DotnetValidationProcess.RunAsync(
arguments,
null,
directory,
context.CancellationToken);

if (result.ExitCode != 0)
{
throw new ExitException("Could not determine the code-first target output.");
}

var properties = JsonNode.Parse(result.Output)?["Properties"];

if (string.IsNullOrEmpty(properties?["TargetFramework"]?.GetValue<string>()))
{
throw new ExitException("Set project.validation.framework for a multi-target project.");
}

var assembly = properties["TargetPath"]!.GetValue<string>();
var runtimeConfig = Path.ChangeExtension(assembly, ".runtimeconfig.json");
var dependencies = Path.ChangeExtension(assembly, ".deps.json");

if (!File.Exists(runtimeConfig) || !File.Exists(dependencies))
{
throw new ExitException(
"Code-first validation requires a host project that produces " +
"runtimeconfig.json and deps.json.");
}

context.Logger.Debug("Code-first target selected: " + assembly);

return new ValidationTarget(assembly, runtimeConfig, dependencies);
}
}

internal sealed record ValidationTarget(
string Assembly,
string RuntimeConfig,
string Dependencies);

internal sealed record RunnerResponse(
int ExitCode,
IReadOnlyList<string> Errors,
JsonNode? Schema);

internal sealed record DotnetValidationTargetFeature(ValidationTarget Target);
Loading
Loading