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

valkyrjaio / sindri-java / 30141271108

25 Jul 2026 02:54AM UTC coverage: 97.972%. First build
30141271108

Pull #38

github

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

491 of 507 branches covered (96.84%)

Branch coverage included in aggregate %.

317 of 341 new or added lines in 15 files covered. (92.96%)

1441 of 1465 relevant lines covered (98.36%)

4.31 hits per line

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

95.94
/src/main/java/io/sindri/generate/abstract_/GenerateDataFromAst.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.generate.abstract_;
11

12
import com.github.javaparser.ast.expr.Expression;
13
import com.github.javaparser.ast.expr.FieldAccessExpr;
14
import io.sindri.ast.CliRouteAttributeReader;
15
import io.sindri.ast.ComponentProviderReader;
16
import io.sindri.ast.ConfigReader;
17
import io.sindri.ast.GrpcRouteAttributeReader;
18
import io.sindri.ast.HttpRouteAttributeReader;
19
import io.sindri.ast.ListenerProviderReader;
20
import io.sindri.ast.MiddlewareClassifier;
21
import io.sindri.ast.RouteProviderReader;
22
import io.sindri.ast.ServiceProviderReader;
23
import io.sindri.ast.data.HttpRouteData;
24
import io.sindri.ast.data.result.CliRouteAttributeResult;
25
import io.sindri.ast.data.result.ComponentProviderResult;
26
import io.sindri.ast.data.result.ConfigResult;
27
import io.sindri.ast.data.result.GrpcRouteAttributeResult;
28
import io.sindri.ast.data.result.HttpRouteAttributeResult;
29
import io.sindri.ast.data.result.RouteProviderResult;
30
import io.sindri.generator.cli.contract.CliDataFileGeneratorContract;
31
import io.sindri.generator.container.contract.ContainerDataFileGeneratorContract;
32
import io.sindri.generator.enum_.GenerateStatus;
33
import io.sindri.generator.event.contract.EventDataFileGeneratorContract;
34
import io.sindri.generator.grpc.contract.GrpcDataFileGeneratorContract;
35
import io.sindri.generator.http.contract.HttpDataFileGeneratorContract;
36
import io.sindri.generator.throwable.exception.DataFileWriteException;
37
import java.util.LinkedHashMap;
38
import java.util.Map;
39

40
public abstract class GenerateDataFromAst {
2✔
41

42
    private final ConfigReader configReader = new ConfigReader();
5✔
43
    private final ComponentProviderReader componentProviderReader = new ComponentProviderReader();
5✔
44
    private final ServiceProviderReader serviceProviderReader = new ServiceProviderReader();
5✔
45
    private final RouteProviderReader routeProviderReader = new RouteProviderReader();
5✔
46
    private final ListenerProviderReader listenerProviderReader = new ListenerProviderReader();
5✔
47
    private final CliRouteAttributeReader cliRouteAttributeReader = new CliRouteAttributeReader();
5✔
48

49
    /**
50
     * Resolve a class to its parsed source for middleware classification: app classes from the
51
     * source tree, framework classes from the sources jar on the classpath. An unresolvable class
52
     * simply stops that branch of the hierarchy walk.
53
     *
54
     * @param config the generation config
55
     * @return the resolver
56
     */
57
    private MiddlewareClassifier.SourceResolver middlewareSourceResolver(ConfigResult config) {
58
        return fqn -> {
4✔
NEW
59
            String path = fqnToFilePath(fqn, config.namespace(), config.dir());
×
NEW
60
            if (path.isEmpty()) {
×
NEW
61
                return java.util.Optional.empty();
×
62
            }
63
            try {
NEW
64
                return java.util.Optional.of(
×
NEW
65
                        com.github.javaparser.StaticJavaParser.parse(new java.io.File(path)));
×
NEW
66
            } catch (java.io.FileNotFoundException e) {
×
NEW
67
                return java.util.Optional.empty();
×
68
            }
69
        };
70
    }
71

72
    /** FQN → staged temp source path (or "" when unresolvable) for classpath-resolved sources. */
73
    private final Map<String, String> classpathSourceCache = new java.util.HashMap<>();
6✔
74

75
    protected abstract ContainerDataFileGeneratorContract getContainerDataFileGenerator();
76

77
    protected abstract EventDataFileGeneratorContract getEventDataFileGenerator();
78

79
    protected abstract CliDataFileGeneratorContract getCliDataFileGenerator();
80

81
    protected abstract HttpDataFileGeneratorContract getHttpDataFileGenerator();
82

83
    protected abstract GrpcDataFileGeneratorContract getGrpcDataFileGenerator();
84

85
    public void run(String configFilePath) {
86
        ConfigResult config = configReader.readFile(configFilePath);
5✔
87

88
        ComponentProviderResult allProviderData = collectProviderData(config);
4✔
89

90
        Map<String, String[]> publishers =
2✔
91
                collectPublishers(allProviderData.serviceProviders(), config);
4✔
92

93
        Map<String, String> listeners =
2✔
94
                collectListeners(allProviderData.listenerProviders(), config);
4✔
95

96
        Map<String, String> cliRoutes =
2✔
97
                collectCliRoutes(allProviderData.cliRouteProviders(), config);
4✔
98

99
        Map<String, String> httpRoutes = new LinkedHashMap<>();
4✔
100
        Map<String, HttpRouteData> httpRouteData = new LinkedHashMap<>();
4✔
101
        collectHttpRoutes(allProviderData.httpRouteProviders(), config, httpRoutes, httpRouteData);
7✔
102

103
        Map<String, String> grpcRoutes =
2✔
104
                collectGrpcRoutes(allProviderData.grpcRouteProviders(), config);
4✔
105

106
        String dataClassName = "AppContainerData";
2✔
107
        String dataDir = config.dataPath();
3✔
108
        String dataNamespace = config.dataNamespace();
3✔
109

110
        requireWritten(
4✔
111
                dataClassName,
112
                getContainerDataFileGenerator()
5✔
113
                        .generateFile(dataDir, dataClassName, dataNamespace, publishers));
1✔
114
        requireWritten(
4✔
115
                "AppEventData",
116
                getEventDataFileGenerator()
5✔
117
                        .generateFile(dataDir, "AppEventData", dataNamespace, listeners));
1✔
118
        requireWritten(
4✔
119
                "AppCliRoutingData",
120
                getCliDataFileGenerator()
5✔
121
                        .generateFile(dataDir, "AppCliRoutingData", dataNamespace, cliRoutes));
1✔
122
        requireWritten(
4✔
123
                "AppHttpRoutingData",
124
                getHttpDataFileGenerator()
6✔
125
                        .generateFile(
1✔
126
                                dataDir,
127
                                "AppHttpRoutingData",
128
                                dataNamespace,
129
                                httpRoutes,
130
                                httpRouteData));
131
        requireWritten(
4✔
132
                "AppGrpcRoutingData",
133
                getGrpcDataFileGenerator()
5✔
134
                        .generateFile(dataDir, "AppGrpcRoutingData", dataNamespace, grpcRoutes));
1✔
135
    }
1✔
136

137
    /**
138
     * Fail loudly when a data file could not be written.
139
     *
140
     * <p>A discarded {@link GenerateStatus#FAILURE} leaves the previous generation's data class on
141
     * disk (or none at all) while the build reports success — the application then boots against a
142
     * stale route map. Surface it instead.
143
     *
144
     * @param dataClassName the data class being written, for the error message
145
     * @param status the generator's result
146
     */
147
    private void requireWritten(String dataClassName, GenerateStatus status) {
148
        if (status == GenerateStatus.FAILURE) {
3!
NEW
149
            throw new DataFileWriteException("Failed to write " + dataClassName + ".");
×
150
        }
151
    }
1✔
152

153
    private ComponentProviderResult collectProviderData(ConfigResult config) {
154
        ComponentProviderResult combined = new ComponentProviderResult();
4✔
155

156
        // Walk the full component-provider graph breadth-first. Each provider can nest further
157
        // component providers (e.g. an app provider pulling in framework providers, which pull in
158
        // their own), so recurse to any depth, guarding against cycles with a visited set.
159
        java.util.Deque<String> queue = new java.util.ArrayDeque<>(config.providers());
6✔
160
        java.util.Set<String> visited = new java.util.HashSet<>();
4✔
161

162
        while (!queue.isEmpty()) {
3✔
163
            String providerFqn = queue.poll();
4✔
164
            if (!visited.add(providerFqn)) {
4✔
165
                continue;
1✔
166
            }
167

168
            String filePath = fqnToFilePath(providerFqn, config.namespace(), config.dir());
8✔
169
            if (filePath.isEmpty()) {
3✔
170
                continue;
1✔
171
            }
172

173
            ComponentProviderResult data = componentProviderReader.readFile(filePath);
5✔
174
            combined = combined.merge(data);
4✔
175
            queue.addAll(data.componentProviders());
5✔
176
        }
1✔
177

178
        return combined;
2✔
179
    }
180

181
    private Map<String, String[]> collectPublishers(
182
            java.util.List<String> serviceProviders, ConfigResult config) {
183
        Map<String, String[]> publishers = new LinkedHashMap<>();
4✔
184
        for (String providerFqn : serviceProviders) {
10✔
185
            String filePath = fqnToFilePath(providerFqn, config.namespace(), config.dir());
8✔
186
            if (filePath.isEmpty()) {
3✔
187
                continue;
1✔
188
            }
189
            publishers.putAll(serviceProviderReader.readFile(filePath).publishers());
7✔
190
        }
1✔
191
        return publishers;
2✔
192
    }
193

194
    private Map<String, String> collectListeners(
195
            java.util.List<String> listenerProviders, ConfigResult config) {
196
        Map<String, String> listeners = new LinkedHashMap<>();
4✔
197
        for (String providerFqn : listenerProviders) {
10✔
198
            String filePath = fqnToFilePath(providerFqn, config.namespace(), config.dir());
8✔
199
            if (filePath.isEmpty()) {
3✔
200
                continue;
1✔
201
            }
202
            listenerProviderReader
3✔
203
                    .readFile(filePath)
1✔
204
                    .listeners()
3✔
205
                    .forEach(expr -> listeners.put(expr.toString(), expr.toString()));
9✔
206
        }
1✔
207
        return listeners;
2✔
208
    }
209

210
    private Map<String, String> collectCliRoutes(
211
            java.util.List<String> cliRouteProviders, ConfigResult config) {
212
        Map<String, String> routes = new LinkedHashMap<>();
4✔
213
        for (String providerFqn : cliRouteProviders) {
10✔
214
            String filePath = fqnToFilePath(providerFqn, config.namespace(), config.dir());
8✔
215
            if (filePath.isEmpty()) {
3✔
216
                continue;
1✔
217
            }
218
            RouteProviderResult routeProvider = routeProviderReader.readFile(filePath);
5✔
219
            for (String controllerFqn : routeProvider.controllerClasses()) {
11✔
220
                String controllerPath =
3✔
221
                        fqnToFilePath(controllerFqn, config.namespace(), config.dir());
5✔
222
                if (!controllerPath.isEmpty()) {
3✔
223
                    CliRouteAttributeResult result =
3✔
224
                            cliRouteAttributeReader.readFile(controllerPath);
2✔
225
                    result.routes().forEach((name, expr) -> routes.put(name, expr.toString()));
12✔
226
                }
227
            }
1✔
228

229
            // Manually-defined provider routes (getRoutes()) — the CLI Route's name is its first
230
            // constructor argument.
231
            for (Expression routeExpr : routeProvider.routes()) {
11✔
232
                String name = extractRouteArgString(routeExpr, 0);
5✔
233
                if (!name.isEmpty()) {
3✔
234
                    routes.put(name, "() -> " + routeExpr);
7✔
235
                }
236
            }
1✔
237
        }
1✔
238
        return routes;
2✔
239
    }
240

241
    private Map<String, String> collectGrpcRoutes(
242
            java.util.List<String> grpcRouteProviders, ConfigResult config) {
243
        Map<String, String> routes = new LinkedHashMap<>();
4✔
244
        for (String providerFqn : grpcRouteProviders) {
10✔
245
            String filePath = fqnToFilePath(providerFqn, config.namespace(), config.dir());
8✔
246
            if (filePath.isEmpty()) {
3✔
247
                continue;
1✔
248
            }
249
            // Built per config so the reader can resolve each @Middleware class's source and
250
            // classify it into its stages before the route is cached.
251
            GrpcRouteAttributeReader grpcRouteAttributeReader =
4✔
252
                    new GrpcRouteAttributeReader(middlewareSourceResolver(config));
3✔
253
            RouteProviderResult routeProvider = routeProviderReader.readFile(filePath);
5✔
254
            for (String controllerFqn : routeProvider.controllerClasses()) {
11✔
255
                String controllerPath =
3✔
256
                        fqnToFilePath(controllerFqn, config.namespace(), config.dir());
5✔
257
                if (!controllerPath.isEmpty()) {
3✔
258
                    GrpcRouteAttributeResult result =
2✔
259
                            grpcRouteAttributeReader.readFile(controllerPath);
2✔
260
                    result.routes().forEach((method, expr) -> routes.put(method, expr.toString()));
12✔
261
                }
262
            }
1✔
263

264
            // Manually-defined provider routes (getRoutes()) — the gRPC Route's fully-qualified
265
            // method is its first constructor argument.
266
            for (Expression routeExpr : routeProvider.routes()) {
11✔
267
                String method = extractRouteArgString(routeExpr, 0);
5✔
268
                if (!method.isEmpty()) {
3✔
269
                    routes.put(method, "() -> " + routeExpr);
7✔
270
                }
271
            }
1✔
272
        }
1✔
273
        return routes;
2✔
274
    }
275

276
    private void collectHttpRoutes(
277
            java.util.List<String> httpRouteProviders,
278
            ConfigResult config,
279
            Map<String, String> httpRoutes,
280
            Map<String, HttpRouteData> httpRouteData) {
281
        // Built per config so the reader can resolve each @Middleware class's source and classify
282
        // it
283
        // into its stages before the route is cached.
284
        HttpRouteAttributeReader httpRouteAttributeReader =
4✔
285
                new HttpRouteAttributeReader(middlewareSourceResolver(config));
3✔
286
        for (String providerFqn : httpRouteProviders) {
10✔
287
            String filePath = fqnToFilePath(providerFqn, config.namespace(), config.dir());
8✔
288
            if (filePath.isEmpty()) {
3✔
289
                continue;
1✔
290
            }
291
            RouteProviderResult routeProvider = routeProviderReader.readFile(filePath);
5✔
292
            for (String controllerFqn : routeProvider.controllerClasses()) {
11✔
293
                String controllerPath =
3✔
294
                        fqnToFilePath(controllerFqn, config.namespace(), config.dir());
5✔
295
                if (!controllerPath.isEmpty()) {
3✔
296
                    HttpRouteAttributeResult result =
2✔
297
                            httpRouteAttributeReader.readFile(controllerPath);
2✔
298
                    result.routes().forEach((name, expr) -> httpRoutes.put(name, expr.toString()));
12✔
299
                    httpRouteData.putAll(result.routeData());
4✔
300
                }
301
            }
1✔
302

303
            // Manually-defined provider routes (getRoutes()) are inlined too, so the cached data
304
            // holds every route and the runtime never has to iterate providers.
305
            for (Expression routeExpr : routeProvider.routes()) {
11✔
306
                String name = extractRouteArgString(routeExpr, 1);
5✔
307
                if (name.isEmpty()) {
3✔
308
                    continue;
1✔
309
                }
310
                httpRoutes.put(name, "() -> " + routeExpr);
7✔
311
                httpRouteData.put(name, buildHttpRouteDataFromExpr(routeExpr, name));
8✔
312
            }
1✔
313
        }
1✔
314
    }
1✔
315

316
    private String extractRouteArgString(Expression routeExpr, int index) {
317
        // Callers only pass object-creation route expressions (RouteProviderReader filters them).
318
        var arguments = routeExpr.asObjectCreationExpr().getArguments();
4✔
319
        if (index < arguments.size() && arguments.get(index).isStringLiteralExpr()) {
10✔
320
            return arguments.get(index).asStringLiteralExpr().getValue();
7✔
321
        }
322
        return "";
2✔
323
    }
324

325
    private HttpRouteData buildHttpRouteDataFromExpr(Expression routeExpr, String name) {
326
        String path = extractRouteArgString(routeExpr, 0);
5✔
327
        boolean isDynamic = path.contains("{");
4✔
328
        String regex = isDynamic ? extractRouteArgString(routeExpr, 2) : "";
9✔
329

330
        return new HttpRouteData(
8✔
331
                path,
332
                name,
333
                null,
334
                extractRequestMethodsFromExpr(routeExpr),
1✔
335
                java.util.List.of(),
1✔
336
                java.util.List.of(),
1✔
337
                java.util.List.of(),
1✔
338
                java.util.List.of(),
1✔
339
                java.util.List.of(),
4✔
340
                null,
341
                null,
342
                isDynamic,
343
                java.util.List.of(),
3✔
344
                regex);
345
    }
346

347
    /** Pull RequestMethod constants out of a route expression, defaulting to HEAD + GET. */
348
    private java.util.List<String> extractRequestMethodsFromExpr(Expression routeExpr) {
349
        java.util.List<String> methods = new java.util.ArrayList<>();
4✔
350
        for (FieldAccessExpr fieldAccess : routeExpr.findAll(FieldAccessExpr.class)) {
12✔
351
            if (fieldAccess.getScope().toString().endsWith("RequestMethod")) {
6✔
352
                methods.add(fieldAccess.getNameAsString());
5✔
353
            }
354
        }
1✔
355
        return methods.isEmpty() ? java.util.List.of("HEAD", "GET") : methods;
9✔
356
    }
357

358
    private String fqnToFilePath(String fqn, String namespace, String srcDir) {
359
        String namespacePkg = namespace.toLowerCase(java.util.Locale.ROOT);
4✔
360
        if (fqn.startsWith(namespacePkg + ".")) {
5✔
361
            String relative = fqn.substring(namespacePkg.length() + 1).replace('.', '/');
10✔
362
            return srcDir + "/" + relative + ".java";
4✔
363
        }
364

365
        // Framework/vendor classes live outside the app source tree. Resolve their .java from the
366
        // sources jar on the classpath (the portable equivalent of PHP's
367
        // ReflectionClass::getFileName()) so their publishers()/providers can be scanned too.
368
        return resolveSourceFromClasspath(fqn);
4✔
369
    }
370

371
    /**
372
     * Resolve a class's {@code .java} source from a sources jar on the classpath and stage it as a
373
     * temp file (the AST readers accept only file paths). Returns {@code ""} when not found.
374
     */
375
    protected String resolveSourceFromClasspath(String fqn) {
376
        String cached = classpathSourceCache.get(fqn);
6✔
377
        if (cached != null) {
2✔
378
            return cached;
2✔
379
        }
380

381
        String resource = fqn.replace('.', '/') + ".java";
6✔
382
        java.io.InputStream in = getClass().getClassLoader().getResourceAsStream(resource);
6✔
383
        if (in == null) {
2✔
384
            classpathSourceCache.put(fqn, "");
6✔
385
            return "";
2✔
386
        }
387

388
        // A plain try/catch (rather than try-with-resources) keeps both outcomes fully reachable;
389
        // stageSource owns closing the stream.
390
        try {
391
            String path = stageSource(in);
4✔
392
            classpathSourceCache.put(fqn, path);
6✔
393

394
            return path;
2✔
395
        } catch (java.io.IOException e) {
1✔
396
            classpathSourceCache.put(fqn, "");
6✔
397
            return "";
2✔
398
        }
399
    }
400

401
    /** Copy a resolved source stream to a temp file, closing the stream, and return its path. */
402
    protected String stageSource(java.io.InputStream in) throws java.io.IOException {
403
        java.nio.file.Path temp = java.nio.file.Files.createTempFile("sindri-src-", ".java");
6✔
404
        temp.toFile().deleteOnExit();
3✔
405
        java.nio.file.Files.copy(in, temp, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
10✔
406
        in.close();
2✔
407

408
        return temp.toString();
3✔
409
    }
410
}
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