Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Two paths into one emitter

The TraefikBundleGenerator doesn't write builder code itself. It produces a BuilderEmitModel and hands it to FrenchExDev.Net.Builder.SourceGenerator.Lib.BuilderEmitter.Emit(model) — the same emitter the DockerCompose generator and every other "build a config object fluently" generator in the FrenchExDev monorepo uses. There is exactly one WithFoo codegen path, regardless of which schema feeds it.

TraefikBuilderHelper is the bridge. It has two public entry points — one for standard model classes, one for discriminated-union types — and they hit the same emitter via different BuilderEmitModel shapes:

internal static class TraefikBuilderHelper
{
    public static BuilderEmitModel CreateBuilderModel(
        string ns, string className, List<UnifiedProperty> properties)
    {
        var builderProps = new List<BuilderPropertyModel>();
        foreach (var up in properties)
        {
            var prop = up.Property;
            var csharpType = TraefikNamingHelper.MapCSharpType(prop);
            var (isCollection, itemType) = DetectCollection(csharpType);
            var (isDict, dictKey, dictValue) = DetectDictionary(csharpType);
            var dictValueBuilder = isDict ? ResolveValueBuilderClassName(dictValue) : null;

            builderProps.Add(new BuilderPropertyModel(
                prop.CSharpName,
                csharpType,
                csharpType,
                isCollection,
                itemType,
                withMethodAttributes: BuildVersionAttributes(up),
                isDictionary: isDict,
                dictKeyTypeFull: dictKey,
                dictValueTypeFull: dictValue,
                dictValueBuilderClassName: dictValueBuilder,
                dictSingularName: dictValueBuilder is not null ? Singularize(prop.CSharpName) : null));
        }

        return new BuilderEmitModel(
            ns, className, className + "Builder", builderProps);
    }

    public static BuilderEmitModel CreateDiscriminatedBuilderModel(
        string ns, string className, List<DiscriminatedBranch> branches)
    {
        var builderProps = new List<BuilderPropertyModel>();
        var branchPropNames = new List<string>();
        foreach (var branch in branches)
        {
            var propName = TraefikNamingHelper.ToPascalCase(branch.PropertyName);
            var refClassName = TraefikNamingHelper.DefinitionToClassName(branch.RefName);
            var csharpType = $"{refClassName}?";
            branchPropNames.Add(propName);

            builderProps.Add(new BuilderPropertyModel(propName, csharpType, csharpType));
        }

        var epilogue = BuildExactlyOneBranchEpilogue(className, branchPropNames);

        return new BuilderEmitModel(
            ns, className, className + "Builder", builderProps,
            validateAsyncEpilogue: epilogue);
    }
    // …
}

The standard path discovers Dictionary<,> and List<> shapes, then asks the shared emitter to produce both a flat WithEntryPoints(dict) overload and a key-and-builder WithEntryPoint(string key, Action<…>) overload — that pair is the standard way to fluently configure dictionaries in the FrenchExDev builder library.

The discriminated path produces a flat list of WithBranch(BranchType?) setters and then attaches a custom epilogue to the BuilderEmitModel. The epilogue is a string of C# the shared BuilderEmitter will paste verbatim at the end of the generated ValidateAsync method.

The epilogue: __setCount == 1

This is the runtime defense for the flat-union pattern from Part 5. The whole epilogue builder is twelve lines:

/// <summary>
/// Emits a snippet that asserts exactly one of the discriminated-union
/// branch properties on the builder is non-null. Traefik silently rejects
/// configs where two branches of a flat union (e.g. AddPrefix + BasicAuth
/// on the same TraefikHttpMiddleware) are both populated; this surfaces
/// the error at BuildAsync time instead of at runtime.
/// </summary>
private static string BuildExactlyOneBranchEpilogue(string className, List<string> branchPropNames)
{
    var sb = new System.Text.StringBuilder();
    sb.AppendLine("        var __setCount = 0;");
    foreach (var name in branchPropNames)
    {
        sb.AppendLine($"        if ({name} is not null) __setCount++;");
    }
    sb.AppendLine("        if (__setCount != 1)");
    sb.AppendLine("        {");
    sb.AppendLine("            __result.AddError(");
    sb.AppendLine("                new global::FrenchExDev.Net.Builder.MemberName(\"$Discriminator\", __type),");
    sb.AppendLine("                new global::System.InvalidOperationException(");
    sb.AppendLine($"                    $\"{className} requires exactly one branch to be set; found {{__setCount}}.\"));");
    sb.AppendLine("        }");
    return sb.ToString().TrimEnd('\r', '\n');
}

The output ends up inside ValidateAsync, after every per-property validator has run. The error is added to the same ValidationResult builder the rest of the per-property validations use, and surfaces through the standard BuildAsync()Result<T> path, with MemberName == "$Discriminator" so consumers can pattern-match it for special UI handling if they want.

What it looks like in the generated builder

TraefikHttpMiddlewareBuilder.g.cs is ~260 lines (one block per branch). The interesting parts:

// <auto-generated/>
#nullable enable
#pragma warning disable CS0618 // Obsolete members

namespace FrenchExDev.Net.Traefik.Bundle;

[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public partial class TraefikHttpMiddlewareBuilder
    : global::FrenchExDev.Net.Builder.AbstractBuilder<global::FrenchExDev.Net.Traefik.Bundle.TraefikHttpMiddleware>
{
    // ── Input properties ─────────────────────────────────────────────
    protected TraefikAddPrefixMiddleware? AddPrefix { get; private set; }
    public TraefikHttpMiddlewareBuilder WithAddPrefix(TraefikAddPrefixMiddleware? value) { AddPrefix = value; return this; }
    protected TraefikBasicAuthMiddleware? BasicAuth { get; private set; }
    public TraefikHttpMiddlewareBuilder WithBasicAuth(TraefikBasicAuthMiddleware? value) { BasicAuth = value; return this; }
    // … 23 more With* methods, one per middleware branch …

    // ── Per-property validation (virtual, overridable) ────────────────
    protected virtual IEnumerable<Exception>? ValidateAddPrefix(TraefikAddPrefixMiddleware? value) => null;
    protected virtual IEnumerable<Exception>? ValidateBasicAuth(TraefikBasicAuthMiddleware? value) => null;
    // … 23 more virtual hooks the consumer can override …

    // ── ValidateAsync ─────────────────────────────────────────────────
    protected override Task<Result<ValidationResult>> ValidateAsync(CancellationToken ct = default)
    {
        var __result = new ValidationResult();
        var __type = typeof(TraefikHttpMiddlewareBuilder);

        foreach (var __err in ValidateAddPrefix(AddPrefix) ?? Array.Empty<Exception>())
            __result.AddError(new MemberName(nameof(AddPrefix), __type), __err);

        foreach (var __err in ValidateBasicAuth(BasicAuth) ?? Array.Empty<Exception>())
            __result.AddError(new MemberName(nameof(BasicAuth), __type), __err);

        // … 23 more per-property validation calls …


        // ── Custom epilogue ──
        var __setCount = 0;
        if (AddPrefix is not null) __setCount++;
        if (BasicAuth is not null) __setCount++;
        if (Buffering is not null) __setCount++;
        if (Chain is not null) __setCount++;
        if (CircuitBreaker is not null) __setCount++;
        if (Compress is not null) __setCount++;
        if (ContentType is not null) __setCount++;
        if (DigestAuth is not null) __setCount++;
        if (Errors is not null) __setCount++;
        if (ForwardAuth is not null) __setCount++;
        if (GrpcWeb is not null) __setCount++;
        if (Headers is not null) __setCount++;
        if (IpWhiteList is not null) __setCount++;
        if (IpAllowList is not null) __setCount++;
        if (InFlightReq is not null) __setCount++;
        if (PassTLSClientCert is not null) __setCount++;
        if (Plugin is not null) __setCount++;
        if (RateLimit is not null) __setCount++;
        if (RedirectRegex is not null) __setCount++;
        if (RedirectScheme is not null) __setCount++;
        if (ReplacePath is not null) __setCount++;
        if (ReplacePathRegex is not null) __setCount++;
        if (Retry is not null) __setCount++;
        if (StripPrefix is not null) __setCount++;
        if (StripPrefixRegex is not null) __setCount++;
        if (__setCount != 1)
        {
            __result.AddError(
                new MemberName("$Discriminator", __type),
                new InvalidOperationException(
                    $"TraefikHttpMiddleware requires exactly one branch to be set; found {__setCount}."));
        }

        return Task.FromResult(Result<ValidationResult>.Success(__result));
    }
}

Note the structure: the per-property validators are emitted by the shared BuilderEmitter, then the __setCount block is the verbatim string from BuildExactlyOneBranchEpilogue. The shared emitter doesn't know anything about discriminated unions; it just appends whatever epilogue string the BuilderEmitModel carries. That keeps the Traefik-specific knowledge in TraefikBuilderHelper.cs and lets the same emitter serve every other generator in the monorepo.

The dictionary-friendly path: WithEntryPoint

For comparison, here is the relevant slice of TraefikStaticConfigBuilder.g.cs where the EntryPoints dictionary becomes both a bulk setter and a per-key fluent path:

protected Dictionary<string, TraefikStaticEntryPoint>? EntryPoints { get; private set; }
public TraefikStaticConfigBuilder WithEntryPoints(Dictionary<string, TraefikStaticEntryPoint>? value)
{
    EntryPoints = value;
    return this;
}

private DictionaryBuilder<string, TraefikStaticEntryPoint, TraefikStaticEntryPointBuilder>? __EntryPointsBuilder;

public TraefikStaticConfigBuilder WithEntryPoints(
    Action<DictionaryBuilder<string, TraefikStaticEntryPoint, TraefikStaticEntryPointBuilder>> configure)
{
    __EntryPointsBuilder = new DictionaryBuilder<…>();
    configure(__EntryPointsBuilder);
    return this;
}

public TraefikStaticConfigBuilder WithEntryPoint(string key, Action<TraefikStaticEntryPointBuilder> configure)
{
    __EntryPointsBuilder ??= new DictionaryBuilder<…>();
    var __b = new TraefikStaticEntryPointBuilder();
    configure(__b);
    __EntryPointsBuilder.With(key, __b);
    return this;
}

Three overloads emitted from one schema property:

  1. WithEntryPoints(Dictionary<…>?) — the literal property setter, for when you already have a dictionary in hand.
  2. WithEntryPoints(Action<DictionaryBuilder<…>>) — pass a builder configuration block, useful when you're constructing both the dictionary and its values from scratch.
  3. WithEntryPoint(string key, Action<…>) — the singular form. The most common use case in practice; this is what produces the Singularize("EntryPoints") → "EntryPoint" step in TraefikBuilderHelper.

That Singularize is intentionally naive (return name.EndsWith("s") ? name[..^1] : name;). It works for EntryPoints, CertificatesResolvers, Routers, Services, Middlewares. It produces WithCircuitBreaker from CircuitBreakers, which is fine because the C# pluralization edge cases (children, criteria) don't appear in the Traefik schema.

Consuming the builder

The builder integrates with the standard Result<T> flow from FrenchExDev.Net.Builder:

var middleware = await new TraefikHttpMiddlewareBuilder()
    .WithBasicAuth(new TraefikBasicAuthMiddleware
    {
        Users = new() { "admin:$apr1$..." },
        Realm = "API"
    })
    // .WithStripPrefix(...)   ← uncommenting this would make __setCount == 2
    //                            and BuildAsync would return a Failure
    .BuildAsync();

middleware.IsSuccess.ShouldBeTrue();

If both WithBasicAuth and WithStripPrefix are called, BuildAsync returns a Failure whose ValidationResult contains exactly one error: MemberName("$Discriminator", typeof(TraefikHttpMiddlewareBuilder)) with the message "TraefikHttpMiddleware requires exactly one branch to be set; found 2.". No exception thrown — it's just a failed Result, which is the convention every other FrenchExDev.Net.Builder consumer follows.

Why two layers of defense?

Because the runtime __setCount check fires only at BuildAsync time. If you write the offending object initializer in your IDE, you get no feedback at the cursor — you have to actually run a test or hit F5 to see the failure. That's still an order of magnitude better than discovering the problem at Traefik startup, but it's not as good as a red squiggle in the editor.

Part 7 closes the gap with a Roslyn analyzer that flags the same shape as you type it. The two defenses share nothing except the [TraefikDiscriminatedUnion] attribute on the model class, and they catch slightly different mistakes — the analyzer catches object initializers (new TraefikHttpMiddleware { … }), the runtime check catches builder usage (new TraefikHttpMiddlewareBuilder().With…). Together they cover essentially every way to construct one of these types in C#.

← Part 5: Emitting Models · Next: Part 7 — Catching Misuse at Edit-Time →

⬇ Download