Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api-detector-core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,16 @@ dependencies {
implementation libs.javaparser.core
implementation libs.swagger.parser

// Only for org.gradle.api.logging.Logger, the sole Gradle type ScanProgressReporter's public
// API accepts. compileOnly: the real implementation is always supplied by whichever Gradle
// version a consuming plugin task actually runs under, so it must not be bundled/pinned here.
compileOnly gradleApi()

testImplementation libs.junit.jupiter
testImplementation libs.assertj.core
testRuntimeOnly libs.junit.platform.launcher
// Needed at test runtime too, to hand-write a Logger test double (RecordingLogger).
testImplementation gradleApi()
}

publishing {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package com.arc_e_tect.gradle.detector.core.console;

import org.gradle.api.logging.Logger;

import java.util.concurrent.TimeUnit;
import java.util.function.LongSupplier;

/**
* Emits periodic, low-overhead {@code LIFECYCLE}-level status lines for a long-running scan loop
* (controller scanning, OpenAPI {@code $ref} resolution, verification-evidence scanning, ...), so
* a consumer watching the build knows the task is still alive and roughly how far along it is.
*
* <p>Deliberately built only on the public {@link Logger} API - one plain line per status update,
* never overwriting a line in place - rather than Gradle's internal, unsupported rich-console
* single-line progress indicator. These plugins are published to the Gradle Plugin Portal for
* consumers on Gradle versions this codebase doesn't control; the internal progress API has
* changed shape across versions and offers no compatibility guarantee. This is a deliberate
* trade-off in exchange for forward/backward compatibility, not an oversight.</p>
*
* <p>A status line is emitted every {@code everyNItems} items <strong>or</strong> every
* {@code everySeconds} seconds since the last emission, whichever comes first - never on every
* single item, and never silent for more than {@code everySeconds} regardless of how many items
* are processed in between. {@link #complete()} always emits a final summary line, even if the
* most recent {@link #step()} landed inside the throttle window - the final line is never
* suppressed by throttling.</p>
*/
public final class ScanProgressReporter {

/** Default number of items between emitted status lines, absent an explicit override. */
public static final int DEFAULT_EVERY_N_ITEMS = 50;

/** Default number of seconds between emitted status lines, absent an explicit override. */
public static final long DEFAULT_EVERY_SECONDS = 2;

private static final int INDETERMINATE_TOTAL = -1;

private final Logger logger;
private final String phaseLabel;
private final int total;
private final int everyNItems;
private final long everyNanos;
private final LongSupplier nanoTimeSource;

private int count;
private int lastEmittedCount;
private long lastEmittedNanos;

/**
* Creates a reporter with the default throttle (every {@value #DEFAULT_EVERY_N_ITEMS} items or
* every {@value #DEFAULT_EVERY_SECONDS} seconds) and the real wall-clock time source. Prefer
* {@link #determinate(Logger, String, int)} or {@link #indeterminate(Logger, String)}.
*
* @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level
* @param phaseLabel short label identifying the scan phase, e.g. {@code "Scanning @RestController classes"}
* @param total the total number of items expected, or a negative number for an
* indeterminate-total scan (the total isn't known ahead of time)
*/
public ScanProgressReporter(Logger logger, String phaseLabel, int total) {
this(logger, phaseLabel, total, DEFAULT_EVERY_N_ITEMS, DEFAULT_EVERY_SECONDS, System::nanoTime);
}

/**
* Creates a reporter with an explicit throttle and the real wall-clock time source.
*
* @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level
* @param phaseLabel short label identifying the scan phase
* @param total the total number of items expected, or a negative number for an
* indeterminate-total scan
* @param everyNItems emit a status line at least this often, counted in processed items
* @param everySeconds emit a status line at least this often, counted in elapsed seconds since
* the last emission
*/
public ScanProgressReporter(Logger logger, String phaseLabel, int total, int everyNItems, long everySeconds) {
this(logger, phaseLabel, total, everyNItems, everySeconds, System::nanoTime);
}

/**
* Creates a reporter with an explicit throttle and time source, for use by tests that need to
* control elapsed time without a real {@code Thread.sleep}.
*
* @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level
* @param phaseLabel short label identifying the scan phase
* @param total the total number of items expected, or a negative number for an
* indeterminate-total scan
* @param everyNItems emit a status line at least this often, counted in processed items
* @param everySeconds emit a status line at least this often, counted in elapsed seconds
* since the last emission
* @param nanoTimeSource nanosecond tick source, normally {@code System::nanoTime}
*/
ScanProgressReporter(
Logger logger, String phaseLabel, int total, int everyNItems, long everySeconds,
LongSupplier nanoTimeSource) {
this.logger = logger;
this.phaseLabel = phaseLabel;
this.total = total;
this.everyNItems = everyNItems;
this.everyNanos = TimeUnit.SECONDS.toNanos(everySeconds);
this.nanoTimeSource = nanoTimeSource;
this.lastEmittedNanos = nanoTimeSource.getAsLong();
}

/**
* Creates a reporter for a scan whose total item count is known ahead of time. Emitted lines
* include a running fraction and percentage, e.g. {@code "Scanning @RestController classes: 150/438 (34%)"}.
*
* @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level
* @param phaseLabel short label identifying the scan phase
* @param total the total number of items expected; {@code 0} is valid and not an error
* @return a new determinate-mode reporter, using the default throttle
*/
public static ScanProgressReporter determinate(Logger logger, String phaseLabel, int total) {
return new ScanProgressReporter(logger, phaseLabel, total);
}

/**
* Creates a reporter for a scan whose total item count isn't known ahead of time. Emitted
* lines report only a running count, e.g. {@code "Resolving OpenAPI documents: 27 processed so far"}.
*
* @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level
* @param phaseLabel short label identifying the scan phase
* @return a new indeterminate-mode reporter, using the default throttle
*/
public static ScanProgressReporter indeterminate(Logger logger, String phaseLabel) {
return new ScanProgressReporter(logger, phaseLabel, INDETERMINATE_TOTAL);
}

/**
* Records one item processed, emitting a status line if the throttle window has elapsed.
* Equivalent to {@link #step(String)} with no detail.
*/
public void step() {
step(null);
}

/**
* Records one item processed, emitting a status line - with {@code detail} appended, when
* given - if the throttle window has elapsed.
*
* @param detail short description of the current item, appended to the line only when this
* call itself results in an emission; or {@code null}/blank for no detail
*/
public void step(String detail) {
count++;
long now = nanoTimeSource.getAsLong();
boolean dueByCount = (count - lastEmittedCount) >= everyNItems;
boolean dueByTime = (now - lastEmittedNanos) >= everyNanos;
if (dueByCount || dueByTime) {
logger.lifecycle(progressLine(detail));
lastEmittedCount = count;
lastEmittedNanos = now;
}
}

/**
* Emits a final summary line unconditionally, regardless of throttling state - the last
* {@link #step()} may have landed inside the throttle window, but this line is never
* suppressed. Safe to call on a reporter that never had {@link #step()} called at all (the
* {@code 0}-items-processed case).
*/
public void complete() {
logger.lifecycle(phaseLabel + ": done, " + count + " item(s)");
}

private String progressLine(String detail) {
String base = total >= 0
? phaseLabel + ": " + count + "/" + total + " (" + percentage() + "%)"
: phaseLabel + ": " + count + " processed so far";
return isBlank(detail) ? base : base + " - " + detail;
}

private long percentage() {
return total == 0 ? 100 : Math.round(100.0 * count / total);
}

private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

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

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

/** Creates a new {@code OpenApiEndpointCollector}. */
public OpenApiEndpointCollector() {}

/**
* Parses {@code rootDocument} and every document it links to (relative {@code $ref}s are
* resolved automatically), and returns the verb + path template pair described by every
* operation found.
* operation found. Equivalent to {@link #collect(File, Consumer)} with a callback that does
* nothing.
*
* @param rootDocument the root OpenAPI document (JSON or YAML)
* @return possibly-empty list of described endpoints, never {@code null}
* @throws IllegalStateException if the document cannot be parsed
*/
public List<DescribedEndpoint> collect(File rootDocument) {
return collect(rootDocument, file -> { });
}

/**
* Parses {@code rootDocument} and every document it links to (relative {@code $ref}s are
* resolved automatically), and returns the verb + path template pair described by every
* operation found.
*
* <p>{@code onDocumentResolved} is invoked once for {@code rootDocument} itself and once for
* every distinct document reachable from it via a relative {@code $ref}, so a caller can drive
* a progress indicator during what would otherwise be a single opaque, potentially long-running
* call - the underlying parser resolves {@code $ref}s internally and offers no such callback of
* its own. The set of documents is discovered by a lightweight, best-effort textual scan for
* {@code $ref} entries (the same "read the file as data" approach the plugins' own WireMock and
* Spring Cloud Contract scanners use), run <em>before</em> the real parse; it does not replace
* or influence actual {@code $ref} resolution, which is still performed by the parser exactly
* as it is for {@link #collect(File)}. A document that can't be read for this discovery pass is
* silently skipped - the real parse below still surfaces the failure normally.</p>
*
* @param rootDocument the root OpenAPI document (JSON or YAML)
* @param onDocumentResolved invoked once per distinct document discovered, including the root;
* never {@code null}
* @return possibly-empty list of described endpoints, never {@code null}
* @throws IllegalStateException if the document cannot be parsed
*/
public List<DescribedEndpoint> collect(File rootDocument, Consumer<File> onDocumentResolved) {
discoverReferencedDocuments(rootDocument, new HashSet<>(), onDocumentResolved);

ParseOptions options = new ParseOptions();
options.setResolve(true);
options.setResolveFully(true);
Expand Down Expand Up @@ -77,6 +117,47 @@ private static List<String> operationTags(Operation operation) {
return operation.getTags() == null ? List.of() : List.copyOf(operation.getTags());
}

/**
* Recursively discovers every document reachable from {@code document} via a relative
* {@code $ref}, invoking {@code onDocumentResolved} once for each distinct document the first
* time it's encountered. {@code visited} guards against revisiting a document already seen -
* both to avoid infinite recursion on a {@code $ref} cycle and to guarantee each document is
* reported at most once.
*/
private void discoverReferencedDocuments(File document, Set<File> visited, Consumer<File> onDocumentResolved) {
if (!visited.add(canonicalOrAbsolute(document))) {
return;
}
onDocumentResolved.accept(document);
if (!document.isFile()) {
return;
}

String content;
try {
content = Files.readString(document.toPath());
} catch (IOException e) {
return;
}

Matcher matcher = REF_TARGET_PATTERN.matcher(content);
while (matcher.find()) {
String refPath = matcher.group(1);
if (refPath.isBlank() || refPath.contains("://")) {
continue;
}
discoverReferencedDocuments(new File(document.getParentFile(), refPath), visited, onDocumentResolved);
}
}

private File canonicalOrAbsolute(File file) {
try {
return file.getCanonicalFile();
} catch (IOException e) {
return file.getAbsoluteFile();
}
}

private static SwaggerParseResult parse(File rootDocument, ParseOptions options) {
return new OpenAPIV3Parser().readLocation(rootDocument.getAbsolutePath(), null, options);
}
Expand Down
Loading
Loading