Skip to content
Merged
69 changes: 69 additions & 0 deletions docs/articles/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,75 @@ myService:
retries: 5
```

## YAML naming conventions

Section property keys are bound using a `YamlNamingConventionType` (`SquidStd.Core.Types.Yaml`); section
names themselves are never affected - they are matched exactly as registered (the `sectionName` argument
of `RegisterConfigSection`, e.g. `myService` above).

| `YamlNamingConventionType` | Example key for `MaxRetryCount` |
|-----------------------------|----------------------------------|
| `PascalCase` (default) | `MaxRetryCount` |
| `CamelCase` | `maxRetryCount` |
| `SnakeCase` | `max_retry_count` |
| `KebabCase` | `max-retry-count` |
| `LowerCase` | `maxretrycount` |

The convention is fixed at load time and applies to every section bound from that `SquidStdConfig`:

- **Through the bootstrap** - `SquidStdOptions.YamlNamingConvention` (default `PascalCase`) is passed to
the internal `SquidStdConfig.Load(configName, configDirectory, convention)` call the bootstrap makes for
you. It has no effect when you supply a pre-loaded `SquidStdConfig` via
`SquidStdBootstrap.Create(SquidStdConfig, SquidStdOptions)` (see the two-phase setup below) - that
config's own convention always applies, since the file was already loaded before `Create` saw the options.
- **Loading `SquidStdConfig` yourself** - pass the convention as the third argument:
`SquidStdConfig.Load("squidstd", "~/.squidstd", YamlNamingConventionType.SnakeCase)`.
- **Registering against a container directly** - `RegisterConfigServices(configName, configDirectory,
convention)` and `RegisterConfigManagerService(configName, configDirectory, convention)` both accept the
same trailing `convention` parameter (default `PascalCase`).

Every path through `SquidStdConfig` - `GetSection`, `BindSection`, `HasSection`, `Compose`, `Save` - uses
the convention recorded at `Load` time, so binding a section and later re-serializing it with `Compose()`
or `Save()` round-trip through the same casing.

A `squidstd.yaml` written in `SnakeCase`:

```yaml
network:
bind_address: 0.0.0.0
max_connections: 128
```

binds against a plain, PascalCase-declared C# type once the option is set:

```csharp
public sealed class NetworkConfig
{
public string BindAddress { get; set; } = string.Empty;
public int MaxConnections { get; set; }
}

var bootstrap = SquidStdBootstrap.Create(
new SquidStdOptions
{
ConfigName = "squidstd",
RootDirectory = AppContext.BaseDirectory,
YamlNamingConvention = YamlNamingConventionType.SnakeCase
});

bootstrap.ConfigureServices(container =>
{
container.RegisterConfigSection("network", static () => new NetworkConfig());
return container;
});
```

The `network` section name is untouched by the convention - only its property keys
(`BindAddress`/`bind_address`, `MaxConnections`/`max_connections`) are. `YamlUtils.Deserialize`,
`Serialize`, `SerializeSections`, and the standalone `YamlDataSerializer` (the `IDataSerializer` /
`IDataDeserializer` implementation behind `RegisterYamlDataSerializer()`) accept the same enum as a
trailing parameter and default to `PascalCase` too.

## Four ways to configure a service

Sectioned registrations (`RegisterEventLoop`, `RegisterJobSystemService`,
Expand Down
4 changes: 2 additions & 2 deletions docs/articles/scheduler.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ var loop = container.Resolve<IEventLoopService>();
Console.WriteLine($"{loop.TickCount} ticks, avg {loop.AverageTickMs:0.###} ms");
```

Configure it via the `eventLoop` section (section keys are matched as registered, property names are
PascalCase):
Configure it via the `eventLoop` section (section keys are matched as registered, property names follow
the configured convention - PascalCase by default):

```yaml
eventLoop:
Expand Down
3 changes: 2 additions & 1 deletion src/SquidStd.AspNetCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ builder.UseSquidStd(options => options.ConfigName = "squidstd", c => c.RegisterC
builder.AddSquidStdSerilog();
```

The logger is driven entirely by `squidstd.yaml` (keys are PascalCase and case-sensitive):
The logger is driven entirely by `squidstd.yaml` (keys follow the configured convention - PascalCase by
default - and are case-sensitive):

```yaml
logger:
Expand Down
27 changes: 20 additions & 7 deletions src/SquidStd.Core/Config/SquidStdConfig.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using SquidStd.Core.Config.Internal;
using SquidStd.Core.Extensions.Directories;
using SquidStd.Core.Interfaces.Config;
using SquidStd.Core.Types.Yaml;
using SquidStd.Core.Yaml;

namespace SquidStd.Core.Config;
Expand All @@ -25,6 +26,12 @@ public sealed class SquidStdConfig
/// <summary>Gets the full path of the YAML configuration file.</summary>
public string ConfigPath { get; }

/// <summary>
/// Gets the naming convention applied to YAML property keys when binding, composing and
/// saving sections. Section names are always matched exactly as registered.
/// </summary>
public YamlNamingConventionType NamingConvention { get; }

/// <summary>Gets the tracked sections (bound and unbound), ordered by priority then name.</summary>
public IReadOnlyCollection<IConfigEntry> Entries
{
Expand All @@ -37,12 +44,13 @@ public IReadOnlyCollection<IConfigEntry> Entries
}
}

private SquidStdConfig(string configName, string configDirectory, string rawYaml)
private SquidStdConfig(string configName, string configDirectory, string rawYaml, YamlNamingConventionType convention)
{
ConfigName = configName;
ConfigDirectory = configDirectory;
ConfigPath = ResolveConfigPath(configName, configDirectory);
_rawYaml = rawYaml;
NamingConvention = convention;
}

/// <summary>
Expand All @@ -52,8 +60,13 @@ private SquidStdConfig(string configName, string configDirectory, string rawYaml
/// </summary>
/// <param name="configName">Logical configuration name or YAML file name.</param>
/// <param name="configDirectory">Directory where the configuration file is searched.</param>
/// <param name="convention">The naming convention applied to YAML property keys.</param>
/// <returns>The loaded configuration.</returns>
public static SquidStdConfig Load(string configName, string configDirectory)
public static SquidStdConfig Load(
string configName,
string configDirectory,
YamlNamingConventionType convention = YamlNamingConventionType.PascalCase
)
{
ArgumentException.ThrowIfNullOrWhiteSpace(configName);
ArgumentException.ThrowIfNullOrWhiteSpace(configDirectory);
Expand All @@ -62,7 +75,7 @@ public static SquidStdConfig Load(string configName, string configDirectory)
var path = ResolveConfigPath(configName, resolvedDirectory);
var raw = File.Exists(path) ? File.ReadAllText(path) : string.Empty;

return new(configName, resolvedDirectory, raw);
return new(configName, resolvedDirectory, raw, convention);
}

/// <summary>
Expand Down Expand Up @@ -134,7 +147,7 @@ public bool HasSection(string sectionName)
lock (_sync)
{
return !string.IsNullOrWhiteSpace(_rawYaml) &&
YamlUtils.DeserializeSection(_rawYaml, sectionName, typeof(Dictionary<object, object>)) is not null;
YamlUtils.DeserializeSection(_rawYaml, sectionName, typeof(Dictionary<object, object>), NamingConvention) is not null;
}
}

Expand Down Expand Up @@ -181,7 +194,7 @@ public string Compose()
{
lock (_sync)
{
return YamlUtils.SerializeSections(BuildSectionMap());
return YamlUtils.SerializeSections(BuildSectionMap(), NamingConvention);
}
}

Expand All @@ -192,7 +205,7 @@ public void Save()
{
lock (_sync)
{
YamlUtils.SerializeToFile(BuildSectionMap(), ConfigPath);
YamlUtils.SerializeToFile(BuildSectionMap(), ConfigPath, NamingConvention);
}
}

Expand Down Expand Up @@ -223,7 +236,7 @@ private object Bind(ConfigSectionEntry entry)
{
var value = string.IsNullOrWhiteSpace(_rawYaml)
? entry.CreateDefault()
: YamlUtils.DeserializeSection(_rawYaml, entry.SectionName, entry.ConfigType) ??
: YamlUtils.DeserializeSection(_rawYaml, entry.SectionName, entry.ConfigType, NamingConvention) ??
entry.CreateDefault();

ConfigEnvSubstitution.Apply(value);
Expand Down
10 changes: 10 additions & 0 deletions src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using SquidStd.Core.Types.Yaml;

namespace SquidStd.Core.Data.Bootstrap;

/// <summary>
Expand Down Expand Up @@ -39,4 +41,12 @@ public sealed class SquidStdOptions
/// (snake_case on disk, same rule as directory path resolution).
/// </summary>
public string[] Directories { get; set; } = [];

/// <summary>
/// Naming convention for YAML property keys in the configuration file (PascalCase by
/// default). Section names are always matched exactly as registered. Ignored when a
/// pre-loaded SquidStdConfig is supplied via Create(SquidStdConfig, SquidStdOptions) -
/// that config's own NamingConvention applies.
/// </summary>
public YamlNamingConventionType YamlNamingConvention { get; set; } = YamlNamingConventionType.PascalCase;
}
5 changes: 5 additions & 0 deletions src/SquidStd.Core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ dotnet add package SquidStd.Core

```csharp
using SquidStd.Core.Extensions.Env;
using SquidStd.Core.Types.Yaml;
using SquidStd.Core.Yaml;

// Expand "$VAR" tokens against the environment (unknown vars are left untouched).
var path = "$HOME/squidstd/data".ReplaceEnv();

// Serialize / deserialize YAML.
var yaml = YamlUtils.Serialize(new { name = "squid", port = 9000 });

// IDataSerializer/IDataDeserializer over YAML, with a configurable naming convention
// (PascalCase by default) and a strict mode that rejects unknown keys.
var yamlSerializer = new YamlDataSerializer(YamlNamingConventionType.SnakeCase, ignoreUnmatchedProperties: false);
```

```csharp
Expand Down
23 changes: 23 additions & 0 deletions src/SquidStd.Core/Types/Yaml/YamlNamingConventionType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace SquidStd.Core.Types.Yaml;

/// <summary>
/// Naming convention applied to YAML property keys during serialization and deserialization.
/// Section names in configuration files are dictionary keys and are never affected.
/// </summary>
public enum YamlNamingConventionType
{
/// <summary>Properties as declared (C# convention makes this PascalCase). The default.</summary>
PascalCase,

/// <summary>camelCase keys.</summary>
CamelCase,

/// <summary>snake_case keys.</summary>
SnakeCase,

/// <summary>kebab-case keys.</summary>
KebabCase,

/// <summary>lowercase keys.</summary>
LowerCase
}
65 changes: 65 additions & 0 deletions src/SquidStd.Core/Yaml/YamlDataSerializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Text;
using SquidStd.Core.Interfaces.Serialization;
using SquidStd.Core.Types.Yaml;
using YamlDotNet.Serialization;

namespace SquidStd.Core.Yaml;

/// <summary>
/// YAML data serializer based on YamlDotNet. Implements both <see cref="IDataSerializer" />
/// and <see cref="IDataDeserializer" /> with a configurable property naming convention and an
/// optional strict mode that rejects unknown YAML keys.
/// </summary>
public sealed class YamlDataSerializer : IDataSerializer, IDataDeserializer
{
private readonly IDeserializer _deserializer;
private readonly ISerializer _serializer;

/// <summary>
/// Initializes the serializer.
/// </summary>
/// <param name="convention">Property naming convention (PascalCase-as-declared by default).</param>
/// <param name="ignoreUnmatchedProperties">
/// When true (default) unknown YAML keys are ignored; when false they fail deserialization.
/// </param>
public YamlDataSerializer(
YamlNamingConventionType convention = YamlNamingConventionType.PascalCase,
bool ignoreUnmatchedProperties = true
)
{
var namingConvention = YamlNamingConventions.Resolve(convention);

_serializer = new SerializerBuilder()
.DisableAliases()
.WithIndentedSequences()
.WithNamingConvention(namingConvention)
.Build();

var deserializerBuilder = new DeserializerBuilder().WithNamingConvention(namingConvention);

if (ignoreUnmatchedProperties)
{
deserializerBuilder = deserializerBuilder.IgnoreUnmatchedProperties();
}

_deserializer = deserializerBuilder.Build();
}

/// <inheritdoc />
public T Deserialize<T>(ReadOnlyMemory<byte> data)
{
var yaml = Encoding.UTF8.GetString(data.Span);

if (string.IsNullOrWhiteSpace(yaml))
{
throw new InvalidOperationException($"Cannot deserialize empty or whitespace-only YAML for type {typeof(T).Name}.");
}

return _deserializer.Deserialize<T>(yaml) ??
throw new InvalidOperationException($"Deserialization returned null for type {typeof(T).Name}.");
}

/// <inheritdoc />
public ReadOnlyMemory<byte> Serialize<T>(T value)
=> Encoding.UTF8.GetBytes(_serializer.Serialize(value));
}
27 changes: 27 additions & 0 deletions src/SquidStd.Core/Yaml/YamlNamingConventions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using SquidStd.Core.Types.Yaml;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;

namespace SquidStd.Core.Yaml;

/// <summary>
/// Resolves a <see cref="YamlNamingConventionType" /> to the corresponding YamlDotNet
/// <see cref="INamingConvention" /> instance.
/// </summary>
internal static class YamlNamingConventions
{
/// <summary>
/// Resolves the YamlDotNet naming convention for the given <see cref="YamlNamingConventionType" />.
/// </summary>
/// <param name="convention">The naming convention to resolve.</param>
/// <returns>The matching YamlDotNet <see cref="INamingConvention" />.</returns>
public static INamingConvention Resolve(YamlNamingConventionType convention)
=> convention switch
{
YamlNamingConventionType.CamelCase => CamelCaseNamingConvention.Instance,
YamlNamingConventionType.SnakeCase => UnderscoredNamingConvention.Instance,
YamlNamingConventionType.KebabCase => HyphenatedNamingConvention.Instance,
YamlNamingConventionType.LowerCase => LowerCaseNamingConvention.Instance,
_ => NullNamingConvention.Instance
};
}
Loading
Loading