Skip to content

Commit 95e86d8

Browse files
Merge pull request #272 from TimeWarpEngineering/dev
Generated mock-factory registry (085-003): defining a factory IS registering it
2 parents 5a93dd8 + ba57b4b commit 95e86d8

8 files changed

Lines changed: 279 additions & 47 deletions

File tree

.editorconfig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ csharp_style_prefer_readonly_struct = true
132132
csharp_style_prefer_readonly_struct_member = true
133133

134134
# Code-block preferences
135-
csharp_prefer_braces = when-multiline:suggestion
135+
csharp_prefer_braces = when_multiline:warning
136136
csharp_prefer_simple_using_statement = true:suggestion
137137
csharp_style_namespace_declarations = file_scoped:error
138138
csharp_style_prefer_method_group_conversion = true
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Source-generate the MockWebApiService factory registry
2+
3+
## Parent
4+
085-analyzer-and-source-gen-opportunities-to-remove-inference-collected-candidates
5+
6+
## Description
7+
8+
`MockWebApiService.Factories` is a hand-maintained `Dictionary<Type, Delegate>`; a contract can
9+
define `GetMockResponseFactory()` and never be registered (task 078 had to hand-add the CreateRole
10+
entry). Source-generate the registry: scan referenced contract types for the
11+
`GetMockResponseFactory()` convention and emit the dictionary population into a partial of
12+
`MockWebApiService` — the manual registration step disappears entirely.
13+
14+
## Checklist
15+
16+
- [x] `MockResponseFactoryRegistryGenerator` in the existing `timewarp-architecture-analyzers`
17+
generator assembly (web-spa already references it as an analyzer — zero new wiring).
18+
Scans referenced *contracts* assemblies for public static parameterless
19+
`GetMockResponseFactory()` + nested Query/Command; emits a sorted
20+
`GeneratedMockResponseFactories.Create()` registry. Gated on the compilation declaring a
21+
`MockWebApiService` class, so servers referencing contracts get nothing.
22+
- [x] Registry design refined from "partial class": the generator emits a standalone
23+
`GeneratedMockResponseFactories` class and the hand-written service consumes it — the
24+
manual dictionary is gone. The old "comment a line out to use the real API" affordance is
25+
preserved as an explicit `UseRealApi` exclusion set in the hand file.
26+
- [x] Generator tests (2): factory contract registered / factory-less contract absent;
27+
no `MockWebApiService` host → nothing emitted. Sourcegen suite 16/16.
28+
- [x] Mock mode verified: built web-spa with the `MOCK_WEB_API` define enabled — compiles clean
29+
against the generated registry. All suites green (analyzer 26, sourcegen 16, contracts 7,
30+
web-server integration 22); `dev build` 0/0.
31+
32+
## Results
33+
34+
**The generator's first real output caught live drift**: the hand dictionary had **7** entries;
35+
the generated registry has **8**`GetSignInToken` defined a factory that was never registered.
36+
Exactly the agreement-by-memory failure this candidate predicted. From here, defining
37+
`GetMockResponseFactory()` on a contract IS registering it; new contracts (like the four roles
38+
factories added this week) appear in mock mode with zero registration work.
39+
40+
## Notes
41+
42+
- Convention detection: `public static MockResponseFactory<T> GetMockResponseFactory()` on the
43+
static contract shell. Cross-assembly metadata scan, like the FastEndpoint generator does.
44+
- Incidental fix: two pre-existing brace-less `if`s in `ModalContainer.razor.cs` surfaced when
45+
the changed analyzer assembly forced full re-analysis of web-spa; brought up to repo style.

kanban/to-do/085-003-source-generate-the-mockwebapiservice-factory-registry.md

Lines changed: 0 additions & 26 deletions
This file was deleted.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#region Purpose
2+
// Generates the MockWebApiService factory registry from every contract exposing GetMockResponseFactory().
3+
#endregion
4+
5+
#region Design
6+
// Removes the hand-maintained Dictionary<Type, Delegate> registration step (a contract could
7+
// define a factory that was never registered — agreement by memory). The registry is derived:
8+
// any referenced *contracts* assembly type with a public static parameterless
9+
// GetMockResponseFactory() and a nested Query/Command gets an entry keyed by that request type.
10+
// Emission is gated on the compilation declaring a MockWebApiService class (the SPA), so server
11+
// projects that also reference contracts get nothing. The per-feature "use the real API while
12+
// developing" affordance moves from commenting dictionary lines to the hand-written
13+
// UseRealApi set in mock-web-api-service.cs. Entries are sorted for deterministic output.
14+
#endregion
15+
16+
namespace TimeWarp.Architecture.Analyzers;
17+
18+
using System.Collections.Generic;
19+
20+
[Generator]
21+
public sealed class MockResponseFactoryRegistryGenerator : IIncrementalGenerator
22+
{
23+
private const string HostTypeName = "MockWebApiService";
24+
private const string FactoryMethodName = "GetMockResponseFactory";
25+
26+
public void Initialize(IncrementalGeneratorInitializationContext context)
27+
{
28+
context.RegisterSourceOutput(context.CompilationProvider, static (spc, compilation) => Execute(spc, compilation));
29+
}
30+
31+
private static void Execute(SourceProductionContext context, Compilation compilation)
32+
{
33+
INamedTypeSymbol? host = compilation.GetSymbolsWithName(HostTypeName, SymbolFilter.Type)
34+
.OfType<INamedTypeSymbol>()
35+
.FirstOrDefault();
36+
if (host is null) return;
37+
38+
var entries = new List<(string RequestType, string Shell)>();
39+
40+
IEnumerable<IAssemblySymbol> assemblies = compilation.SourceModule.ReferencedAssemblySymbols
41+
.Where(static a => a.Name.Contains("contracts", System.StringComparison.OrdinalIgnoreCase))
42+
.Concat(new[] { (IAssemblySymbol)compilation.Assembly });
43+
44+
foreach (IAssemblySymbol assembly in assemblies)
45+
{
46+
foreach (INamedTypeSymbol type in GetAllTypes(assembly.GlobalNamespace))
47+
{
48+
bool hasFactory = type.GetMembers(FactoryMethodName).OfType<IMethodSymbol>()
49+
.Any(static m => m is { IsStatic: true, Parameters.Length: 0, DeclaredAccessibility: Accessibility.Public });
50+
if (!hasFactory) continue;
51+
52+
INamedTypeSymbol? request = type.GetTypeMembers()
53+
.FirstOrDefault(static t => t.Name is "Query" or "Command");
54+
if (request is null) continue;
55+
56+
entries.Add((
57+
request.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
58+
type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)));
59+
}
60+
}
61+
62+
entries.Sort(static (a, b) => string.CompareOrdinal(a.RequestType, b.RequestType));
63+
64+
var sb = new StringBuilder();
65+
sb.Append("// <auto-generated/>\n#nullable enable\n");
66+
sb.Append("namespace ").Append(host.ContainingNamespace.ToDisplayString()).Append(";\n\n");
67+
sb.Append("internal static class GeneratedMockResponseFactories\n{\n");
68+
sb.Append(" internal static global::System.Collections.Generic.Dictionary<global::System.Type, global::System.Delegate> Create() =>\n");
69+
sb.Append(" new()\n {\n");
70+
foreach ((string requestType, string shell) in entries)
71+
{
72+
sb.Append(" { typeof(").Append(requestType).Append("), ")
73+
.Append(shell).Append('.').Append(FactoryMethodName).Append("() },\n");
74+
}
75+
76+
sb.Append(" };\n}\n");
77+
78+
context.AddSource("GeneratedMockResponseFactories.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
79+
}
80+
81+
private static IEnumerable<INamedTypeSymbol> GetAllTypes(INamespaceSymbol root)
82+
{
83+
foreach (INamespaceOrTypeSymbol member in root.GetMembers())
84+
{
85+
switch (member)
86+
{
87+
case INamespaceSymbol ns:
88+
foreach (INamedTypeSymbol nested in GetAllTypes(ns)) yield return nested;
89+
break;
90+
case INamedTypeSymbol type:
91+
yield return type;
92+
break;
93+
}
94+
}
95+
}
96+
}

source/analyzers/timewarp-architecture-convention-analyzers/endpoint-coverage-analyzer.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,10 @@ private static string FirstSegment(string assemblyName)
184184
{
185185
string? name = attribute.AttributeClass?.Name;
186186
if (name is null || !name.StartsWith("Http", System.StringComparison.Ordinal)
187-
|| !name.EndsWith("Attribute", System.StringComparison.Ordinal)) return null;
187+
|| !name.EndsWith("Attribute", System.StringComparison.Ordinal))
188+
{
189+
return null;
190+
}
188191

189192
string verb = name.Substring("Http".Length, name.Length - "Http".Length - "Attribute".Length);
190193
return verb is "Get" or "Post" or "Put" or "Delete" or "Patch" or "Head" or "Options" ? verb : null;

source/container-apps/web/web-spa/features/application/components/ModalContainer.razor.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,22 @@ partial class ModalContainer
2626
protected override void OnInitialized()
2727
{
2828
if (Parent == null)
29+
{
2930
throw new ArgumentNullException
3031
(
31-
nameof(Parent),
32-
$"{nameof(ModalContainer)} must exist within a {nameof(ModalController)} Component"
32+
nameof(Parent),
33+
$"{nameof(ModalContainer)} must exist within a {nameof(ModalController)} Component"
3334
);
35+
}
3436

3537
if (!OnActivate.HasDelegate)
38+
{
3639
throw new ArgumentNullException
3740
(
38-
nameof(OnActivate),
39-
$"{nameof(OnActivate)} is required"
41+
nameof(OnActivate),
42+
$"{nameof(OnActivate)} is required"
4043
);
44+
}
4145

4246
base.OnInitialized();
4347
Parent.AddModal(this);

source/container-apps/web/web-spa/services/mocks/mock-web-api-service.cs

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
#endregion
44

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

32-
private readonly Dictionary<Type, Delegate> Factories = new()
34+
// Requests listed here fall through to the real API service even in mock mode.
35+
private static readonly HashSet<Type> UseRealApi =
36+
[
37+
// typeof(GetProfile.Query),
38+
];
39+
40+
private readonly Dictionary<Type, Delegate> Factories = CreateFactories();
41+
42+
private static Dictionary<Type, Delegate> CreateFactories()
3343
{
34-
// Comment out those where you want to use the real API service
35-
{ typeof(GetCurrentUser.Query), GetCurrentUser.GetMockResponseFactory() },
36-
{ typeof(GetRoles.Query),GetRoles.GetMockResponseFactory() },
37-
{ typeof(GetRole.Query), GetRole.GetMockResponseFactory() },
38-
{ typeof(CreateRole.Command), CreateRole.GetMockResponseFactory() },
39-
{ typeof(UpdateRole.Command), UpdateRole.GetMockResponseFactory()},
40-
{ typeof(DeleteRole.Command), DeleteRole.GetMockResponseFactory()},
41-
{ typeof(GetProfile.Query), GetProfile.GetMockResponseFactory() }
42-
43-
// Add other mappings here
44-
};
44+
Dictionary<Type, Delegate> factories = GeneratedMockResponseFactories.Create();
45+
foreach (Type type in UseRealApi) factories.Remove(type);
46+
return factories;
47+
}
4548

4649
public async Task<OneOf<TResponse, FileResponse, SharedProblemDetails>> GetResponse<TResponse>
4750
(
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#region Purpose
2+
// Verifies the mock-factory registry generator: contracts with GetMockResponseFactory register,
3+
// others don't, and nothing is emitted without a MockWebApiService host.
4+
#endregion
5+
6+
namespace TimeWarp.Architecture.SourceGenerator.Tests;
7+
8+
using System;
9+
using System.Linq;
10+
using Microsoft.CodeAnalysis.CSharp;
11+
12+
public class MockResponseFactoryRegistryGenerator_Tests
13+
{
14+
private const string ContractSource = """
15+
namespace TimeWarp.Foundation.Features
16+
{
17+
public interface IApiRequest { }
18+
}
19+
namespace TimeWarp.Foundation.Types
20+
{
21+
public delegate TResponse MockResponseFactory<out TResponse>(TimeWarp.Foundation.Features.IApiRequest request) where TResponse : class;
22+
}
23+
namespace MyApp.Features.Widgets
24+
{
25+
public static class GetWidget
26+
{
27+
public sealed class Query : TimeWarp.Foundation.Features.IApiRequest { }
28+
public sealed class Response { }
29+
public static TimeWarp.Foundation.Types.MockResponseFactory<Response> GetMockResponseFactory() => _ => new Response();
30+
}
31+
32+
public static class NoFactory
33+
{
34+
public sealed class Query : TimeWarp.Foundation.Features.IApiRequest { }
35+
public sealed class Response { }
36+
}
37+
}
38+
""";
39+
40+
private const string HostSource = """
41+
namespace TimeWarp.Architecture.Services
42+
{
43+
public class MockWebApiService { }
44+
}
45+
""";
46+
47+
public static void Should_Register_Contract_Factories()
48+
{
49+
string generated = RunGenerator(ContractSource, HostSource);
50+
51+
generated.ShouldContain("namespace TimeWarp.Architecture.Services;");
52+
generated.ShouldContain("typeof(global::MyApp.Features.Widgets.GetWidget.Query)");
53+
generated.ShouldContain("global::MyApp.Features.Widgets.GetWidget.GetMockResponseFactory()");
54+
generated.ShouldNotContain("NoFactory");
55+
}
56+
57+
public static void Should_Emit_Nothing_Without_MockWebApiService_Host()
58+
{
59+
string generated = RunGenerator(ContractSource, consumerSource: "namespace App { public class NotAMockService { } }");
60+
61+
generated.ShouldBe(string.Empty);
62+
}
63+
64+
private static string RunGenerator(string contractSource, string consumerSource)
65+
{
66+
// The generator scans REFERENCED *contracts* assemblies, so compile the contract separately
67+
// (assembly name "Test.Contracts" satisfies the name filter) and reference it.
68+
Microsoft.CodeAnalysis.MetadataReference contractReference = CompileContracts(contractSource);
69+
70+
var compilation = CSharpCompilation.Create(
71+
"Test.Spa",
72+
syntaxTrees: [CSharpSyntaxTree.ParseText(consumerSource)],
73+
references:
74+
[
75+
Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
76+
contractReference,
77+
],
78+
new CSharpCompilationOptions(Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary));
79+
80+
var generator = new Analyzers.MockResponseFactoryRegistryGenerator();
81+
Microsoft.CodeAnalysis.GeneratorDriver driver =
82+
CSharpGeneratorDriver.Create(generator.AsSourceGenerator());
83+
84+
Microsoft.CodeAnalysis.GeneratorDriverRunResult result = driver.RunGenerators(compilation).GetRunResult();
85+
return string.Join("\n", result.GeneratedTrees.Select(tree => tree.ToString()));
86+
}
87+
88+
private static Microsoft.CodeAnalysis.MetadataReference CompileContracts(string source)
89+
{
90+
var compilation = CSharpCompilation.Create(
91+
"Test.Contracts",
92+
syntaxTrees: [CSharpSyntaxTree.ParseText(source)],
93+
references: [Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(object).Assembly.Location)],
94+
new CSharpCompilationOptions(Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary));
95+
96+
using var peStream = new System.IO.MemoryStream();
97+
Microsoft.CodeAnalysis.Emit.EmitResult emitResult = compilation.Emit(peStream);
98+
if (!emitResult.Success)
99+
{
100+
string errors = string.Join(Environment.NewLine, emitResult.Diagnostics);
101+
throw new InvalidOperationException($"Contract assembly failed to compile:{Environment.NewLine}{errors}");
102+
}
103+
104+
peStream.Position = 0;
105+
return Microsoft.CodeAnalysis.MetadataReference.CreateFromStream(peStream);
106+
}
107+
}

0 commit comments

Comments
 (0)