• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ImmediatePlatform / Immediate.Apis / 28677000796

03 Jul 2026 06:14PM UTC coverage: 91.678% (+0.8%) from 90.893%
28677000796

push

github

web-flow
Strongly-typed Route Groups (#302)

* Revert `[RouteGroup]`
* Initial draft of strongly-typed Route Groups
* Initial analyzers
* Check target of `MapGroup`
* Better access to `HashCode`
* Fix braceless route group codefix
* Improve whitespace handling

427 of 453 new or added lines in 16 files covered. (94.26%)

6 existing lines in 1 file now uncovered.

1333 of 1454 relevant lines covered (91.68%)

3.67 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.14
/src/Immediate.Apis.Generators/ImmediateApisGenerator.Transform.cs
1
using System.Diagnostics.CodeAnalysis;
2
using Microsoft.CodeAnalysis;
3
using Microsoft.CodeAnalysis.CSharp;
4

5
namespace Immediate.Apis.Generators;
6

7
public sealed partial class ImmediateApisGenerator
8
{
9
        private static Method? TransformEndpoint(
10
                GeneratorAttributeSyntaxContext context,
11
                CancellationToken token
12
        )
13
        {
14
                token.ThrowIfCancellationRequested();
4✔
15

16
                var symbol = (INamedTypeSymbol)context.TargetSymbol;
4✔
17
                var attributes = symbol.GetAttributes();
4✔
18

19
                token.ThrowIfCancellationRequested();
4✔
20

21
                if (attributes.GetMethodAttribute() is not { } attribute)
4✔
22
                        return null;
×
23

24
                if (attribute.GetRoutes() is not { Count: > 0 } routes)
4✔
25
                        return null;
×
26

27
                token.ThrowIfCancellationRequested();
4✔
28

29
                if (symbol.GetValidHandleMethod() is not { } handleMethod)
4✔
30
                        return null;
4✔
31

32
                token.ThrowIfCancellationRequested();
4✔
33

34
                if (!TryGetMapGoup(symbol, out var routeGroupFullClassName))
4✔
NEW
35
                        return null;
×
36

37
                token.ThrowIfCancellationRequested();
4✔
38

39
                var allowAnonymous = attributes.Any(a => a.AttributeClass.IsAllowAnonymousAttribute);
4✔
40

41
                var authorizeAttribute = attributes.FirstOrDefault(a => a.AttributeClass.IsAuthorizeAttribute);
4✔
42
                var authorize = authorizeAttribute != null;
4✔
43
                var authorizePolicy = string.Empty;
4✔
44

45
                switch (authorizeAttribute)
46
                {
47
                        case { ConstructorArguments.Length: > 0 }:
48
                                authorizePolicy = (string)authorizeAttribute.ConstructorArguments[0].Value!;
4✔
49
                                break;
4✔
50

51
                        case { NamedArguments.Length: > 0 }:
52
                        {
53
                                foreach (var argument in authorizeAttribute.NamedArguments)
4✔
54
                                {
55
                                        if (argument is not { Key: "Policy", Value.Value: string ap })
4✔
56
                                                return null;
4✔
57

58
                                        authorizePolicy = ap;
4✔
59
                                }
60

61
                                break;
62
                        }
63

64
                        default:
65
                                break;
66
                }
67

68
                token.ThrowIfCancellationRequested();
4✔
69

70
                var @namespace = symbol.ContainingNamespace.ToString().NullIf("<global namespace>");
4✔
71
                var @class = GetClass(symbol);
4✔
72

73
                token.ThrowIfCancellationRequested();
4✔
74

75
                var className = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
4✔
76
                var classAsMethodName = symbol.ToString().Replace('.', '_');
4✔
77
                var parameterType = handleMethod.Parameters[0].Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
4✔
78

79
                token.ThrowIfCancellationRequested();
4✔
80

81
                var mapMethod = attribute.GetMapMethodName();
4✔
82
                var httpMethod = attribute.GetMapMethodMethod();
4✔
83
                var parameterAttribute = GetParameterAttribute(handleMethod.Parameters[0], mapMethod);
4✔
84
                var handleMethodAttributes = GetHandleMethodAttributes(handleMethod);
4✔
85
                var useCustomization = HasCustomizeEndpointMethod(symbol);
4✔
86
                var useTransformMethod = HasTransformResultMethod(symbol, handleMethod.ReturnType);
4✔
87

88
                token.ThrowIfCancellationRequested();
4✔
89

90
                return new()
4✔
91
                {
4✔
92
                        MapMethod = mapMethod,
4✔
93
                        HttpMethod = httpMethod,
4✔
94
                        Attributes = handleMethodAttributes,
4✔
95
                        ParameterAttribute = parameterAttribute,
4✔
96
                        Routes = new(routes),
4✔
97

4✔
98
                        Namespace = @namespace,
4✔
99
                        Class = @class,
4✔
100
                        ClassFullName = className,
4✔
101
                        ParameterType = parameterType,
4✔
102

4✔
103
                        AllowAnonymous = allowAnonymous,
4✔
104
                        Authorize = authorize,
4✔
105
                        AuthorizePolicy = authorizePolicy,
4✔
106

4✔
107
                        UseCustomization = useCustomization,
4✔
108
                        UseTransformMethod = useTransformMethod,
4✔
109
                        HasReturn = handleMethod.ReturnType.IsValueTask1,
4✔
110

4✔
111
                        RouteGroupClassFullName = routeGroupFullClassName,
4✔
112
                };
4✔
113
        }
114

115
        private static RouteGroupDefinition? TransformRouteGroup(
116
                GeneratorAttributeSyntaxContext context,
117
                CancellationToken token
118
        )
119
        {
120
                token.ThrowIfCancellationRequested();
4✔
121

122
                if (context.Attributes[0].ConstructorArguments is not [{ Value: string route }])
4✔
NEW
123
                        return null;
×
124

125
                var symbol = (INamedTypeSymbol)context.TargetSymbol;
4✔
126

127
                if (symbol.ContainingType is { } && symbol.ContainingType.GetAttributes().GetRouteGroupAttribute() is null)
4✔
NEW
128
                        return null;
×
129

130
                var @namespace = symbol.ContainingNamespace.ToString().NullIf("<global namespace>");
4✔
131
                var outerClasses = GetOuterClasses(symbol);
4✔
132
                var @class = GetClass(symbol);
4✔
133
                var customization = HasCustomizeGroupMethod(symbol);
4✔
134

135
                return new()
4✔
136
                {
4✔
137
                        Namespace = @namespace,
4✔
138
                        OuterClasses = outerClasses,
4✔
139
                        Class = @class,
4✔
140
                        ClassFullName = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
4✔
141
                        RouteGroupClassFullName = symbol.ContainingType?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
4✔
142
                        Route = route,
4✔
143
                        UseCustomization = customization,
4✔
144
                };
4✔
145
        }
146

147
        private static bool TryGetMapGoup(INamedTypeSymbol symbol, out string? routeGroupFullClassName)
148
        {
149
                if (symbol.GetAttributes().GetMapGroupAttribute() is not { AttributeClass.TypeArguments: [{ } groupTypeSymbol] })
4✔
150
                {
151
                        routeGroupFullClassName = null;
4✔
152
                        return true;
4✔
153
                }
154

155
                if (groupTypeSymbol.GetAttributes().GetRouteGroupAttribute() is null)
4✔
156
                {
NEW
157
                        routeGroupFullClassName = null;
×
NEW
158
                        return false;
×
159
                }
160

161
                routeGroupFullClassName = groupTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
4✔
162
                return true;
4✔
163
        }
164

165
        private static bool HasCustomizeEndpointMethod(INamedTypeSymbol symbol)
166
                => symbol
4✔
167
                        .GetMembers()
4✔
168
                        .OfType<IMethodSymbol>()
4✔
169
                        .Any(m =>
4✔
170
                                m is
4✔
171
                                {
4✔
172
                                        Name: "CustomizeEndpoint",
4✔
173
                                        IsStatic: true,
4✔
174
                                        DeclaredAccessibility: Accessibility.Internal or Accessibility.Private,
4✔
175
                                        ReturnsVoid: true,
4✔
176
                                        Parameters: [{ Type.IsIEndpointConventionBuilderOrRouteHandlerBuilder: true }],
4✔
177
                                }
4✔
178
                        );
4✔
179

180
        private static bool HasCustomizeGroupMethod(INamedTypeSymbol symbol)
181
                => symbol
4✔
182
                        .GetMembers()
4✔
183
                        .OfType<IMethodSymbol>()
4✔
184
                        .Any(m =>
4✔
185
                                m is
4✔
186
                                {
4✔
187
                                        Name: "CustomizeGroup",
4✔
188
                                        IsStatic: true,
4✔
189
                                        DeclaredAccessibility: Accessibility.Private,
4✔
190
                                        ReturnsVoid: true,
4✔
191
                                        Parameters: [{ Type.IsRouteGroupBuilder: true }],
4✔
192
                                }
4✔
193
                        );
4✔
194

195
        private static bool HasTransformResultMethod(INamedTypeSymbol symbol, ITypeSymbol returnType)
196
        {
197
                return (
4✔
198
                        returnType is INamedTypeSymbol { IsValueTask1: true, TypeArguments: [{ } returnInnerType] }
4✔
199
                        && symbol
4✔
200
                                .GetMembers()
4✔
201
                                .OfType<IMethodSymbol>()
4✔
202
                                .Any(m =>
4✔
203
                                        m is
4✔
204
                                        {
4✔
205
                                                Name: "TransformResult",
4✔
206
                                                IsStatic: true,
4✔
207
                                                DeclaredAccessibility: Accessibility.Internal,
4✔
208
                                                ReturnsVoid: false,
4✔
209
                                                Parameters: [{ Type: { } paramType }],
4✔
210
                                        }
4✔
211
                                        && SymbolEqualityComparer.IncludeNullability.Equals(returnInnerType, paramType)
4✔
212
                                )
4✔
213
                )
4✔
214
                || (
4✔
215
                        returnType is INamedTypeSymbol { IsValueTask: true }
4✔
216
                        && symbol
4✔
217
                                .GetMembers()
4✔
218
                                .OfType<IMethodSymbol>()
4✔
219
                                .Any(m =>
4✔
220
                                        m is
4✔
221
                                        {
4✔
222
                                                Name: "TransformResult",
4✔
223
                                                IsStatic: true,
4✔
224
                                                DeclaredAccessibility: Accessibility.Internal,
4✔
225
                                                ReturnsVoid: false,
4✔
226
                                                Parameters: [],
4✔
227
                                        }
4✔
228
                                )
4✔
229
                                );
4✔
230
        }
231

232
        private static EquatableReadOnlyList<Class> GetOuterClasses(INamedTypeSymbol symbol)
233
        {
234
                List<Class>? outerClasses = null;
4✔
235
                var outerSymbol = symbol.ContainingType;
4✔
236
                while (outerSymbol is not null)
4✔
237
                {
238
                        (outerClasses ??= []).Add(GetClass(outerSymbol));
4✔
239
                        outerSymbol = outerSymbol.ContainingType;
4✔
240
                }
241

242
                if (outerClasses is null)
4✔
243
                        return default;
4✔
244

245
                outerClasses.Reverse();
4✔
246

247
                return outerClasses.ToEquatableReadOnlyList();
4✔
248
        }
249

250
        private static Class GetClass(INamedTypeSymbol symbol) =>
251
                new()
4✔
252
                {
4✔
253
                        Name = symbol.Name,
4✔
254
                        Type = symbol switch
4✔
255
                        {
4✔
256
                                { TypeKind: TypeKind.Interface } => "interface",
×
257
                                { IsRecord: true, TypeKind: TypeKind.Struct, } => "record struct",
×
258
                                { IsRecord: true, } => "record",
×
259
                                { TypeKind: TypeKind.Struct, } => "struct",
×
260
                                _ => "class",
4✔
261
                        },
4✔
262
                };
4✔
263

264
        private static string GetParameterAttribute(IParameterSymbol parameterSymbol, string httpMethod)
265
        {
266
                foreach (var a in parameterSymbol.GetAttributes())
4✔
267
                {
268
                        if (a.AttributeClass.IsBindingParameterAttribute)
4✔
269
                                return a.AttributeClass.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
4✔
270
                }
271

272
                if (parameterSymbol.Type is INamedTypeSymbol typeSymbol)
4✔
273
                {
274
                        foreach (var p in typeSymbol.GetMembers().OfType<IPropertySymbol>())
4✔
275
                        {
276
                                if (p.Type.IsIFormFile)
4✔
277
                                        return "global::Microsoft.AspNetCore.Mvc.FromFormAttribute";
4✔
278
                        }
279

280
                        foreach (var p in typeSymbol.GetMembers().OfType<IPropertySymbol>())
4✔
281
                        {
282
                                if (p.GetAttributes().Any(a => a.AttributeClass.IsFromXxxAttribute))
4✔
283
                                        return "global::Microsoft.AspNetCore.Http.AsParametersAttribute";
4✔
284
                        }
285
                }
286

287
                return httpMethod is "MapPatch" or "MapPost" or "MapPut"
4✔
288
                        ? "global::Microsoft.AspNetCore.Mvc.FromBodyAttribute"
4✔
289
                        : "global::Microsoft.AspNetCore.Http.AsParametersAttribute";
4✔
290
        }
4✔
291

292
        private static EquatableReadOnlyList<string> GetHandleMethodAttributes(IMethodSymbol methodSymbol) =>
293
                methodSymbol.GetAttributes()
4✔
294
                        .Select(GetAttributeString)
4✔
295
                        .ToEquatableReadOnlyList();
4✔
296

297
        private static string GetAttributeString(AttributeData attributeData)
298
        {
299
                var @class = attributeData.AttributeClass!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
4✔
300

301
                var parameters = new List<string>();
4✔
302

303
                foreach (var tc in attributeData.ConstructorArguments)
4✔
304
                {
305
                        if (GetTypedConstantString(tc) is { } str)
4✔
306
                                parameters.Add(str);
4✔
307
                }
308

309
                foreach (var na in attributeData.NamedArguments)
4✔
310
                {
311
                        if (GetTypedConstantString(na.Value) is { } str)
×
312
                                parameters.Add($"{na.Key} = {str}");
×
313
                }
314

315
                return parameters.Count == 0
4✔
316
                        ? @class
4✔
317
                        : $"{@class}({string.Join(", ", parameters)})";
4✔
318
        }
319

320
        [SuppressMessage("Style", "IDE0072:Add missing cases")]
321
        private static string? GetTypedConstantString(TypedConstant tc) =>
322
                tc.Kind switch
4✔
323
                {
4✔
324
                        TypedConstantKind.Array => $"[{string.Join(", ", tc.Values.Select(GetTypedConstantString))}]",
×
325
                        _ => tc.ToCSharpString(),
4✔
326
                };
4✔
327
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc