Skip to content

Commit 9357bc9

Browse files
authored
feat(api-detector-core): add ScanProgressReporter for visible long-scan progress (#56)
Adds ScanProgressReporter in a new console package (deliberately separate from the unrelated progress package, which is scenario/contract *history* across builds, not live console feedback for a single run). Emits periodic LIFECYCLE-level status lines - one plain line per update, never overwriting in place - throttled to at most every 50 items or every 2 seconds, whichever comes first, so a consumer watching a long @RestController scan or OpenAPI $ref resolution knows the task is still alive. Deliberately built only on the public org.gradle.api.logging.Logger API, not Gradle's internal rich-console progress indicator: these plugins are published to the Gradle Plugin Portal for consumers on Gradle versions this codebase doesn't control, and the internal API offers no compatibility guarantee across versions. Adds an additive collect(File, Consumer<File>) overload to OpenApiEndpointCollector so a caller can drive an indeterminate progress reporter during $ref resolution, which the underlying parser performs internally with no callback of its own - backed by a lightweight, best-effort textual $ref scan run ahead of the real parse, purely for progress purposes. The existing collect(File) is unchanged, delegating to the new overload with a no-op callback.
1 parent 14c5f16 commit 9357bc9

6 files changed

Lines changed: 732 additions & 1 deletion

File tree

api-detector-core/build.gradle

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,16 @@ dependencies {
6868
implementation libs.javaparser.core
6969
implementation libs.swagger.parser
7070

71+
// Only for org.gradle.api.logging.Logger, the sole Gradle type ScanProgressReporter's public
72+
// API accepts. compileOnly: the real implementation is always supplied by whichever Gradle
73+
// version a consuming plugin task actually runs under, so it must not be bundled/pinned here.
74+
compileOnly gradleApi()
75+
7176
testImplementation libs.junit.jupiter
7277
testImplementation libs.assertj.core
7378
testRuntimeOnly libs.junit.platform.launcher
79+
// Needed at test runtime too, to hand-write a Logger test double (RecordingLogger).
80+
testImplementation gradleApi()
7481
}
7582

7683
publishing {
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package com.arc_e_tect.gradle.detector.core.console;
2+
3+
import org.gradle.api.logging.Logger;
4+
5+
import java.util.concurrent.TimeUnit;
6+
import java.util.function.LongSupplier;
7+
8+
/**
9+
* Emits periodic, low-overhead {@code LIFECYCLE}-level status lines for a long-running scan loop
10+
* (controller scanning, OpenAPI {@code $ref} resolution, verification-evidence scanning, ...), so
11+
* a consumer watching the build knows the task is still alive and roughly how far along it is.
12+
*
13+
* <p>Deliberately built only on the public {@link Logger} API - one plain line per status update,
14+
* never overwriting a line in place - rather than Gradle's internal, unsupported rich-console
15+
* single-line progress indicator. These plugins are published to the Gradle Plugin Portal for
16+
* consumers on Gradle versions this codebase doesn't control; the internal progress API has
17+
* changed shape across versions and offers no compatibility guarantee. This is a deliberate
18+
* trade-off in exchange for forward/backward compatibility, not an oversight.</p>
19+
*
20+
* <p>A status line is emitted every {@code everyNItems} items <strong>or</strong> every
21+
* {@code everySeconds} seconds since the last emission, whichever comes first - never on every
22+
* single item, and never silent for more than {@code everySeconds} regardless of how many items
23+
* are processed in between. {@link #complete()} always emits a final summary line, even if the
24+
* most recent {@link #step()} landed inside the throttle window - the final line is never
25+
* suppressed by throttling.</p>
26+
*/
27+
public final class ScanProgressReporter {
28+
29+
/** Default number of items between emitted status lines, absent an explicit override. */
30+
public static final int DEFAULT_EVERY_N_ITEMS = 50;
31+
32+
/** Default number of seconds between emitted status lines, absent an explicit override. */
33+
public static final long DEFAULT_EVERY_SECONDS = 2;
34+
35+
private static final int INDETERMINATE_TOTAL = -1;
36+
37+
private final Logger logger;
38+
private final String phaseLabel;
39+
private final int total;
40+
private final int everyNItems;
41+
private final long everyNanos;
42+
private final LongSupplier nanoTimeSource;
43+
44+
private int count;
45+
private int lastEmittedCount;
46+
private long lastEmittedNanos;
47+
48+
/**
49+
* Creates a reporter with the default throttle (every {@value #DEFAULT_EVERY_N_ITEMS} items or
50+
* every {@value #DEFAULT_EVERY_SECONDS} seconds) and the real wall-clock time source. Prefer
51+
* {@link #determinate(Logger, String, int)} or {@link #indeterminate(Logger, String)}.
52+
*
53+
* @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level
54+
* @param phaseLabel short label identifying the scan phase, e.g. {@code "Scanning @RestController classes"}
55+
* @param total the total number of items expected, or a negative number for an
56+
* indeterminate-total scan (the total isn't known ahead of time)
57+
*/
58+
public ScanProgressReporter(Logger logger, String phaseLabel, int total) {
59+
this(logger, phaseLabel, total, DEFAULT_EVERY_N_ITEMS, DEFAULT_EVERY_SECONDS, System::nanoTime);
60+
}
61+
62+
/**
63+
* Creates a reporter with an explicit throttle and the real wall-clock time source.
64+
*
65+
* @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level
66+
* @param phaseLabel short label identifying the scan phase
67+
* @param total the total number of items expected, or a negative number for an
68+
* indeterminate-total scan
69+
* @param everyNItems emit a status line at least this often, counted in processed items
70+
* @param everySeconds emit a status line at least this often, counted in elapsed seconds since
71+
* the last emission
72+
*/
73+
public ScanProgressReporter(Logger logger, String phaseLabel, int total, int everyNItems, long everySeconds) {
74+
this(logger, phaseLabel, total, everyNItems, everySeconds, System::nanoTime);
75+
}
76+
77+
/**
78+
* Creates a reporter with an explicit throttle and time source, for use by tests that need to
79+
* control elapsed time without a real {@code Thread.sleep}.
80+
*
81+
* @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level
82+
* @param phaseLabel short label identifying the scan phase
83+
* @param total the total number of items expected, or a negative number for an
84+
* indeterminate-total scan
85+
* @param everyNItems emit a status line at least this often, counted in processed items
86+
* @param everySeconds emit a status line at least this often, counted in elapsed seconds
87+
* since the last emission
88+
* @param nanoTimeSource nanosecond tick source, normally {@code System::nanoTime}
89+
*/
90+
ScanProgressReporter(
91+
Logger logger, String phaseLabel, int total, int everyNItems, long everySeconds,
92+
LongSupplier nanoTimeSource) {
93+
this.logger = logger;
94+
this.phaseLabel = phaseLabel;
95+
this.total = total;
96+
this.everyNItems = everyNItems;
97+
this.everyNanos = TimeUnit.SECONDS.toNanos(everySeconds);
98+
this.nanoTimeSource = nanoTimeSource;
99+
this.lastEmittedNanos = nanoTimeSource.getAsLong();
100+
}
101+
102+
/**
103+
* Creates a reporter for a scan whose total item count is known ahead of time. Emitted lines
104+
* include a running fraction and percentage, e.g. {@code "Scanning @RestController classes: 150/438 (34%)"}.
105+
*
106+
* @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level
107+
* @param phaseLabel short label identifying the scan phase
108+
* @param total the total number of items expected; {@code 0} is valid and not an error
109+
* @return a new determinate-mode reporter, using the default throttle
110+
*/
111+
public static ScanProgressReporter determinate(Logger logger, String phaseLabel, int total) {
112+
return new ScanProgressReporter(logger, phaseLabel, total);
113+
}
114+
115+
/**
116+
* Creates a reporter for a scan whose total item count isn't known ahead of time. Emitted
117+
* lines report only a running count, e.g. {@code "Resolving OpenAPI documents: 27 processed so far"}.
118+
*
119+
* @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level
120+
* @param phaseLabel short label identifying the scan phase
121+
* @return a new indeterminate-mode reporter, using the default throttle
122+
*/
123+
public static ScanProgressReporter indeterminate(Logger logger, String phaseLabel) {
124+
return new ScanProgressReporter(logger, phaseLabel, INDETERMINATE_TOTAL);
125+
}
126+
127+
/**
128+
* Records one item processed, emitting a status line if the throttle window has elapsed.
129+
* Equivalent to {@link #step(String)} with no detail.
130+
*/
131+
public void step() {
132+
step(null);
133+
}
134+
135+
/**
136+
* Records one item processed, emitting a status line - with {@code detail} appended, when
137+
* given - if the throttle window has elapsed.
138+
*
139+
* @param detail short description of the current item, appended to the line only when this
140+
* call itself results in an emission; or {@code null}/blank for no detail
141+
*/
142+
public void step(String detail) {
143+
count++;
144+
long now = nanoTimeSource.getAsLong();
145+
boolean dueByCount = (count - lastEmittedCount) >= everyNItems;
146+
boolean dueByTime = (now - lastEmittedNanos) >= everyNanos;
147+
if (dueByCount || dueByTime) {
148+
logger.lifecycle(progressLine(detail));
149+
lastEmittedCount = count;
150+
lastEmittedNanos = now;
151+
}
152+
}
153+
154+
/**
155+
* Emits a final summary line unconditionally, regardless of throttling state - the last
156+
* {@link #step()} may have landed inside the throttle window, but this line is never
157+
* suppressed. Safe to call on a reporter that never had {@link #step()} called at all (the
158+
* {@code 0}-items-processed case).
159+
*/
160+
public void complete() {
161+
logger.lifecycle(phaseLabel + ": done, " + count + " item(s)");
162+
}
163+
164+
private String progressLine(String detail) {
165+
String base = total >= 0
166+
? phaseLabel + ": " + count + "/" + total + " (" + percentage() + "%)"
167+
: phaseLabel + ": " + count + " processed so far";
168+
return isBlank(detail) ? base : base + " - " + detail;
169+
}
170+
171+
private long percentage() {
172+
return total == 0 ? 100 : Math.round(100.0 * count / total);
173+
}
174+
175+
private boolean isBlank(String value) {
176+
return value == null || value.isBlank();
177+
}
178+
}

api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollector.java

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
import java.nio.file.Files;
1515
import java.nio.file.Path;
1616
import java.util.ArrayList;
17+
import java.util.HashSet;
1718
import java.util.List;
19+
import java.util.Set;
20+
import java.util.function.Consumer;
1821
import java.util.regex.Matcher;
1922
import java.util.regex.Pattern;
2023

@@ -30,19 +33,56 @@ public class OpenApiEndpointCollector {
3033
private static final Pattern OPENAPI_32_JSON_PATTERN =
3134
Pattern.compile("(\"openapi\"\\s*:\\s*\")3\\.2(?:\\.\\d+)?(\")");
3235

36+
/**
37+
* Matches a {@code $ref} entry's target in either YAML ({@code $ref: 'foo.yaml#/...'}) or
38+
* JSON ({@code "$ref": "foo.json#/..."}) syntax, capturing everything up to the closing quote
39+
* or a {@code #} fragment marker, whichever comes first. A same-document ref (starting
40+
* directly with {@code #}) captures an empty group.
41+
*/
42+
private static final Pattern REF_TARGET_PATTERN = Pattern.compile("\\$ref\\s*:\\s*[\"']([^\"'#]*)");
43+
3344
/** Creates a new {@code OpenApiEndpointCollector}. */
3445
public OpenApiEndpointCollector() {}
3546

3647
/**
3748
* Parses {@code rootDocument} and every document it links to (relative {@code $ref}s are
3849
* resolved automatically), and returns the verb + path template pair described by every
39-
* operation found.
50+
* operation found. Equivalent to {@link #collect(File, Consumer)} with a callback that does
51+
* nothing.
4052
*
4153
* @param rootDocument the root OpenAPI document (JSON or YAML)
4254
* @return possibly-empty list of described endpoints, never {@code null}
4355
* @throws IllegalStateException if the document cannot be parsed
4456
*/
4557
public List<DescribedEndpoint> collect(File rootDocument) {
58+
return collect(rootDocument, file -> { });
59+
}
60+
61+
/**
62+
* Parses {@code rootDocument} and every document it links to (relative {@code $ref}s are
63+
* resolved automatically), and returns the verb + path template pair described by every
64+
* operation found.
65+
*
66+
* <p>{@code onDocumentResolved} is invoked once for {@code rootDocument} itself and once for
67+
* every distinct document reachable from it via a relative {@code $ref}, so a caller can drive
68+
* a progress indicator during what would otherwise be a single opaque, potentially long-running
69+
* call - the underlying parser resolves {@code $ref}s internally and offers no such callback of
70+
* its own. The set of documents is discovered by a lightweight, best-effort textual scan for
71+
* {@code $ref} entries (the same "read the file as data" approach the plugins' own WireMock and
72+
* Spring Cloud Contract scanners use), run <em>before</em> the real parse; it does not replace
73+
* or influence actual {@code $ref} resolution, which is still performed by the parser exactly
74+
* as it is for {@link #collect(File)}. A document that can't be read for this discovery pass is
75+
* silently skipped - the real parse below still surfaces the failure normally.</p>
76+
*
77+
* @param rootDocument the root OpenAPI document (JSON or YAML)
78+
* @param onDocumentResolved invoked once per distinct document discovered, including the root;
79+
* never {@code null}
80+
* @return possibly-empty list of described endpoints, never {@code null}
81+
* @throws IllegalStateException if the document cannot be parsed
82+
*/
83+
public List<DescribedEndpoint> collect(File rootDocument, Consumer<File> onDocumentResolved) {
84+
discoverReferencedDocuments(rootDocument, new HashSet<>(), onDocumentResolved);
85+
4686
ParseOptions options = new ParseOptions();
4787
options.setResolve(true);
4888
options.setResolveFully(true);
@@ -77,6 +117,47 @@ private static List<String> operationTags(Operation operation) {
77117
return operation.getTags() == null ? List.of() : List.copyOf(operation.getTags());
78118
}
79119

120+
/**
121+
* Recursively discovers every document reachable from {@code document} via a relative
122+
* {@code $ref}, invoking {@code onDocumentResolved} once for each distinct document the first
123+
* time it's encountered. {@code visited} guards against revisiting a document already seen -
124+
* both to avoid infinite recursion on a {@code $ref} cycle and to guarantee each document is
125+
* reported at most once.
126+
*/
127+
private void discoverReferencedDocuments(File document, Set<File> visited, Consumer<File> onDocumentResolved) {
128+
if (!visited.add(canonicalOrAbsolute(document))) {
129+
return;
130+
}
131+
onDocumentResolved.accept(document);
132+
if (!document.isFile()) {
133+
return;
134+
}
135+
136+
String content;
137+
try {
138+
content = Files.readString(document.toPath());
139+
} catch (IOException e) {
140+
return;
141+
}
142+
143+
Matcher matcher = REF_TARGET_PATTERN.matcher(content);
144+
while (matcher.find()) {
145+
String refPath = matcher.group(1);
146+
if (refPath.isBlank() || refPath.contains("://")) {
147+
continue;
148+
}
149+
discoverReferencedDocuments(new File(document.getParentFile(), refPath), visited, onDocumentResolved);
150+
}
151+
}
152+
153+
private File canonicalOrAbsolute(File file) {
154+
try {
155+
return file.getCanonicalFile();
156+
} catch (IOException e) {
157+
return file.getAbsoluteFile();
158+
}
159+
}
160+
80161
private static SwaggerParseResult parse(File rootDocument, ParseOptions options) {
81162
return new OpenAPIV3Parser().readLocation(rootDocument.getAbsolutePath(), null, options);
82163
}

0 commit comments

Comments
 (0)