Skip to content

Year: 2026

Scoping Access to Generated Internal Types

Source code generated with a Roslyn Source Generator ends up in the assembly the source generator ran in, which means that access modifiers restrict access with the same scope as any manually written code in the same assembly. When a generator generates a lot of code, some of that code might be used as internal helpers for the generated code, and should not be exposed to the consumer of the generated code. One example is limiting the public API produced by that code.

Current Access Modifiers

One, rather new, access modifier was introduced in C# 11 to support similar scenarios; file. file scopes access to the current file, in source generator lingo that is defined by the hintName in the SourceProductionContext. The problem with file is that it doesn’t scale with generators that generate a lot of source code.

There are discussions about other scoped access modifiers in the C# language, but none have been implemented up to this date. A namespace scoped access modifier could partly solve the above problem, if one accept that a consumer might still declare types in that same namespace, and hence also be able to access those scoped types. Not really what we are looking for here.

More Roslyn to the Rescue

A work around to this problem is to build a Roslyn Analyzer. An analyzer can inspect code in the assembly it is installed in and find symbol usage that we want to prohibit, like referencing any internal members produced by a source code generator. The trick is to identify code being generated by a specific generator and scope it, i.e. it should only ever be referenced by code generated by that generator.

Fortunately, “files” in source code generators (remember the hintName above) does include the name of the source generator in the generated path, something like baseDirectory/assemblyName/generatorTypeName/hintName, which we can use.

using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Foo;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class AccessToGeneratedInternalSymbolAnalyzer : DiagnosticAnalyzer
{
    private static readonly string SourceGeneratorName = typeof(FooGenerator).FullName!;
    private static readonly DiagnosticDescriptor Rule = new(
        id: "AB0001",
        title: "Access to internal generated symbol",
        messageFormat: "Cannot access internal generated {0} '{1}'",
        category: "Usage",
        defaultSeverity: DiagnosticSeverity.Error,
        isEnabledByDefault: true,
        description: $"Internal symbols produced by {SourceGeneratorName} should not be accessed as they can introduce breaking changes without notice.");

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = [Rule];

    public override void Initialize(AnalysisContext context)
    {
        context.EnableConcurrentExecution();

        context.ConfigureGeneratedCodeAnalysis(
            GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics);

        context.RegisterSyntaxNodeAction(
            ReportSyntaxNodesReferencingInternalGeneratedSymbols, 
            SyntaxKind.IdentifierName,
            SyntaxKind.GenericName);

        context.RegisterSymbolAction(
            ReportSymbolsReferencingInternalGeneratedSymbols, 
            SymbolKind.NamedType);
    }

    private static void ReportSyntaxNodesReferencingInternalGeneratedSymbols(SyntaxNodeAnalysisContext context)
    {
        if (IsFromSourceGenerator(context.Node.SyntaxTree))
        {
            return;
        }

        var symbol = context.SemanticModel.GetSymbolInfo(context.Node, context.CancellationToken).Symbol;
        if (symbol is not null && ReferencesGeneratedInternalSymbols(symbol))
        {
            context.ReportDiagnostic(
                AccessToGeneratedInternalSymbol(symbol, 
                    context.Node.GetLocation()));
        }
    }

    private static void ReportSymbolsReferencingInternalGeneratedSymbols(SymbolAnalysisContext context)
    {
        var symbol = context.Symbol;
        if (!ReferencesGeneratedInternalSymbols(symbol))
        {
            return;
        }

        foreach (var location in symbol.Locations.Where(location =>
                     !IsFromSourceGenerator(location.SourceTree)))
        {
            context.ReportDiagnostic(
                AccessToGeneratedInternalSymbol(symbol, location));
        }
    }

    private static Diagnostic AccessToGeneratedInternalSymbol(ISymbol symbol, Location location) =>
        Diagnostic.Create(
            Rule,
            location,
            DescribeKind(symbol),
            symbol.ToDisplayString());

    private static string DescribeKind(ISymbol symbol) =>
        symbol switch
        {
            IMethodSymbol { MethodKind: MethodKind.Constructor } => "constructor",
            IMethodSymbol => "method",
            IPropertySymbol => "property",
            IFieldSymbol => "field",
            IEventSymbol => "event",
            INamedTypeSymbol => "type",
            _ => "member",
        };

    private static bool ReferencesGeneratedInternalSymbols(ISymbol symbol)
    {
        for (var current = symbol; current is not null; current = current.ContainingType)
        {
            if (current is
                {
                    DeclaredAccessibility: Accessibility.Internal,
                    DeclaringSyntaxReferences.Length: > 0
                } &&
                current.DeclaringSyntaxReferences.Any(reference =>
                    IsFromSourceGenerator(reference.SyntaxTree)))
            {
                return true;
            }
        }

        return false;
    }

    private static bool IsFromSourceGenerator(SyntaxTree? tree) =>
        tree is not null &&
        tree.FilePath.Replace('\\', '/').Contains($"/{SourceGeneratorName}/");
}

Note IsFromSourceGenerator, which checks if a syntax tree is declared in a file(hint) that was generated by the source generator.

Symbols

Any named type symbols, i.e. classes, structs, enum etc., that references any symbols generated by the source generator get reported.

Example:

// Generated
internal partial class Foo {} 

// This partial class is not generated and cannot extend the generated type declaration -> Report
internal partial class Foo 
{ 
    void Fee() {} 
} 

Syntax Trees

Any syntax node identifiers, i.e. the name of the instance of a class, method, property, parameter etc., and generic names, that references any nodes generated by the source code generator get reported. We ignore any syntax trees generated by the source generator it self.

Example:

// Generated
internal class Foo {} 

// fee is not generated and cannot reference Foo -> Report
Foo fee = new Foo(); 

Limitations

The access modifiers in the C# language are authoritative, violate them and the code doesn’t compile. An analyzer can be opted out. Whether this is perceived as a limitation or not is debatable considering a consumer opts in to use a source code generator. The internal access modifier can also be ignored by the owner of the protected assembly using the InternalsVisibleToAttribute. For an analyzer, the consumer can control this instead using preprocessor directives.

Summary

Only the C# language can really solve this issue, but until then using an analyzer might be an acceptable work-around.

Leave a Comment

Generating Web Clients from OpenAPI Specifications

A while back I published a source code generator for C# that scaffolds web APIs from OpenAPI specifications. Since then I’ve realized that the available OpenAPI source generated clients, like Microsoft’s Kiota, seems to suffer similar lack in functionality and type safety as it’s web api scaffolding counterparts. So I decided to open source my own, strongly typed, fully featured, generator for web clients; OpenApi.WebClientGenerator.

The generator is exclusively generating clients in C#, and is, similar to the Web API generator, also an incremental source generator, meaning it generates the clients into an existing assembly.

Getting started:

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>net10.0</TargetFramework>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="Corvus.Json.ExtendedTypes" Version="4.4.2" />
        <PackageReference Include="ParameterStyleParsers.OpenAPI" Version="1.5.0" />
    </ItemGroup>

    <ItemGroup>
        <PackageReference Include="WebClientGenerator.OpenAPI" Version="x.y.z" PrivateAssets="all" />
    </ItemGroup>
    <ItemGroup>
        <WebClientGenerator Include="path/to/openapi.json" ClientName="Foo" Namespace="Example" />
    </ItemGroup>

</Project>

Build, and then use:

using var client = new Example.Foo(Servers.Production);

The generated clients are strongly typed wrappers around HttpClient, so it’s still possible to customize behavior that might not be defined by an OpenAPI specification.

It supports all OpenAPI specifications currently published.

Corvus.JsonSchema

As with the Web API generator, the client generator also generates JSON schema models using Corvus.JsonSchema.

ParameterStyleParsers.OpenAPI

OpenAPI parameters are parsed by ParameterStyleParsers.OpenAPI, same as for the generated Web API.

Leave a Comment

Generating Web APIs from OpenAPI specifications

Keeping Web API implementations of an OpenAPI specification in-sync is difficult without automation, if not impossible. Several initiatives exists, they all come with various limitations and of course opiniated solutions, none have really solved all the various directives of OpenAPI, especially not schema validation. So I decided to build my own generator, OpenAPI.WebApiGenerator!

The generator is exclusive for .NET API scaffolding. It’s implemented as a source generator, so no external tooling required, it generates straight into your assembly!

<Project Sdk="Microsoft.NET.Sdk.Web">

    <PropertyGroup>
        <TargetFramework>net9.0</TargetFramework>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="Corvus.Json.ExtendedTypes" Version="4.4.2" />
        <PackageReference Include="ParameterStyleParsers.OpenAPI" Version="1.4.0" />
    </ItemGroup>

    <ItemGroup>
        <PackageReference Include="WebApiGenerator.OpenAPI" Version="x.y.z" PrivateAssets="all" />
    </ItemGroup>
    <ItemGroup>
        <AdditionalFiles Include="openapi.json" />
    </ItemGroup>

</Project>

That is all that is needed!

The generator supports all OpenAPI specification versions and integrates with Minimal API.

API operations are generated with a familiar request/response signature where namespaces follow the OpenAPI operation paths:

paths: 
  "/foo/{FooId}":
    put:
      parameters: ...
      requestBody: ...
      responses: ...   
namespace Example.Paths.FooFooId.Put;

internal partial class Operation
{
    internal partial Task<Response> HandleAsync(Request request, CancellationToken cancellationToken)
    {
        var response = ...
        return Task.FromResult<Response>(response);
    }
}

Corvus.JsonSchema

Requests and responses are automatically validated and serialized according to the OpenAPI specification using the excellent JSON Schema model Corvus.JsonSchema. JSON schemas are difficult to express in C# structures, or any object oriented language for that matter. The traditional DTO approach which System.Text.Json takes are very limited in expressiveness, and validation is mostly left for the implementer. Corvus.Json delivers all-in one. JSON semantic structure, validation, serialization and marshalling to C# primitives, all according to the schema it was generated from. No scaffolding required!

ParameterStyleParsers.OpenAPI

Request parameters in HTTP is not defined as JSON, as content can be. Yet they are defined using JSON schemas in OpenAPI. ParameterStyleParsers.OpenAPI parses parameter styled instances to and from JSON according to the parameter specification. Read this post for more details.

The Future

It’s still early days, I’m planning on implementing many more advanced features, like streamable content and AuthN/AuthZ. Until then, give it a swirl!

1 Comment