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

The emitter

TraefikModelClassEmitter is the function that turns a UnifiedDefinition into a .g.cs partial class. It is intentionally a StringBuilder, not a SyntaxFactory tree — Roslyn's syntax APIs are far more verbose than the schema warrants, and the output shape is regular enough that string concatenation is faster to read and faster to maintain. Here is the standard-class path (the discriminated-union path is shown below):

private static string EmitClass(string ns, string className, string? description,
    List<UnifiedProperty> properties, string? sinceVersion, string? untilVersion)
{
    var sb = new StringBuilder();
    sb.AppendLine("// <auto-generated/>");
    sb.AppendLine("#nullable enable");
    sb.AppendLine();
    sb.AppendLine($"namespace {ns};");
    sb.AppendLine();

    if (description is not null)
    {
        sb.AppendLine("/// <summary>");
        sb.AppendLine($"/// {EscapeXml(description)}");
        sb.AppendLine("/// </summary>");
    }

    EmitVersionAttributes(sb, sinceVersion, untilVersion, "");

    sb.AppendLine("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]");
    sb.AppendLine($"public partial class {className}");
    sb.AppendLine("{");

    foreach (var up in properties)
    {
        var prop = up.Property;
        var type = TraefikNamingHelper.MapCSharpType(prop);

        if (prop.Description is not null)
        {
            sb.AppendLine("    /// <summary>");
            sb.AppendLine($"    /// {EscapeXml(prop.Description)}");
            sb.AppendLine("    /// </summary>");
        }
        EmitVersionAttributes(sb, up);
        if (prop.IsDeprecated)
            sb.AppendLine("    [global::System.Obsolete(\"Deprecated.\")]");
        sb.AppendLine($"    public {type} {prop.CSharpName} {{ get; set; }}");
        sb.AppendLine();
    }

    sb.AppendLine("}");
    return sb.ToString();
}

Three things on every emitted class:

  • public partial — so consumers can extend the generated class with hand-written members in another file. This is critical for the small number of cases where the schema can't express a constraint that you want to enforce in C#.
  • [ExcludeFromCodeCoverage] — generated code is not interesting to coverage. The emitter itself is covered by EmitterTests (see Part 9); the output is just data.
  • /// <summary> lifted from prop.Description — the JSON schema's description field becomes XML doc that shows in IntelliSense. EscapeXml collapses newlines and escapes <, >, & so multi-line descriptions don't break the build.

The output: TraefikAddPrefixMiddleware.g.cs

The simplest possible case — a Traefik middleware with one string property — produces this:

// <auto-generated/>
#nullable enable

namespace FrenchExDev.Net.Traefik.Bundle;

/// <summary>
/// The AddPrefix middleware updates the URL Path of the request before forwarding it.
/// </summary>
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public partial class TraefikAddPrefixMiddleware
{
    /// <summary>
    /// prefix is the string to add before the current path in the requested URL. It should include the leading slash (/).
    /// </summary>
    public string? Prefix { get; set; }

}

Both <summary> blocks were lifted directly from the JSON schema's description fields. No translation, no rewrite. The IntelliSense the user gets when they type new TraefikAddPrefixMiddleware { is whatever the Traefik docs team wrote.

The flat discriminated-union pattern

The interesting case is TraefikHttpMiddleware, which the JSON schema models as a oneOf over 25 different middleware types (AddPrefix, BasicAuth, RateLimit, StripPrefix, …). The schema says: a middleware is one of these 25 things. The C# language has no native way to express that — record-based discriminated unions are still in proposal form.

The generator's compromise: emit a flat class with one nullable property per branch, plus a marker attribute the analyzer can find:

private static string EmitDiscriminatedClass(string ns, string className,
    string? description, List<DiscriminatedBranch> branches)
{
    var sb = new StringBuilder();
    sb.AppendLine("// <auto-generated/>");
    sb.AppendLine("#nullable enable");
    sb.AppendLine();
    sb.AppendLine($"namespace {ns};");
    sb.AppendLine();

    if (description is not null)
    {
        sb.AppendLine("/// <summary>");
        sb.AppendLine($"/// {EscapeXml(description)}");
        sb.AppendLine("/// </summary>");
    }

    sb.AppendLine("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]");
    sb.AppendLine("[global::FrenchExDev.Net.Traefik.Bundle.Attributes.TraefikDiscriminatedUnion]");
    sb.AppendLine($"public partial class {className}");
    sb.AppendLine("{");

    foreach (var branch in branches)
    {
        var propName = TraefikNamingHelper.ToPascalCase(branch.PropertyName);
        var refClassName = TraefikNamingHelper.DefinitionToClassName(branch.RefName);
        sb.AppendLine($"    public {refClassName}? {propName} {{ get; set; }}");
        sb.AppendLine();
    }

    sb.AppendLine("}");
    return sb.ToString();
}

The [TraefikDiscriminatedUnion] attribute is the entire reason the runtime check in Part 6 and the IDE-time analyzer in Part 7 can find these classes. It's emitted on every oneOf definition and on nothing else. The analyzer literally checks for that one attribute by full metadata name.

The output: TraefikHttpMiddleware.g.cs

The 25-branch dynamic-config middleware union expands to:

// <auto-generated/>
#nullable enable

namespace FrenchExDev.Net.Traefik.Bundle;

[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
[global::FrenchExDev.Net.Traefik.Bundle.Attributes.TraefikDiscriminatedUnion]
public partial class TraefikHttpMiddleware
{
    public TraefikAddPrefixMiddleware? AddPrefix { get; set; }

    public TraefikBasicAuthMiddleware? BasicAuth { get; set; }

    public TraefikBufferingMiddleware? Buffering { get; set; }

    public TraefikChainMiddleware? Chain { get; set; }

    public TraefikCircuitBreakerMiddleware? CircuitBreaker { get; set; }

    public TraefikCompressMiddleware? Compress { get; set; }

    public TraefikContentTypeMiddleware? ContentType { get; set; }

    public TraefikDigestAuthMiddleware? DigestAuth { get; set; }

    public TraefikErrorsMiddleware? Errors { get; set; }

    public TraefikForwardAuthMiddleware? ForwardAuth { get; set; }

    public TraefikGrpcWebMiddleware? GrpcWeb { get; set; }

    public TraefikHeadersMiddleware? Headers { get; set; }

    public TraefikIpWhiteListMiddleware? IpWhiteList { get; set; }

    public TraefikIpAllowListMiddleware? IpAllowList { get; set; }

    public TraefikInFlightReqMiddleware? InFlightReq { get; set; }

    public TraefikPassTLSClientCertMiddleware? PassTLSClientCert { get; set; }

    public TraefikPluginMiddleware? Plugin { get; set; }

    public TraefikRateLimitMiddleware? RateLimit { get; set; }

    public TraefikRedirectRegexMiddleware? RedirectRegex { get; set; }

    public TraefikRedirectSchemeMiddleware? RedirectScheme { get; set; }

    public TraefikReplacePathMiddleware? ReplacePath { get; set; }

    public TraefikReplacePathRegexMiddleware? ReplacePathRegex { get; set; }

    public TraefikRetryMiddleware? Retry { get; set; }

    public TraefikStripPrefixMiddleware? StripPrefix { get; set; }

    public TraefikStripPrefixRegexMiddleware? StripPrefixRegex { get; set; }

}

This shape has one important property: it round-trips through YAML and JSON for free. YamlDotNet sees a class with 25 nullable properties; whichever one is set on the inbound side gets serialized, the others are omitted (because of OmitNull). On the outbound side, the deserializer fills in whichever property the YAML tag maps to. No custom converter, no [JsonConverter], no discriminator field.

The trade-off is that the type TraefikHttpMiddleware does not enforce "exactly one branch is set". The C# compiler will happily let you write:

new TraefikHttpMiddleware
{
    AddPrefix = new() { Prefix = "/v1" },
    BasicAuth = new() { Users = new() { "..." } },   // ← WRONG: two branches
}

Traefik will silently reject this configuration at startup (or worse, pick one and ignore the other). The next two chapters close that gap from two angles:

  • Part 6 — the matching TraefikHttpMiddlewareBuilder.g.cs includes a generated __setCount == 1 epilogue that fails BuildAsync if you populate more than one branch.
  • Part 7DiscriminatedUnionAnalyzer raises TFK001 in the IDE the moment you write the offending object initializer, because the type carries the [TraefikDiscriminatedUnion] attribute.

The flat shape stays. The defenses are layered on top.

The static config root, for contrast

Just so you can see what a non-discriminated definition looks like, here is TraefikStaticConfig.g.cs — the root of traefik.yml:

// <auto-generated/>
#nullable enable

namespace FrenchExDev.Net.Traefik.Bundle;

[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public partial class TraefikStaticConfig
{
    public TraefikTypesAccessLog? AccessLog { get; set; }
    public TraefikStaticAPI? Api { get; set; }
    public global::System.Collections.Generic.Dictionary<string, TraefikStaticCertificateResolver>? CertificatesResolvers { get; set; }
    public TraefikStaticCore? Core { get; set; }
    public global::System.Collections.Generic.Dictionary<string, TraefikStaticEntryPoint>? EntryPoints { get; set; }
    public TraefikStaticExperimental? Experimental { get; set; }
    public TraefikStaticGlobal? Global { get; set; }
    public TraefikTypesHostResolverConfig? HostResolver { get; set; }
    public TraefikTypesTraefikLog? Log { get; set; }
    public TraefikTypesMetrics? Metrics { get; set; }
    public TraefikPingHandler? Ping { get; set; }
    public TraefikStaticProviders? Providers { get; set; }
    public TraefikStaticServersTransport? ServersTransport { get; set; }
    public TraefikStaticSpiffeClientConfig? Spiffe { get; set; }
    public TraefikStaticTCPServersTransport? TcpServersTransport { get; set; }
    public TraefikStaticTracing? Tracing { get; set; }
}

No discriminator attribute, no __setCount. Just 16 nullable properties — every Traefik static-config section in one place. Note the two Dictionary<string, …> properties for CertificatesResolvers and EntryPoints; these are the cases where the builder in Part 6 emits its dictionary-friendly WithEntryPoint(string key, Action<…>) overloads.

← Part 4: Value-Equal IR · Next: Part 6 — Fluent Builders →

⬇ Download