Skip to content

Commit 0cd8333

Browse files
authored
Merge pull request #77 from tgiachi/feature/yaml-serializer-conventions
feat(yaml): YamlDataSerializer and configurable naming conventions
2 parents 2fc03de + 3ed56ae commit 0cd8333

19 files changed

Lines changed: 557 additions & 43 deletions

File tree

docs/articles/guides/configuration.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,75 @@ myService:
7474
retries: 5
7575
```
7676
77+
## YAML naming conventions
78+
79+
Section property keys are bound using a `YamlNamingConventionType` (`SquidStd.Core.Types.Yaml`); section
80+
names themselves are never affected - they are matched exactly as registered (the `sectionName` argument
81+
of `RegisterConfigSection`, e.g. `myService` above).
82+
83+
| `YamlNamingConventionType` | Example key for `MaxRetryCount` |
84+
|-----------------------------|----------------------------------|
85+
| `PascalCase` (default) | `MaxRetryCount` |
86+
| `CamelCase` | `maxRetryCount` |
87+
| `SnakeCase` | `max_retry_count` |
88+
| `KebabCase` | `max-retry-count` |
89+
| `LowerCase` | `maxretrycount` |
90+
91+
The convention is fixed at load time and applies to every section bound from that `SquidStdConfig`:
92+
93+
- **Through the bootstrap** - `SquidStdOptions.YamlNamingConvention` (default `PascalCase`) is passed to
94+
the internal `SquidStdConfig.Load(configName, configDirectory, convention)` call the bootstrap makes for
95+
you. It has no effect when you supply a pre-loaded `SquidStdConfig` via
96+
`SquidStdBootstrap.Create(SquidStdConfig, SquidStdOptions)` (see the two-phase setup below) - that
97+
config's own convention always applies, since the file was already loaded before `Create` saw the options.
98+
- **Loading `SquidStdConfig` yourself** - pass the convention as the third argument:
99+
`SquidStdConfig.Load("squidstd", "~/.squidstd", YamlNamingConventionType.SnakeCase)`.
100+
- **Registering against a container directly** - `RegisterConfigServices(configName, configDirectory,
101+
convention)` and `RegisterConfigManagerService(configName, configDirectory, convention)` both accept the
102+
same trailing `convention` parameter (default `PascalCase`).
103+
104+
Every path through `SquidStdConfig` - `GetSection`, `BindSection`, `HasSection`, `Compose`, `Save` - uses
105+
the convention recorded at `Load` time, so binding a section and later re-serializing it with `Compose()`
106+
or `Save()` round-trip through the same casing.
107+
108+
A `squidstd.yaml` written in `SnakeCase`:
109+
110+
```yaml
111+
network:
112+
bind_address: 0.0.0.0
113+
max_connections: 128
114+
```
115+
116+
binds against a plain, PascalCase-declared C# type once the option is set:
117+
118+
```csharp
119+
public sealed class NetworkConfig
120+
{
121+
public string BindAddress { get; set; } = string.Empty;
122+
public int MaxConnections { get; set; }
123+
}
124+
125+
var bootstrap = SquidStdBootstrap.Create(
126+
new SquidStdOptions
127+
{
128+
ConfigName = "squidstd",
129+
RootDirectory = AppContext.BaseDirectory,
130+
YamlNamingConvention = YamlNamingConventionType.SnakeCase
131+
});
132+
133+
bootstrap.ConfigureServices(container =>
134+
{
135+
container.RegisterConfigSection("network", static () => new NetworkConfig());
136+
return container;
137+
});
138+
```
139+
140+
The `network` section name is untouched by the convention - only its property keys
141+
(`BindAddress`/`bind_address`, `MaxConnections`/`max_connections`) are. `YamlUtils.Deserialize`,
142+
`Serialize`, `SerializeSections`, and the standalone `YamlDataSerializer` (the `IDataSerializer` /
143+
`IDataDeserializer` implementation behind `RegisterYamlDataSerializer()`) accept the same enum as a
144+
trailing parameter and default to `PascalCase` too.
145+
77146
## Four ways to configure a service
78147

79148
Sectioned registrations (`RegisterEventLoop`, `RegisterJobSystemService`,

docs/articles/scheduler.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ var loop = container.Resolve<IEventLoopService>();
5454
Console.WriteLine($"{loop.TickCount} ticks, avg {loop.AverageTickMs:0.###} ms");
5555
```
5656

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

6060
```yaml
6161
eventLoop:

src/SquidStd.AspNetCore/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ builder.UseSquidStd(options => options.ConfigName = "squidstd", c => c.RegisterC
6969
builder.AddSquidStdSerilog();
7070
```
7171

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

7475
```yaml
7576
logger:

src/SquidStd.Core/Config/SquidStdConfig.cs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using SquidStd.Core.Config.Internal;
22
using SquidStd.Core.Extensions.Directories;
33
using SquidStd.Core.Interfaces.Config;
4+
using SquidStd.Core.Types.Yaml;
45
using SquidStd.Core.Yaml;
56

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

29+
/// <summary>
30+
/// Gets the naming convention applied to YAML property keys when binding, composing and
31+
/// saving sections. Section names are always matched exactly as registered.
32+
/// </summary>
33+
public YamlNamingConventionType NamingConvention { get; }
34+
2835
/// <summary>Gets the tracked sections (bound and unbound), ordered by priority then name.</summary>
2936
public IReadOnlyCollection<IConfigEntry> Entries
3037
{
@@ -37,12 +44,13 @@ public IReadOnlyCollection<IConfigEntry> Entries
3744
}
3845
}
3946

40-
private SquidStdConfig(string configName, string configDirectory, string rawYaml)
47+
private SquidStdConfig(string configName, string configDirectory, string rawYaml, YamlNamingConventionType convention)
4148
{
4249
ConfigName = configName;
4350
ConfigDirectory = configDirectory;
4451
ConfigPath = ResolveConfigPath(configName, configDirectory);
4552
_rawYaml = rawYaml;
53+
NamingConvention = convention;
4654
}
4755

4856
/// <summary>
@@ -52,8 +60,13 @@ private SquidStdConfig(string configName, string configDirectory, string rawYaml
5260
/// </summary>
5361
/// <param name="configName">Logical configuration name or YAML file name.</param>
5462
/// <param name="configDirectory">Directory where the configuration file is searched.</param>
63+
/// <param name="convention">The naming convention applied to YAML property keys.</param>
5564
/// <returns>The loaded configuration.</returns>
56-
public static SquidStdConfig Load(string configName, string configDirectory)
65+
public static SquidStdConfig Load(
66+
string configName,
67+
string configDirectory,
68+
YamlNamingConventionType convention = YamlNamingConventionType.PascalCase
69+
)
5770
{
5871
ArgumentException.ThrowIfNullOrWhiteSpace(configName);
5972
ArgumentException.ThrowIfNullOrWhiteSpace(configDirectory);
@@ -62,7 +75,7 @@ public static SquidStdConfig Load(string configName, string configDirectory)
6275
var path = ResolveConfigPath(configName, resolvedDirectory);
6376
var raw = File.Exists(path) ? File.ReadAllText(path) : string.Empty;
6477

65-
return new(configName, resolvedDirectory, raw);
78+
return new(configName, resolvedDirectory, raw, convention);
6679
}
6780

6881
/// <summary>
@@ -134,7 +147,7 @@ public bool HasSection(string sectionName)
134147
lock (_sync)
135148
{
136149
return !string.IsNullOrWhiteSpace(_rawYaml) &&
137-
YamlUtils.DeserializeSection(_rawYaml, sectionName, typeof(Dictionary<object, object>)) is not null;
150+
YamlUtils.DeserializeSection(_rawYaml, sectionName, typeof(Dictionary<object, object>), NamingConvention) is not null;
138151
}
139152
}
140153

@@ -181,7 +194,7 @@ public string Compose()
181194
{
182195
lock (_sync)
183196
{
184-
return YamlUtils.SerializeSections(BuildSectionMap());
197+
return YamlUtils.SerializeSections(BuildSectionMap(), NamingConvention);
185198
}
186199
}
187200

@@ -192,7 +205,7 @@ public void Save()
192205
{
193206
lock (_sync)
194207
{
195-
YamlUtils.SerializeToFile(BuildSectionMap(), ConfigPath);
208+
YamlUtils.SerializeToFile(BuildSectionMap(), ConfigPath, NamingConvention);
196209
}
197210
}
198211

@@ -223,7 +236,7 @@ private object Bind(ConfigSectionEntry entry)
223236
{
224237
var value = string.IsNullOrWhiteSpace(_rawYaml)
225238
? entry.CreateDefault()
226-
: YamlUtils.DeserializeSection(_rawYaml, entry.SectionName, entry.ConfigType) ??
239+
: YamlUtils.DeserializeSection(_rawYaml, entry.SectionName, entry.ConfigType, NamingConvention) ??
227240
entry.CreateDefault();
228241

229242
ConfigEnvSubstitution.Apply(value);

src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using SquidStd.Core.Types.Yaml;
2+
13
namespace SquidStd.Core.Data.Bootstrap;
24

35
/// <summary>
@@ -39,4 +41,12 @@ public sealed class SquidStdOptions
3941
/// (snake_case on disk, same rule as directory path resolution).
4042
/// </summary>
4143
public string[] Directories { get; set; } = [];
44+
45+
/// <summary>
46+
/// Naming convention for YAML property keys in the configuration file (PascalCase by
47+
/// default). Section names are always matched exactly as registered. Ignored when a
48+
/// pre-loaded SquidStdConfig is supplied via Create(SquidStdConfig, SquidStdOptions) -
49+
/// that config's own NamingConvention applies.
50+
/// </summary>
51+
public YamlNamingConventionType YamlNamingConvention { get; set; } = YamlNamingConventionType.PascalCase;
4252
}

src/SquidStd.Core/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,18 @@ dotnet add package SquidStd.Core
1616

1717
```csharp
1818
using SquidStd.Core.Extensions.Env;
19+
using SquidStd.Core.Types.Yaml;
1920
using SquidStd.Core.Yaml;
2021

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

2425
// Serialize / deserialize YAML.
2526
var yaml = YamlUtils.Serialize(new { name = "squid", port = 9000 });
27+
28+
// IDataSerializer/IDataDeserializer over YAML, with a configurable naming convention
29+
// (PascalCase by default) and a strict mode that rejects unknown keys.
30+
var yamlSerializer = new YamlDataSerializer(YamlNamingConventionType.SnakeCase, ignoreUnmatchedProperties: false);
2631
```
2732

2833
```csharp
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
namespace SquidStd.Core.Types.Yaml;
2+
3+
/// <summary>
4+
/// Naming convention applied to YAML property keys during serialization and deserialization.
5+
/// Section names in configuration files are dictionary keys and are never affected.
6+
/// </summary>
7+
public enum YamlNamingConventionType
8+
{
9+
/// <summary>Properties as declared (C# convention makes this PascalCase). The default.</summary>
10+
PascalCase,
11+
12+
/// <summary>camelCase keys.</summary>
13+
CamelCase,
14+
15+
/// <summary>snake_case keys.</summary>
16+
SnakeCase,
17+
18+
/// <summary>kebab-case keys.</summary>
19+
KebabCase,
20+
21+
/// <summary>lowercase keys.</summary>
22+
LowerCase
23+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using System.Text;
2+
using SquidStd.Core.Interfaces.Serialization;
3+
using SquidStd.Core.Types.Yaml;
4+
using YamlDotNet.Serialization;
5+
6+
namespace SquidStd.Core.Yaml;
7+
8+
/// <summary>
9+
/// YAML data serializer based on YamlDotNet. Implements both <see cref="IDataSerializer" />
10+
/// and <see cref="IDataDeserializer" /> with a configurable property naming convention and an
11+
/// optional strict mode that rejects unknown YAML keys.
12+
/// </summary>
13+
public sealed class YamlDataSerializer : IDataSerializer, IDataDeserializer
14+
{
15+
private readonly IDeserializer _deserializer;
16+
private readonly ISerializer _serializer;
17+
18+
/// <summary>
19+
/// Initializes the serializer.
20+
/// </summary>
21+
/// <param name="convention">Property naming convention (PascalCase-as-declared by default).</param>
22+
/// <param name="ignoreUnmatchedProperties">
23+
/// When true (default) unknown YAML keys are ignored; when false they fail deserialization.
24+
/// </param>
25+
public YamlDataSerializer(
26+
YamlNamingConventionType convention = YamlNamingConventionType.PascalCase,
27+
bool ignoreUnmatchedProperties = true
28+
)
29+
{
30+
var namingConvention = YamlNamingConventions.Resolve(convention);
31+
32+
_serializer = new SerializerBuilder()
33+
.DisableAliases()
34+
.WithIndentedSequences()
35+
.WithNamingConvention(namingConvention)
36+
.Build();
37+
38+
var deserializerBuilder = new DeserializerBuilder().WithNamingConvention(namingConvention);
39+
40+
if (ignoreUnmatchedProperties)
41+
{
42+
deserializerBuilder = deserializerBuilder.IgnoreUnmatchedProperties();
43+
}
44+
45+
_deserializer = deserializerBuilder.Build();
46+
}
47+
48+
/// <inheritdoc />
49+
public T Deserialize<T>(ReadOnlyMemory<byte> data)
50+
{
51+
var yaml = Encoding.UTF8.GetString(data.Span);
52+
53+
if (string.IsNullOrWhiteSpace(yaml))
54+
{
55+
throw new InvalidOperationException($"Cannot deserialize empty or whitespace-only YAML for type {typeof(T).Name}.");
56+
}
57+
58+
return _deserializer.Deserialize<T>(yaml) ??
59+
throw new InvalidOperationException($"Deserialization returned null for type {typeof(T).Name}.");
60+
}
61+
62+
/// <inheritdoc />
63+
public ReadOnlyMemory<byte> Serialize<T>(T value)
64+
=> Encoding.UTF8.GetBytes(_serializer.Serialize(value));
65+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using SquidStd.Core.Types.Yaml;
2+
using YamlDotNet.Serialization;
3+
using YamlDotNet.Serialization.NamingConventions;
4+
5+
namespace SquidStd.Core.Yaml;
6+
7+
/// <summary>
8+
/// Resolves a <see cref="YamlNamingConventionType" /> to the corresponding YamlDotNet
9+
/// <see cref="INamingConvention" /> instance.
10+
/// </summary>
11+
internal static class YamlNamingConventions
12+
{
13+
/// <summary>
14+
/// Resolves the YamlDotNet naming convention for the given <see cref="YamlNamingConventionType" />.
15+
/// </summary>
16+
/// <param name="convention">The naming convention to resolve.</param>
17+
/// <returns>The matching YamlDotNet <see cref="INamingConvention" />.</returns>
18+
public static INamingConvention Resolve(YamlNamingConventionType convention)
19+
=> convention switch
20+
{
21+
YamlNamingConventionType.CamelCase => CamelCaseNamingConvention.Instance,
22+
YamlNamingConventionType.SnakeCase => UnderscoredNamingConvention.Instance,
23+
YamlNamingConventionType.KebabCase => HyphenatedNamingConvention.Instance,
24+
YamlNamingConventionType.LowerCase => LowerCaseNamingConvention.Instance,
25+
_ => NullNamingConvention.Instance
26+
};
27+
}

0 commit comments

Comments
 (0)