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

valkyrjaio / sindri-java / 30138871992

25 Jul 2026 01:37AM UTC coverage: 97.932%. First build
30138871992

Pull #38

github

web-flow
Merge e9912f47b into 04c800ff7
Pull Request #38: [Grpc] Add gRPC data-class generation

483 of 499 branches covered (96.79%)

Branch coverage included in aggregate %.

278 of 302 new or added lines in 14 files covered. (92.05%)

1411 of 1435 relevant lines covered (98.33%)

4.3 hits per line

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

89.86
/src/main/java/io/sindri/ast/GrpcRouteAttributeReader.java
1
/*
2
 * This file is part of the Sindri package.
3
 *
4
 * (c) Melech Mizrachi <melechmizrachi@gmail.com>
5
 *
6
 * For the full copyright and license information, please view the LICENSE
7
 * file that was distributed with this source code.
8
 */
9

10
package io.sindri.ast;
11

12
import com.github.javaparser.StaticJavaParser;
13
import com.github.javaparser.ast.CompilationUnit;
14
import com.github.javaparser.ast.body.MethodDeclaration;
15
import com.github.javaparser.ast.body.TypeDeclaration;
16
import com.github.javaparser.ast.expr.AnnotationExpr;
17
import com.github.javaparser.ast.expr.ArrayInitializerExpr;
18
import com.github.javaparser.ast.expr.Expression;
19
import com.github.javaparser.ast.expr.MemberValuePair;
20
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
21
import io.sindri.ast.abstract_.AstReader;
22
import io.sindri.ast.contract.GrpcRouteAttributeReaderContract;
23
import io.sindri.ast.data.GrpcRouteData;
24
import io.sindri.ast.data.HandlerData;
25
import io.sindri.ast.data.result.GrpcRouteAttributeResult;
26
import io.sindri.ast.throwable.exception.NonLiteralAttributeValueException;
27
import java.util.ArrayList;
28
import java.util.LinkedHashMap;
29
import java.util.List;
30
import java.util.Map;
31
import java.util.Optional;
32
import java.util.Set;
33

34
public class GrpcRouteAttributeReader extends AstReader
35
        implements GrpcRouteAttributeReaderContract {
36

37
    /**
38
     * The gRPC middleware stages, in {@link GrpcRouteData} constructor order. Each pairs a stage
39
     * contract (matched against a middleware's ancestry) with the {@code Route} builder call that
40
     * registers a class under it in the generated cache.
41
     */
42
    private enum Stage {
3✔
43
        ROUTE_MATCHED(
8✔
44
                "io.valkyrja.grpc.middleware.contract.RouteMatchedMiddlewareContract",
45
                "withAddedRouteMatchedMiddleware"),
46
        ROUTE_DISPATCHED(
8✔
47
                "io.valkyrja.grpc.middleware.contract.RouteDispatchedMiddlewareContract",
48
                "withAddedRouteDispatchedMiddleware"),
49
        THROWABLE_CAUGHT(
8✔
50
                "io.valkyrja.grpc.middleware.contract.ThrowableCaughtMiddlewareContract",
51
                "withAddedThrowableCaughtMiddleware"),
52
        SENDING_RESPONSE(
8✔
53
                "io.valkyrja.grpc.middleware.contract.SendingResponseMiddlewareContract",
54
                "withAddedSendingResponseMiddleware"),
55
        TERMINATED(
8✔
56
                "io.valkyrja.grpc.middleware.contract.TerminatedMiddlewareContract",
57
                "withAddedTerminatedMiddleware");
58

59
        private final String contractFqn;
60
        private final String addMethod;
61

62
        Stage(String contractFqn, String addMethod) {
4✔
63
            this.contractFqn = contractFqn;
3✔
64
            this.addMethod = addMethod;
3✔
65
        }
1✔
66
    }
67

68
    private static final Set<String> STAGE_CONTRACTS = stageContracts();
3✔
69

70
    private final MiddlewareClassifier classifier = new MiddlewareClassifier();
5✔
71
    private final MiddlewareClassifier.SourceResolver resolver;
72

73
    /** No-op resolver: without a way to resolve middleware sources, no middleware is classified. */
74
    public GrpcRouteAttributeReader() {
75
        this(fqn -> Optional.empty());
5✔
76
    }
1✔
77

78
    public GrpcRouteAttributeReader(MiddlewareClassifier.SourceResolver resolver) {
2✔
79
        this.resolver = resolver;
3✔
80
    }
1✔
81

82
    private static Set<String> stageContracts() {
83
        Set<String> contracts = new java.util.HashSet<>();
4✔
84
        for (Stage stage : Stage.values()) {
16✔
85
            contracts.add(stage.contractFqn);
5✔
86
        }
87
        return contracts;
2✔
88
    }
89

90
    @Override
91
    public GrpcRouteAttributeResult readFile(String filePath) {
92
        CompilationUnit cu = parseFile(filePath);
4✔
93
        String pkg = getPackageName(cu);
4✔
94
        Map<String, String> imports = buildImportMap(cu);
4✔
95

96
        TypeDeclaration<?> type =
2✔
97
                findType(cu)
3✔
98
                        .orElseThrow(() -> new RuntimeException("No type found in: " + filePath));
9✔
99

100
        Map<String, Expression> routeMap = new LinkedHashMap<>();
4✔
101
        Map<String, GrpcRouteData> routeDataMap = new LinkedHashMap<>();
4✔
102

103
        String service = readServiceName(type);
4✔
104
        if (service.isEmpty()) {
3✔
105
            return new GrpcRouteAttributeResult(routeMap, routeDataMap);
6✔
106
        }
107

108
        String controllerFqn =
109
                pkg.isEmpty() ? type.getNameAsString() : pkg + "." + type.getNameAsString();
11✔
110

111
        for (MethodDeclaration method : type.getMethods()) {
11✔
112
            NormalAnnotationExpr methodAttribute = findMethodAttribute(method);
4✔
113
            if (methodAttribute == null) {
2✔
114
                continue;
1✔
115
            }
116

117
            String name = "";
2✔
118
            boolean clientStreaming = false;
2✔
119
            boolean serverStreaming = false;
2✔
120
            for (MemberValuePair pair : methodAttribute.getPairs()) {
11✔
121
                switch (pair.getNameAsString()) {
9✔
122
                    case "name" ->
123
                            name =
3✔
124
                                    requireStringLiteral(
2✔
125
                                            pair.getValue(),
3✔
126
                                            "@Method(name)",
127
                                            method.getNameAsString());
1✔
128
                    case "clientStreaming" -> clientStreaming = extractBoolean(pair.getValue());
6✔
129
                    case "serverStreaming" -> serverStreaming = extractBoolean(pair.getValue());
6✔
130
                    default -> {}
131
                }
132
            }
1✔
133
            if (name.isEmpty()) {
3✔
134
                continue;
1✔
135
            }
136

137
            String fullMethod = "/" + service + "/" + name;
4✔
138
            String methodName = method.getNameAsString();
3✔
139
            Map<Stage, List<String>> middleware = classifyMiddleware(method, imports, pkg);
6✔
140

141
            GrpcRouteData data =
14✔
142
                    new GrpcRouteData(
143
                            fullMethod,
144
                            service,
145
                            name,
146
                            new HandlerData(controllerFqn, methodName),
147
                            clientStreaming,
148
                            serverStreaming,
149
                            stageList(middleware, Stage.ROUTE_MATCHED),
3✔
150
                            stageList(middleware, Stage.ROUTE_DISPATCHED),
3✔
151
                            stageList(middleware, Stage.THROWABLE_CAUGHT),
3✔
152
                            stageList(middleware, Stage.SENDING_RESPONSE),
3✔
153
                            stageList(middleware, Stage.TERMINATED));
3✔
154
            routeDataMap.put(fullMethod, data);
5✔
155
            routeMap.put(
11✔
156
                    fullMethod,
157
                    buildRouteValue(
1✔
158
                            fullMethod,
159
                            controllerFqn,
160
                            methodName,
161
                            clientStreaming,
162
                            serverStreaming,
163
                            middleware));
164
        }
1✔
165

166
        return new GrpcRouteAttributeResult(routeMap, routeDataMap);
6✔
167
    }
168

169
    /** Read the {@code service} value from the class-level {@code @Service}, or "" if absent. */
170
    private String readServiceName(TypeDeclaration<?> type) {
171
        for (AnnotationExpr annotation : type.getAnnotations()) {
11✔
172
            if (annotation.getNameAsString().equals("Service")
8✔
173
                    && annotation instanceof NormalAnnotationExpr normal) {
3✔
174
                for (MemberValuePair pair : normal.getPairs()) {
11!
175
                    if (pair.getNameAsString().equals("service")) {
5✔
176
                        return requireStringLiteral(
4✔
177
                                pair.getValue(), "@Service(service)", type.getNameAsString());
4✔
178
                    }
179
                }
1✔
180
            }
181
        }
1✔
182
        return "";
2✔
183
    }
184

185
    /**
186
     * Resolve an annotation value that must be a string literal.
187
     *
188
     * <p>Sindri parses source syntactically, so a non-literal (a constant reference, a
189
     * concatenation) cannot be evaluated here. Falling back to the expression's source text would
190
     * bake a route key like {@code /SERVICE_NAME/Method} into the cache — a route that silently
191
     * answers {@code UNIMPLEMENTED}, and only when the cache is enabled. Fail at generation time
192
     * instead, where the developer can see it.
193
     *
194
     * @param expr the annotation value
195
     * @param what the annotation member being read, for the error message
196
     * @param where the declaring type or method, for the error message
197
     * @return the literal value
198
     */
199
    private String requireStringLiteral(Expression expr, String what, String where) {
200
        if (!expr.isStringLiteralExpr()) {
3✔
201
            throw new NonLiteralAttributeValueException(
17✔
202
                    "%s must be a string literal to be cached, but %s uses '%s'. Inline the literal or disable the gRPC route cache."
203
                            .formatted(what, where, expr));
3✔
204
        }
205

206
        return expr.asStringLiteralExpr().asString();
4✔
207
    }
208

209
    private @org.jspecify.annotations.Nullable NormalAnnotationExpr findMethodAttribute(
210
            MethodDeclaration method) {
211
        return method.getAnnotations().stream()
5✔
212
                .filter(a -> a.getNameAsString().equals("Method"))
7✔
213
                .filter(a -> a instanceof NormalAnnotationExpr)
5✔
214
                .map(a -> (NormalAnnotationExpr) a)
4✔
215
                .findFirst()
2✔
216
                .orElse(null);
2✔
217
    }
218

219
    /**
220
     * Resolve every {@code @Middleware} on the method and bucket it by the stages it implements.
221
     *
222
     * <p>The annotation names only the class, so each is classified by walking its type hierarchy —
223
     * the generation-time equivalent of the runtime collector's {@code isAssignableFrom} cascade. A
224
     * class implementing several stage contracts is registered under every one of them.
225
     *
226
     * @param method the handler method
227
     * @param imports the controller's imports, for resolving the middleware class names
228
     * @param pkg the controller's package, for same-package middleware
229
     * @return every stage mapped to its (possibly empty) middleware list
230
     */
231
    private Map<Stage, List<String>> classifyMiddleware(
232
            MethodDeclaration method, Map<String, String> imports, String pkg) {
233
        Map<Stage, List<String>> byStage = new java.util.EnumMap<>(Stage.class);
5✔
234
        for (Stage stage : Stage.values()) {
16✔
235
            byStage.put(stage, new ArrayList<>());
7✔
236
        }
237

238
        for (String middlewareFqn : middlewareClasses(method, imports, pkg)) {
14✔
239
            Set<String> matched = classifier.classify(middlewareFqn, resolver, STAGE_CONTRACTS);
8✔
240
            for (Stage stage : Stage.values()) {
16✔
241
                if (matched.contains(stage.contractFqn)) {
5✔
242
                    stageList(byStage, stage).add(middlewareFqn);
6✔
243
                }
244
            }
245
        }
1✔
246

247
        return byStage;
2✔
248
    }
249

250
    /** The (always-present) middleware list for a stage. */
251
    private static List<String> stageList(Map<Stage, List<String>> byStage, Stage stage) {
252
        return java.util.Objects.requireNonNull(byStage.get(stage));
7✔
253
    }
254

255
    /**
256
     * Collect the fully-qualified middleware classes named by {@code @Middleware} on the method,
257
     * expanding the repeatable container when the source spells it out explicitly.
258
     */
259
    private List<String> middlewareClasses(
260
            MethodDeclaration method, Map<String, String> imports, String pkg) {
261
        List<String> classes = new ArrayList<>();
4✔
262

263
        for (AnnotationExpr annotation : method.getAnnotations()) {
11✔
264
            String annotationName = annotation.getNameAsString();
3✔
265
            if (annotationName.equals("Middleware")) {
4✔
266
                addMiddlewareClass(annotation, imports, pkg, classes);
7✔
267
            } else if (annotationName.equals("Middlewares")) {
4!
NEW
268
                annotation
×
NEW
269
                        .findAll(ArrayInitializerExpr.class)
×
NEW
270
                        .forEach(
×
271
                                array ->
NEW
272
                                        array.getValues()
×
NEW
273
                                                .forEach(
×
274
                                                        value -> {
NEW
275
                                                            if (value
×
276
                                                                    instanceof
NEW
277
                                                                    AnnotationExpr nested) {
×
NEW
278
                                                                addMiddlewareClass(
×
279
                                                                        nested, imports, pkg,
280
                                                                        classes);
281
                                                            }
NEW
282
                                                        }));
×
283
            }
284
        }
1✔
285

286
        return classes;
2✔
287
    }
288

289
    private void addMiddlewareClass(
290
            AnnotationExpr annotation, Map<String, String> imports, String pkg, List<String> into) {
291
        if (!(annotation instanceof NormalAnnotationExpr normal)) {
7!
NEW
292
            return;
×
293
        }
294

295
        for (MemberValuePair pair : normal.getPairs()) {
11✔
296
            if (!pair.getNameAsString().equals("name") || !pair.getValue().isClassExpr()) {
9!
NEW
297
                continue;
×
298
            }
299

300
            String written = pair.getValue().asClassExpr().getType().asString();
6✔
301
            String fqn =
302
                    written.contains(".")
4!
NEW
303
                            ? written
×
304
                            : imports.getOrDefault(
5✔
305
                                    written, pkg.isEmpty() ? written : pkg + "." + written);
6!
306
            if (!into.contains(fqn)) {
4!
307
                into.add(fqn);
4✔
308
            }
309
        }
1✔
310
    }
1✔
311

312
    /**
313
     * Build the {@code Supplier<RouteContract>} expression stored as a route's value, e.g. {@code
314
     * () -> new Route("/pkg.Greeter/SayHello", (c, r) -> new pkg.Greeter().sayHello(c, r))}. Fully
315
     * qualified names are used so the generated file needs no extra imports. Middleware is emitted
316
     * pre-classified, so the cached route needs no stage discovery at runtime.
317
     */
318
    private Expression buildRouteValue(
319
            String fullMethod,
320
            String controllerFqn,
321
            String methodName,
322
            boolean clientStreaming,
323
            boolean serverStreaming,
324
            Map<Stage, List<String>> middleware) {
325
        StringBuilder supplier =
4✔
326
                new StringBuilder(
327
                        "() -> new io.valkyrja.grpc.routing.data.Route(\""
328
                                + escapeJava(fullMethod)
6✔
329
                                + "\", (c, r) -> new "
330
                                + controllerFqn
331
                                + "()."
332
                                + methodName
333
                                + "(c, r))");
334
        if (clientStreaming) {
2✔
335
            supplier.append(".withClientStreaming(true)");
4✔
336
        }
337
        if (serverStreaming) {
2✔
338
            supplier.append(".withServerStreaming(true)");
4✔
339
        }
340
        for (Stage stage : Stage.values()) {
16✔
341
            List<String> classes = stageList(middleware, stage);
4✔
342
            if (classes.isEmpty()) {
3✔
343
                continue;
1✔
344
            }
345
            supplier.append(".")
5✔
346
                    .append(stage.addMethod)
2✔
347
                    .append("(java.util.List.of(")
3✔
348
                    .append(String.join(".class, ", classes))
3✔
349
                    .append(".class))");
2✔
350
        }
351

352
        return StaticJavaParser.parseExpression(supplier.toString());
4✔
353
    }
354

355
    private boolean extractBoolean(Expression expr) {
356
        return expr.isBooleanLiteralExpr() && expr.asBooleanLiteralExpr().getValue();
11✔
357
    }
358

359
    private String escapeJava(String value) {
360
        return value.replace("\\", "\\\\").replace("\"", "\\\"");
8✔
361
    }
362
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc