Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# [vNext]

## Improvements:

* Refactored Formatters Configuration deserialization to align with Reqnroll JsonConfig. Created typed configuration settings object. Breaking change for custom Formatter implementers.
## Bug fixes:

*Contributors of this release (in alphabetical order):*
*Contributors of this release (in alphabetical order):* @clrudolphi

# v3.3.3 - 2026-01-27

Expand Down
18 changes: 18 additions & 0 deletions Reqnroll/Configuration/JsonConfig/FormatterOptionsElement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Reqnroll.Configuration.JsonConfig
{
public class FormatterOptionsElement
{
[JsonPropertyName("outputFilePath")]
public string OutputFilePath { get; set; }

/// <summary>
/// Captures any additional options not explicitly defined above.
/// </summary>
[JsonExtensionData]
public Dictionary<string, JsonElement> AdditionalOptions { get; set; }
}
}
21 changes: 21 additions & 0 deletions Reqnroll/Configuration/JsonConfig/FormattersElement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Reqnroll.Configuration.JsonConfig
{
public class FormattersElement
{
[JsonPropertyName("html")]
public FormatterOptionsElement Html { get; set; }

[JsonPropertyName("message")]
public FormatterOptionsElement Message { get; set; }

/// <summary>
/// Captures any additional/custom formatters not explicitly defined above.
/// </summary>
[JsonExtensionData]
public Dictionary<string, JsonElement> AdditionalFormatters { get; set; }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this not a Dictionary<string,FormatterOptionsElement>?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't found a way to do that directly. The Json source generator limits extension data to JsonElements, JsonNodes, or objects.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But we could make the whole thing as a Dictionary<string,FormatterOptionsElement>, right? That would implicitly contain html and message as keys, but we could make prop getters to access them easily.

}
}
3 changes: 3 additions & 0 deletions Reqnroll/Configuration/JsonConfig/JsonConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,8 @@ public class JsonConfig

[JsonPropertyName("bindingAssemblies")]
public List<StepAssemblyElement> BindingAssemblies { get; set; }

[JsonPropertyName("formatters")]
public FormattersElement Formatters { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ namespace Reqnroll.Configuration.JsonConfig;
Converters = [typeof(CustomTimeSpanConverter)],
ReadCommentHandling = JsonCommentHandling.Skip)] // the user can comment his used configuration value
[JsonSerializable(typeof(JsonConfig))]
[JsonSerializable(typeof(FormattersElement))]
[JsonSerializable(typeof(FormatterOptionsElement))]
internal partial class JsonConfigurationSourceGenerator : JsonSerializerContext
{

Expand Down
41 changes: 26 additions & 15 deletions Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
using Reqnroll.Formatters.RuntimeSupport;
using Reqnroll.Utils;
using System;
using System.Collections.Generic;
using System.Text.Json;

namespace Reqnroll.Formatters.Configuration;

public class FileBasedConfigurationResolver : FormattersConfigurationResolverBase, IFileBasedConfigurationResolver
public class FileBasedConfigurationResolver : IFileBasedConfigurationResolver
{
private readonly IReqnrollJsonLocator _configFileLocator;
private readonly IFileSystem _fileSystem;
Expand All @@ -26,7 +27,29 @@ public FileBasedConfigurationResolver(
_log = log;
}

protected override JsonDocument GetJsonDocument()
/// <summary>
/// File-based configuration replaces entirely (does not merge with previous settings).
/// </summary>
public bool ShouldMergeSettings => false;

public IDictionary<string, FormatterConfiguration> Resolve()
{
var jsonContent = GetJsonContent();
if (jsonContent == null)
return new Dictionary<string, FormatterConfiguration>(StringComparer.OrdinalIgnoreCase);

try
{
return FormattersConfigExtractor.ExtractFormatters(jsonContent);
}
catch (JsonException ex)
{
_log?.WriteMessage($"Failed to parse formatters configuration: {ex.Message}");
return new Dictionary<string, FormatterConfiguration>(StringComparer.OrdinalIgnoreCase);
}
}

private string GetJsonContent()
{
try
{
Expand Down Expand Up @@ -71,19 +94,7 @@ protected override JsonDocument GetJsonDocument()
return null;
}

try
{
return JsonDocument.Parse(jsonFileContent, new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true // More lenient parsing
});
}
catch (JsonException ex)
{
_log?.WriteMessage($"Failed to parse JSON from file '{fileName}': {ex.Message}");
return null;
}
return jsonFileContent;
}
catch (Exception ex)
{
Expand Down
133 changes: 133 additions & 0 deletions Reqnroll/Formatters/Configuration/FormatterConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#nullable enable

using System;
using System.Collections.Generic;

namespace Reqnroll.Formatters.Configuration;

/// <summary>
/// Represents the configuration for a formatter with type-safe access to known properties
/// and extensibility for custom settings.
/// </summary>
public class FormatterConfiguration
{
/// <summary>
/// The output file path for the formatter. May be null if not configured.
/// </summary>
public string? OutputFilePath { get; set; }
Comment thread
gasparnagy marked this conversation as resolved.

/// <summary>
/// Additional settings for the formatter that are not explicitly defined as properties.
/// </summary>
public IDictionary<string, object> AdditionalSettings { get; set; } = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);

/// <summary>
/// Creates a FormatterConfiguration from a dictionary representation.
/// </summary>
/// <param name="dictionary">The dictionary containing formatter configuration values.</param>
/// <returns>A new FormatterConfiguration instance, or null if the dictionary is null.</returns>
public static FormatterConfiguration? FromDictionary(IDictionary<string, object>? dictionary)
{
if (dictionary == null)
return null;

var config = new FormatterConfiguration();
var additionalSettings = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);

foreach (var kvp in dictionary)
{
if (string.Equals(kvp.Key, "outputFilePath", StringComparison.OrdinalIgnoreCase))
{
config.OutputFilePath = kvp.Value?.ToString();
}
else if (kvp.Value != null)
{
additionalSettings[kvp.Key] = kvp.Value;
}
}

config.AdditionalSettings = additionalSettings;
return config;
}

/// <summary>
/// Converts this FormatterConfiguration back to a dictionary representation.
/// Used for backward compatibility with legacy APIs.
/// </summary>
/// <returns>A dictionary containing all configuration values.</returns>
public IDictionary<string, object> ToDictionary()
{
var result = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);

if (OutputFilePath != null)
{
result["outputFilePath"] = OutputFilePath;
}

foreach (var kvp in AdditionalSettings)
{
result[kvp.Key] = kvp.Value;
}

return result;
}

/// <summary>
/// Gets a configuration value by key, checking both known properties and additional settings.
/// </summary>
/// <typeparam name="T">The expected type of the value.</typeparam>
/// <param name="key">The configuration key.</param>
/// <param name="defaultValue">The default value if the key is not found.</param>
/// <returns>The configuration value or the default value.</returns>
public T? GetValue<T>(string key, T? defaultValue = default)
{
if (string.Equals(key, "outputFilePath", StringComparison.OrdinalIgnoreCase))
{
if (OutputFilePath is T typedValue)
return typedValue;
return defaultValue;
}

if (AdditionalSettings.TryGetValue(key, out var value))
{
if (value is T typedValue)
return typedValue;

// Try conversion for common types
try
{
return (T)Convert.ChangeType(value, typeof(T));
}
catch
{
return defaultValue;
}
}

return defaultValue;
}

/// <summary>
/// Merges settings from another FormatterConfiguration into this one.
/// Only non-null values from the other configuration will override values in this configuration.
/// This allows partial overrides where only specified settings are changed.
/// </summary>
/// <param name="other">The configuration to merge from. Null values are ignored.</param>
public void MergeFrom(FormatterConfiguration? other)
{
if (other == null)
return;

// Only override OutputFilePath if the other configuration has it set
if (other.OutputFilePath != null)
{
OutputFilePath = other.OutputFilePath;
}

// Merge additional settings - other's values override this's values
foreach (var kvp in other.AdditionalSettings)
{
AdditionalSettings[kvp.Key] = kvp.Value;
}
}
}
Loading
Loading