Skip to content

Development Notes

David Kallesen edited this page Sep 10, 2026 · 10 revisions

🛠️ Development Notes

This guide is for developers contributing to the Atc.Rest.Api.SourceGenerator project. It covers the architecture, testing setup, and common development tasks.


🏗️ Project Architecture

📐 Three-Layer Architecture

The project uses a three-layer architecture that separates shared logic from Roslyn-specific code and CLI:

flowchart TB
    subgraph SourceGen["Atc.Rest.Api.SourceGenerator"]
        direction LR
        ApiServer["ApiServerGenerator"]
        ApiClient["ApiClientGenerator"]
        ApiDomain["ApiDomainGenerator"]
    end

    subgraph RoslynHelpers["Roslyn-Specific Helpers"]
        direction LR
        DiagHelpers["🔧 DiagnosticHelpers"]
        RoslynExtractor["🔍 RoslynSchemaExtractor"]
    end

    subgraph Generator["📦 Atc.Rest.Api.Generator"]
        subgraph Extractors["🔧 Extractors (45+)"]
            direction LR
            Schema["SchemaExtractor"]
            Enum["EnumExtractor"]
            Handler["HandlerExtractor"]
            More["...9 more"]
        end

        subgraph Services["⚙️ Services"]
            CodeGen["CodeGenerationService"]
        end

        subgraph Support["Supporting Components"]
            direction LR
            Config["⚙️ Configurations"]
            Valid["✅ Validators"]
            Models["📋 Models"]
        end
    end

    SourceGen --> Generator
    RoslynHelpers --> Generator
Loading

✨ Benefits of this architecture:

  • 🧪 Testability: Shared logic tested without Roslyn dependencies
  • 🔄 Reusability: Generator library powers both Source Generator AND CLI tool
  • 🛠️ Maintainability: Clear separation of concerns
  • ⚡ Build performance: Smaller source generator assembly

📁 Solution Structure

atc-rest-api-source-generator/
├── 📁 src/
│   ├── 📦 Atc.NetStandard20/                      # Core utilities
│   ├── 📦 Atc.CodeGeneration.CSharp/              # Code gen library
│   ├── 📦 Atc.CodeGeneration.TypeScript/          # TypeScript code gen primitives
│   ├── 📦 Atc.OpenApi/                            # OpenAPI extensions
│   ├── 📦 Atc.Rest.Api.Generator/                 # Shared library (ALL business logic)
│   │   ├── 📁 Abstractions/                       # ICodeGenerator, IDiagnosticReporter
│   │   ├── 📁 Configurations/                     # ServerConfig, ClientConfig, etc.
│   │   ├── 📁 Extractors/                         # ALL 45+ extractors
│   │   ├── 📁 Services/                           # CodeGenerationService
│   │   ├── 📁 Validators/                         # CasingHelper, OpenApiDocumentValidator
│   │   └── 📁 Helpers/                            # PathSegmentHelper, RuleIdentifiers
│   ├── 🔮 Atc.Rest.Api.SourceGenerator/           # Roslyn generators (thin wrappers)
│   ├── 🧪 Atc.Rest.Api.Client.Testing/            # HTTP test doubles for consumers (shipped package)
│   └── 💻 Atc.Rest.Api.Generator.Cli/              # CLI tool (net10.0)
├── 📁 sample/
│   ├── 🐕 PetStoreSimple/                         # Basic 3-endpoint example
│   ├── 🐕 PetStoreFull/                           # Extended example
│   ├── 🎪 Showcase/                               # Full-featured demo
│   ├── 📑 MultipartDemo/                          # Multi-file specification example
│   ├── 🔌 NexusSample/                            # IoT/Edge device management API
│   ├── 🔌 MontaClient/                            # Third-party spec, client only
│   ├── 🧪 ThirdParty-EPO-Clients/                 # EndpointPerOperation client + unit tests
│   └── 🧪 ThirdParty-Typed-Clients/               # TypedClient client + unit tests
└── 📁 test/
    ├── 🧪 Atc.Rest.Api.Generator.Tests/           # Shared library unit tests
    ├── 🧪 Atc.Rest.Api.Generator.IntegrationTests/# Generator integration tests
    ├── 🧪 Atc.Rest.Api.SourceGenerator.Tests/     # SourceGenerator tests
    └── 🧪 Atc.Rest.Api.Generator.Cli.Tests/        # CLI command tests

🔄 Generator Architecture

The generators follow the Extractor Pattern:

📄 OpenAPI Document -> 🔧 Extractors -> 📋 Parameter Records -> ⚙️ Code Generators -> 💻 C# Code

🔧 Extractors (All in Atc.Rest.Api.Generator)

Core Extractors — Generate primary code artifacts:

Extractor Input Output
SchemaExtractor OpenAPI schemas RecordsParameters (models)
EnumExtractor OpenAPI string enums EnumParameters
InlineEnumExtractor Inline enum schemas Enums co-located with models
InlineSchemaExtractor Inline object schemas Inline record types
PolymorphicTypeExtractor oneOf/anyOf schemas Abstract records + converters
TupleExtractor prefixItems (3.1) Tuple record types
HandlerExtractor OpenAPI operations InterfaceParameters
OperationParameterExtractor Operation params ClassParameters
ResultClassExtractor Operation responses Result class code
EndpointDefinitionExtractor Operations EndpointDefinitionParameters
EndpointRegistrationExtractor Operations Endpoint route mappings
HandlerScaffoldExtractor Operations Handler stub code
HttpClientExtractor Operations TypedClient code
EndpointPerOperationExtractor Operations Per-operation endpoint classes
ConstantsExtractor Project name + config Constants class (HttpClientName)

DI & Cross-Cutting Extractors — Generate dependency injection and middleware setup:

Extractor Input Output
DependencyRegistrationExtractor Handler/validator list ServiceCollectionExtensions
ServerDependencyInjectionExtractor Server config Server DI setup code
UnifiedServiceCollectionExtractor All DI fragments Combined service registration
SecurityDependencyInjectionExtractor Security schemes Auth DI setup code
SecurityPoliciesExtractor Security config Authorization policy constants
RateLimitDependencyInjectionExtractor x-ratelimit-* extensions Rate limit DI setup
RateLimitPoliciesExtractor x-ratelimit-* extensions Rate limit policy constants
ResilienceDependencyInjectionExtractor x-retry-* extensions AddApiResilience() setup
ResiliencePoliciesExtractor x-retry-* extensions Resilience policy constants
HybridCacheDependencyInjectionExtractor x-cache-* extensions HybridCache DI setup
HybridCachePoliciesExtractor x-cache-* extensions Cache policy constants
OutputCacheDependencyInjectionExtractor x-cache-* extensions OutputCache DI setup
OutputCachePoliciesExtractor x-cache-* extensions Output cache policy constants
VersioningDependencyInjectionExtractor Versioning config API versioning DI setup
ValidatorDependencyRegistrationExtractor Validator types Validator DI registration

OAuth / OpenID Connect Extractors:

Extractor Input Output
OAuthConfigExtractor OAuth2 scheme OAuth configuration class
OAuthDependencyInjectionExtractor OAuth2 scheme OAuth DI setup
OAuthHandlerExtractor OAuth2 scheme Token refresh handler
OAuthOptionsExtractor OAuth2 scheme OAuth options record
OAuthTokenProviderExtractor OAuth2 scheme Token provider service
OpenIdConnectConfigExtractor OIDC scheme OpenID Connect configuration
OpenIdConnectDependencyInjectionExtractor OIDC scheme OIDC DI setup

Webhook Extractors (OpenAPI 3.1):

Extractor Input Output
WebhookHandlerExtractor Webhook operations Webhook handler interfaces
WebhookEndpointExtractor Webhook operations Webhook endpoint registrations
WebhookParameterExtractor Webhook parameters Webhook parameter records
WebhookResultExtractor Webhook responses Webhook result types
WebhookDependencyInjectionExtractor Webhook config Webhook DI setup

Shared Utilities:

Extractor Input Output
CustomErrorResponseExtractor Error config Custom error response class
ApiOptionsExtractor Generation config Api options record
WebApplicationExtensionsExtractor All endpoints app.Map{Project}Api() method
ClassSchemaExtractor Complex schemas Class-based models

💡 Note: Only RoslynSchemaExtractor (requires Roslyn for assembly scanning) and PolymorphicTypeEmitter (emits oneOf/anyOf base types straight into a SourceProductionContext, shared by the server and client generators) live in SourceGenerator. The CLI project has an additional ~22 TypeScript extractors in Extractors/TypeScript/.


🧪 Testing

📁 Test Project Structure

test/
├── 🧪 Atc.Rest.Api.Generator.Tests/        # Shared library unit tests
├── 🧪 Atc.Rest.Api.Generator.IntegrationTests/ # Integration tests + TS-Client-* snapshots
├── 🧪 Atc.Rest.Api.SourceGenerator.Tests/  # C# generator snapshots + compilation gate
├── 🧪 Atc.Rest.Api.Generator.Cli.Tests/    # CLI command tests
└── 📁 Scenarios/                           # Shared test scenarios
    ├── 🎪 Demo/                            # Comprehensive demo scenario
    ├── 🐕 PetStoreSimple/                  # Minimal 3-endpoint API
    ├── 🚦 RateLimit/                       # x-ratelimit-* extensions
    ├── 🔄 Retry/                           # x-retry-* resilience extensions
    ├── 🔐 Security*/                       # Security scheme variations
    └── 🔢 Versioning*/                     # API versioning strategies

▶️ Running Tests

# 🧪 Run all tests
dotnet test

# 🎯 Run specific test category
dotnet test --filter "FullyQualifiedName~GeneratorComparisonTests"

# 📋 Run tests for specific scenario
dotnet test --filter "DisplayName~PetStoreSimple"

# ✅ Run validation tests only
dotnet test --filter "FullyQualifiedName~ValidationTests"

# 📝 Run with verbose output
dotnet test -v detailed

📸 Snapshot Testing with Verify

We use Verify for snapshot testing generated code.

🔄 How It Works

  1. 🧪 GeneratorScenarioSnapshotTests runs the real Roslyn generator over a scenario
  2. 📊 Each emitted source is compared against its .verified.cs baseline file
  3. ❌ Differences are shown in .received.cs files
  4. 👀 Developer reviews and accepts/rejects changes

Snapshots live under {Scenario}/{MasterFolder}/ and are named after the generator hint name, which is the fully-qualified type name — flat in the master folder, with no Contracts/{Segment}/ nesting. Folder placement is a scaffolding concern the generator has no notion of; the hint name is the only identity it assigns.

🗂️ Master Folders

Each scenario folder holds one subfolder per master folder, and the marker file lives inside it — so a scenario opts into a master folder simply by placing the marker there:

test/Scenarios/PetStoreSimple/
├── PetStoreSimple.yaml
├── Server/           .atc-rest-api-server          + *.verified.cs
├── ServerDomain/     .atc-rest-api-server-handlers + *.verified.cs
├── Client-Typed/     .atc-rest-api-client          + *.verified.cs
├── Client-Operation/ .atc-rest-api-client          + *.verified.cs
└── TS-Client-*/      (TypeScript baselines)
Master folder Marker file Generator Driven by
Server .atc-rest-api-server ApiServerGenerator GeneratorScenarioSnapshotTests
ServerDomain .atc-rest-api-server-handlers ApiServerDomainGenerator GeneratorScenarioSnapshotTests
Client-Typed .atc-rest-api-client ApiClientGenerator GeneratorScenarioSnapshotTests
Client-Operation .atc-rest-api-client ApiClientGenerator GeneratorScenarioSnapshotTests
TS-Client-* — CLI TypeScript path ScenarioTests (integration project)

Client-Typed and Client-Operation share a generator and a marker file name; they differ only in the generationMode set inside their own copy of the marker.

The four C# folders are converted: they are snapshotted from the generators, and ScenarioDiscovery.ConvertedMasterFolders makes the integration-test suite skip them so exactly one suite owns each. TS-Client-* stays on the old path because there is no TypeScript incremental generator to converge onto.

⚠️ Never add a converted master folder back to the integration suite. Two suites verifying the same files against two implementations means whichever runs second rewrites the other's snapshots — which is how the two generation paths silently diverged in the first place.

Three tests guard each converted combination:

Test Guards
Generator_MatchesSnapshot One test per emitted file, so a mismatch does not hide every later file
Generator_EmitsSnapshots An empty generator run cannot quietly verify an empty set
Snapshots_HaveNoOrphans Verify never deletes, so a stale baseline would otherwise pass forever

🚦 The Compilation Gate

Snapshots compare text and never compile, so a snapshot set can reference undefined types indefinitely. GeneratorCompilationGateTests closes that blind spot: it runs each generator over every scenario declaring its marker file and compiles the result for real.

Combinations that do not compile today are recorded in KnownNonCompilingCombinations as a ratchet, not a skip list — they are asserted to still fail, so a combination that starts compiling fails the test asking to be removed. The list can only shrink.

dotnet test --filter "FullyQualifiedName~GeneratorCompilationGateTests"

ℹ️ ServerDomain is compiled together with the handler scaffolds it registers and the server output they implement against, so a {Scenario}/ServerDomain entry in the ratchet is usually derivative — it inherits its {Scenario}/Server row's failure and goes green when that row does.

👀 Review Changes

When tests fail due to generated code changes:

# 📋 Review all pending changes
dotnet verify review

# ✅ Accept all changes (update baselines)
dotnet verify accept

# 📄 Accept specific file
dotnet verify accept --file "path/to/test.cs"

➕ Adding a New Scenario

  1. 📁 Create folder in test/Scenarios/{ScenarioName}/

  2. 📄 Add OpenAPI spec: {ScenarioName}.yaml

  3. ⚙️ Add a subfolder per master folder you want covered, each holding its own marker file:

    Server/           .atc-rest-api-server
    ServerDomain/     .atc-rest-api-server-handlers
    Client-Typed/     .atc-rest-api-client   ("generationMode": "TypedClient")
    Client-Operation/ .atc-rest-api-client   ("generationMode": "EndpointPerOperation")
  4. ▶️ Run tests to generate .received.cs files

  5. ✅ Review and accept:

    dotnet verify accept
  6. 💾 Commit .verified.cs files to source control

  7. 🚦 Make sure the new scenario passes the compilation gate:

    dotnet test --filter "FullyQualifiedName~GeneratorCompilationGateTests"

    A new scenario is expected to compile. Only add it to KnownNonCompilingCombinations when it reproduces a known open generator defect, and say which one in a comment — the list is a ratchet that should be shrinking, not a parking space for new debt.

💡 Snapshot files are written under {Scenario}/{MasterFolder}/ for the four C# master folders. Delete a stale .verified.cs when a type stops being generated; Snapshots_HaveNoOrphans will fail until you do.


🔨 Build Commands

🏗️ Build the Solution

# 🐛 Debug build
dotnet build

# 📦 Release build (warnings as errors)
dotnet build -c Release

▶️ Run Sample Projects

# 🐕 PetStoreSimple
dotnet run --project sample/PetStoreSimple/PetStoreSimple.Api/PetStoreSimple.Api.csproj

# 🐕 PetStoreSimple with Aspire
dotnet run --project sample/PetStoreSimple/PetStoreSimple.Aspire/PetStoreSimple.Aspire.csproj

# 🎪 Showcase with Aspire
dotnet run --project sample/Showcase/Showcase.Aspire/Showcase.Aspire.csproj

🧹 Clean Build

dotnet clean
rm -rf **/obj **/bin
dotnet build

🐛 Debugging Tips

👁️ See Generated Files

By default, source generator output only exists in memory during compilation. To inspect the generated code on disk, enable file emission in your .csproj:

Basic Setup

<PropertyGroup>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>

Generated files appear in:

obj/Debug/net9.0/generated/Atc.Rest.Api.SourceGenerator/
├── 📁 ApiServerGenerator/
│   └── *.g.cs
├── 📁 ApiClientGenerator/
│   └── *.g.cs
└── 📁 ApiServerDomainGenerator/
    └── *.cs (physical files)

Custom Output Location

To output generated files to a more accessible location, use CompilerGeneratedFilesOutputPath:

<PropertyGroup>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
  <CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

This outputs to obj/Generated/ instead of the deep nested path, making it easier to browse and diff generated code.

Recommended Debug Configuration

For development, you may want to emit files only in Debug builds:

<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
  <CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

💡 Tip: Add **/obj/Generated/ to your .gitignore to avoid committing generated files.

Why This Is Useful

  • 🔍 Inspect generated code: See exactly what the source generator produces
  • 🐛 Debug compilation errors: Easier to understand errors when you can see the full generated file
  • 📊 Compare changes: Track how generator updates affect output
  • 🧪 Verify correctness: Manually review generated handlers, endpoints, and models

🔍 Debug Source Generator

  1. 🛑 Add Debugger.Launch() in generator:

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        #if DEBUG
        if (!Debugger.IsAttached)
        {
            Debugger.Launch();
        }
        #endif
        // ...
    }
  2. 🏗️ Build a project that uses the generator

  3. 🔗 Visual Studio will prompt to attach debugger


📋 Diagnostic Rules Reference

For a complete reference of all diagnostic rules, see Analyzer Rules.

❌ Common Errors

Rule ID Description Solution
ATC_API_DEP001 Server requires ASP.NET Core Add <FrameworkReference Include="Microsoft.AspNetCore.App" />
ATC_API_DEP003 EndpointPerOperation requires Atc.Rest.Client Install Atc.Rest.Client NuGet package
ATC_API_DEP007 useMinimalApiPackage requires package Install Atc.Rest.MinimalApi or set to "disabled"
ATC_API_OPR001 Missing operationId Add operationId to each operation (Strict mode); Standard mode synthesizes a name
ATC_API_VAL002 OpenAPI 2.0 not supported Upgrade to OpenAPI 3.0.x specification

📦 Dependencies

🔮 Source Generator Dependencies

<PackageReference Include="Microsoft.OpenApi.YamlReader" Version="3.0.1" />
<PackageReference Include="Microsoft.OpenApi" Version="3.0.1" />
<PackageReference Include="SharpYaml" Version="2.1.4" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />

🔗 Project References

🔮 Atc.Rest.Api.SourceGenerator
├── 📦 Atc.Rest.Api.Generator           # Shared library
├── 📦 Atc.NetStandard20                # String utilities
├── 📦 Atc.CodeGeneration.CSharp        # Code generation
└── 📦 Atc.OpenApi                      # OpenAPI extensions

⚠️ Important Version Notes

  • Microsoft.OpenApi v3.0.1: Breaking changes from v1.x

    • Uses IOpenApiSchema interface
    • Pattern match OpenApiSchemaReference vs OpenApiSchema
    • schemaRef.Reference.Id instead of schemaRef.Id
    • JsonSchemaType enum instead of string types
  • sample/ projects are deliberately pinned to Microsoft.OpenApi 2.x (2.9.0), not 3.x. This is separate from — and independent of — the src/ generator's own Microsoft.OpenApi 3.x usage above. Microsoft.AspNetCore.OpenApi (ASP.NET Core's built-in OpenAPI generator, used by any sample with a Scalar/Swagger host UI) depends on Microsoft.OpenApi 2.0.0, which is vulnerable to GHSA-v5pm-xwqc-g5wc (NU1903). The pin lives in sample/Directory.Build.props so every sample project resolves to a patched version.

    • Do not bump this pin to 3.x to "match src/" — Microsoft.AspNetCore.OpenApi's built-in XmlCommentGenerator source generator assumes the 2.x API shape (IOpenApiMediaType.Example is settable there; 3.x made it read-only), and fails with CS0200 against 3.x. See FAQ and Troubleshooting#-nu1903-microsoftopenapi-has-a-known-high-severity-vulnerability for the consumer-facing version of this.
    • If you add a new sample project that references Microsoft.AspNetCore.OpenApi directly, it inherits the pin automatically via sample/Directory.Build.props — no per-project action needed. The CLI's project scaffolding (ProjectScaffoldingService.GenerateHostProjectContent) applies the equivalent pin to freshly generated consumer projects.
    • This pin has been silently lost from the working tree before (an uncommitted edit got dropped). If NU1903 warnings reappear across all sample projects, check sample/Directory.Build.props first.

📝 Code Style

🔍 Analyzers Enforced

<PackageReference Include="Atc.Analyzer " Version="0.1.22" PrivateAssets="All" />
<PackageReference Include="AsyncFixer" Version="2.1.0" PrivateAssets="All" />
<PackageReference Include="Asyncify" Version="0.9.7" PrivateAssets="All" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.286" PrivateAssets="All" />
<PackageReference Include="SecurityCodeScan.VS2019" Version="5.6.7" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.18.0.131500" PrivateAssets="All" />

📋 Key Conventions

  • ✅ Use #nullable enable in all generated code
  • 📝 Use records for models, classes for services
  • ➡️ Prefer expression-bodied members
  • 📖 XML documentation on public APIs
  • 🔤 camelCase for private fields (no underscore prefix)

🤝 Contributing Workflow

  1. 🍴 Fork the repository
  2. 🌿 Create a branch for your feature
  3. 💻 Implement following existing patterns
  4. 🧪 Add tests (validation tests, scenario tests)
  5. 🏗️ Run dotnet build -c Release (must pass with 0 warnings)
  6. ✅ Run dotnet test (must pass)
  7. 🚀 Submit PR with description of changes

🔗 Quick Links

Resource Path
📦 Shared Library src/Atc.Rest.Api.Generator/
🔮 Source Generators src/Atc.Rest.Api.SourceGenerator/
🔧 Extractors (ALL 45+) src/Atc.Rest.Api.Generator/Extractors/
⚙️ Services src/Atc.Rest.Api.Generator/Services/
✅ Validators src/Atc.Rest.Api.Generator/Validators/
⚙️ Configurations src/Atc.Rest.Api.Generator/Configurations/
📄 OpenAPI Extensions src/Atc.OpenApi/Extensions/
🧪 Test Scenarios test/Scenarios/
📋 Samples sample/

Clone this wiki locally