Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ csharp_style_prefer_readonly_struct = true
csharp_style_prefer_readonly_struct_member = true

# Code-block preferences
csharp_prefer_braces = when-multiline:suggestion
csharp_prefer_braces = when_multiline:warning
csharp_prefer_simple_using_statement = true:suggestion
csharp_style_namespace_declarations = file_scoped:error
csharp_style_prefer_method_group_conversion = true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Source-generate the MockWebApiService factory registry

## Parent
085-analyzer-and-source-gen-opportunities-to-remove-inference-collected-candidates

## Description

`MockWebApiService.Factories` is a hand-maintained `Dictionary<Type, Delegate>`; a contract can
define `GetMockResponseFactory()` and never be registered (task 078 had to hand-add the CreateRole
entry). Source-generate the registry: scan referenced contract types for the
`GetMockResponseFactory()` convention and emit the dictionary population into a partial of
`MockWebApiService` — the manual registration step disappears entirely.

## Checklist

- [x] `MockResponseFactoryRegistryGenerator` in the existing `timewarp-architecture-analyzers`
generator assembly (web-spa already references it as an analyzer — zero new wiring).
Scans referenced *contracts* assemblies for public static parameterless
`GetMockResponseFactory()` + nested Query/Command; emits a sorted
`GeneratedMockResponseFactories.Create()` registry. Gated on the compilation declaring a
`MockWebApiService` class, so servers referencing contracts get nothing.
- [x] Registry design refined from "partial class": the generator emits a standalone
`GeneratedMockResponseFactories` class and the hand-written service consumes it — the
manual dictionary is gone. The old "comment a line out to use the real API" affordance is
preserved as an explicit `UseRealApi` exclusion set in the hand file.
- [x] Generator tests (2): factory contract registered / factory-less contract absent;
no `MockWebApiService` host → nothing emitted. Sourcegen suite 16/16.
- [x] Mock mode verified: built web-spa with the `MOCK_WEB_API` define enabled — compiles clean
against the generated registry. All suites green (analyzer 26, sourcegen 16, contracts 7,
web-server integration 22); `dev build` 0/0.

## Results

**The generator's first real output caught live drift**: the hand dictionary had **7** entries;
the generated registry has **8** — `GetSignInToken` defined a factory that was never registered.
Exactly the agreement-by-memory failure this candidate predicted. From here, defining
`GetMockResponseFactory()` on a contract IS registering it; new contracts (like the four roles
factories added this week) appear in mock mode with zero registration work.

## Notes

- Convention detection: `public static MockResponseFactory<T> GetMockResponseFactory()` on the
static contract shell. Cross-assembly metadata scan, like the FastEndpoint generator does.
- Incidental fix: two pre-existing brace-less `if`s in `ModalContainer.razor.cs` surfaced when
the changed analyzer assembly forced full re-analysis of web-spa; brought up to repo style.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#region Purpose
// Generates the MockWebApiService factory registry from every contract exposing GetMockResponseFactory().
#endregion

#region Design
// Removes the hand-maintained Dictionary<Type, Delegate> registration step (a contract could
// define a factory that was never registered — agreement by memory). The registry is derived:
// any referenced *contracts* assembly type with a public static parameterless
// GetMockResponseFactory() and a nested Query/Command gets an entry keyed by that request type.
// Emission is gated on the compilation declaring a MockWebApiService class (the SPA), so server
// projects that also reference contracts get nothing. The per-feature "use the real API while
// developing" affordance moves from commenting dictionary lines to the hand-written
// UseRealApi set in mock-web-api-service.cs. Entries are sorted for deterministic output.
#endregion

namespace TimeWarp.Architecture.Analyzers;

using System.Collections.Generic;

[Generator]
public sealed class MockResponseFactoryRegistryGenerator : IIncrementalGenerator
{
private const string HostTypeName = "MockWebApiService";
private const string FactoryMethodName = "GetMockResponseFactory";

public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterSourceOutput(context.CompilationProvider, static (spc, compilation) => Execute(spc, compilation));
}

private static void Execute(SourceProductionContext context, Compilation compilation)
{
INamedTypeSymbol? host = compilation.GetSymbolsWithName(HostTypeName, SymbolFilter.Type)
.OfType<INamedTypeSymbol>()
.FirstOrDefault();
if (host is null) return;

var entries = new List<(string RequestType, string Shell)>();

IEnumerable<IAssemblySymbol> assemblies = compilation.SourceModule.ReferencedAssemblySymbols
.Where(static a => a.Name.Contains("contracts", System.StringComparison.OrdinalIgnoreCase))
.Concat(new[] { (IAssemblySymbol)compilation.Assembly });

foreach (IAssemblySymbol assembly in assemblies)
{
foreach (INamedTypeSymbol type in GetAllTypes(assembly.GlobalNamespace))
{
bool hasFactory = type.GetMembers(FactoryMethodName).OfType<IMethodSymbol>()
.Any(static m => m is { IsStatic: true, Parameters.Length: 0, DeclaredAccessibility: Accessibility.Public });
if (!hasFactory) continue;

INamedTypeSymbol? request = type.GetTypeMembers()
.FirstOrDefault(static t => t.Name is "Query" or "Command");
if (request is null) continue;

entries.Add((
request.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)));
}
}

entries.Sort(static (a, b) => string.CompareOrdinal(a.RequestType, b.RequestType));

var sb = new StringBuilder();
sb.Append("// <auto-generated/>\n#nullable enable\n");
sb.Append("namespace ").Append(host.ContainingNamespace.ToDisplayString()).Append(";\n\n");
sb.Append("internal static class GeneratedMockResponseFactories\n{\n");
sb.Append(" internal static global::System.Collections.Generic.Dictionary<global::System.Type, global::System.Delegate> Create() =>\n");
sb.Append(" new()\n {\n");
foreach ((string requestType, string shell) in entries)
{
sb.Append(" { typeof(").Append(requestType).Append("), ")
.Append(shell).Append('.').Append(FactoryMethodName).Append("() },\n");
}

sb.Append(" };\n}\n");

context.AddSource("GeneratedMockResponseFactories.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
}

private static IEnumerable<INamedTypeSymbol> GetAllTypes(INamespaceSymbol root)
{
foreach (INamespaceOrTypeSymbol member in root.GetMembers())
{
switch (member)
{
case INamespaceSymbol ns:
foreach (INamedTypeSymbol nested in GetAllTypes(ns)) yield return nested;
break;
case INamedTypeSymbol type:
yield return type;
break;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,10 @@ private static string FirstSegment(string assemblyName)
{
string? name = attribute.AttributeClass?.Name;
if (name is null || !name.StartsWith("Http", System.StringComparison.Ordinal)
|| !name.EndsWith("Attribute", System.StringComparison.Ordinal)) return null;
|| !name.EndsWith("Attribute", System.StringComparison.Ordinal))
{
return null;
}

string verb = name.Substring("Http".Length, name.Length - "Http".Length - "Attribute".Length);
return verb is "Get" or "Post" or "Put" or "Delete" or "Patch" or "Head" or "Options" ? verb : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,22 @@ partial class ModalContainer
protected override void OnInitialized()
{
if (Parent == null)
{
throw new ArgumentNullException
(
nameof(Parent),
$"{nameof(ModalContainer)} must exist within a {nameof(ModalController)} Component"
nameof(Parent),
$"{nameof(ModalContainer)} must exist within a {nameof(ModalController)} Component"
);
}

if (!OnActivate.HasDelegate)
{
throw new ArgumentNullException
(
nameof(OnActivate),
$"{nameof(OnActivate)} is required"
nameof(OnActivate),
$"{nameof(OnActivate)} is required"
);
}

base.OnInitialized();
Parent.AddModal(this);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
#endregion

#region Design
// Mock data lives with each contract (GetMockResponseFactory), so this class only maps request
// types to factories; commenting a mapping out reverts that feature to the real server, enabling
// per-feature mocking during UI development. Enabled via the MOCK_WEB_API symbol in Program.
// Mock data lives with each contract (GetMockResponseFactory); the request-type -> factory
// registry is SOURCE-GENERATED from every contract exposing that method
// (MockResponseFactoryRegistryGenerator), so defining a factory IS registering it. To use the
// real server for a feature during UI development, add its request type to UseRealApi below.
// Enabled via the MOCK_WEB_API symbol in Program.
// Requests still run through their FluentValidation validators so mocks cannot mask inputs the
// real server would reject.
#endregion
Expand All @@ -29,19 +31,20 @@ IServiceProvider serviceProvider
ServiceProvider = serviceProvider;
}

private readonly Dictionary<Type, Delegate> Factories = new()
// Requests listed here fall through to the real API service even in mock mode.
private static readonly HashSet<Type> UseRealApi =
[
// typeof(GetProfile.Query),
];

private readonly Dictionary<Type, Delegate> Factories = CreateFactories();

private static Dictionary<Type, Delegate> CreateFactories()
{
// Comment out those where you want to use the real API service
{ typeof(GetCurrentUser.Query), GetCurrentUser.GetMockResponseFactory() },
{ typeof(GetRoles.Query),GetRoles.GetMockResponseFactory() },
{ typeof(GetRole.Query), GetRole.GetMockResponseFactory() },
{ typeof(CreateRole.Command), CreateRole.GetMockResponseFactory() },
{ typeof(UpdateRole.Command), UpdateRole.GetMockResponseFactory()},
{ typeof(DeleteRole.Command), DeleteRole.GetMockResponseFactory()},
{ typeof(GetProfile.Query), GetProfile.GetMockResponseFactory() }

// Add other mappings here
};
Dictionary<Type, Delegate> factories = GeneratedMockResponseFactories.Create();
foreach (Type type in UseRealApi) factories.Remove(type);
return factories;
}

public async Task<OneOf<TResponse, FileResponse, SharedProblemDetails>> GetResponse<TResponse>
(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#region Purpose
// Verifies the mock-factory registry generator: contracts with GetMockResponseFactory register,
// others don't, and nothing is emitted without a MockWebApiService host.
#endregion

namespace TimeWarp.Architecture.SourceGenerator.Tests;

using System;
using System.Linq;
using Microsoft.CodeAnalysis.CSharp;

public class MockResponseFactoryRegistryGenerator_Tests
{
private const string ContractSource = """
namespace TimeWarp.Foundation.Features
{
public interface IApiRequest { }
}
namespace TimeWarp.Foundation.Types
{
public delegate TResponse MockResponseFactory<out TResponse>(TimeWarp.Foundation.Features.IApiRequest request) where TResponse : class;
}
namespace MyApp.Features.Widgets
{
public static class GetWidget
{
public sealed class Query : TimeWarp.Foundation.Features.IApiRequest { }
public sealed class Response { }
public static TimeWarp.Foundation.Types.MockResponseFactory<Response> GetMockResponseFactory() => _ => new Response();
}

public static class NoFactory
{
public sealed class Query : TimeWarp.Foundation.Features.IApiRequest { }
public sealed class Response { }
}
}
""";

private const string HostSource = """
namespace TimeWarp.Architecture.Services
{
public class MockWebApiService { }
}
""";

public static void Should_Register_Contract_Factories()
{
string generated = RunGenerator(ContractSource, HostSource);

generated.ShouldContain("namespace TimeWarp.Architecture.Services;");
generated.ShouldContain("typeof(global::MyApp.Features.Widgets.GetWidget.Query)");
generated.ShouldContain("global::MyApp.Features.Widgets.GetWidget.GetMockResponseFactory()");
generated.ShouldNotContain("NoFactory");
}

public static void Should_Emit_Nothing_Without_MockWebApiService_Host()
{
string generated = RunGenerator(ContractSource, consumerSource: "namespace App { public class NotAMockService { } }");

generated.ShouldBe(string.Empty);
}

private static string RunGenerator(string contractSource, string consumerSource)
{
// The generator scans REFERENCED *contracts* assemblies, so compile the contract separately
// (assembly name "Test.Contracts" satisfies the name filter) and reference it.
Microsoft.CodeAnalysis.MetadataReference contractReference = CompileContracts(contractSource);

var compilation = CSharpCompilation.Create(
"Test.Spa",
syntaxTrees: [CSharpSyntaxTree.ParseText(consumerSource)],
references:
[
Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
contractReference,
],
new CSharpCompilationOptions(Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary));

var generator = new Analyzers.MockResponseFactoryRegistryGenerator();
Microsoft.CodeAnalysis.GeneratorDriver driver =
CSharpGeneratorDriver.Create(generator.AsSourceGenerator());

Microsoft.CodeAnalysis.GeneratorDriverRunResult result = driver.RunGenerators(compilation).GetRunResult();
return string.Join("\n", result.GeneratedTrees.Select(tree => tree.ToString()));
}

private static Microsoft.CodeAnalysis.MetadataReference CompileContracts(string source)
{
var compilation = CSharpCompilation.Create(
"Test.Contracts",
syntaxTrees: [CSharpSyntaxTree.ParseText(source)],
references: [Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(object).Assembly.Location)],
new CSharpCompilationOptions(Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary));

using var peStream = new System.IO.MemoryStream();
Microsoft.CodeAnalysis.Emit.EmitResult emitResult = compilation.Emit(peStream);
if (!emitResult.Success)
{
string errors = string.Join(Environment.NewLine, emitResult.Diagnostics);
throw new InvalidOperationException($"Contract assembly failed to compile:{Environment.NewLine}{errors}");
}

peStream.Position = 0;
return Microsoft.CodeAnalysis.MetadataReference.CreateFromStream(peStream);
}
}
Loading