Skip to content

Commit 7bf2832

Browse files
authored
feat: 1194 json stdout (#2132) Co-authored-by @drewda
1 parent bba846c commit 7bf2832

File tree

15 files changed

+272
-50
lines changed

15 files changed

+272
-50
lines changed

app/gui/src/main/java/org/mobilitydata/gtfsvalidator/app/gui/GtfsValidatorApp.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import java.util.ArrayList;
3636
import java.util.Arrays;
3737
import java.util.List;
38+
import java.util.Optional;
3839
import java.util.ResourceBundle;
3940
import javax.swing.BorderFactory;
4041
import javax.swing.Box;
@@ -371,6 +372,7 @@ private void runValidation() {
371372
private ValidationRunnerConfig buildConfig() throws URISyntaxException {
372373
ValidationRunnerConfig.Builder config = ValidationRunnerConfig.builder();
373374
config.setPrettyJson(true);
375+
config.setStdoutOutput(false);
374376

375377
String gtfsInput = gtfsInputField.getText();
376378
if (gtfsInput.isBlank()) {
@@ -386,7 +388,7 @@ private ValidationRunnerConfig buildConfig() throws URISyntaxException {
386388
if (outputDirectory.isBlank()) {
387389
throw new IllegalStateException("outputDirectoryField is blank");
388390
}
389-
config.setOutputDirectory(Path.of(outputDirectory));
391+
config.setOutputDirectory(Optional.of(Path.of(outputDirectory)));
390392

391393
Object numThreads = numThreadsSpinner.getValue();
392394
if (numThreads instanceof Integer) {

app/gui/src/main/java/org/mobilitydata/gtfsvalidator/app/gui/ValidationDisplay.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,18 @@ void handleResult(ValidationRunnerConfig config, ValidationRunner.Status status)
1818
handleError();
1919
}
2020

21-
Path reportPath = config.htmlReportPath();
21+
Path reportPath = config.htmlReportPath().orElse(null);
2222
if (status == ValidationRunner.Status.SYSTEM_ERRORS) {
23-
reportPath = config.systemErrorsReportPath();
23+
reportPath = config.systemErrorsReportPath().orElse(null);
24+
}
25+
26+
// Handle missing report path explicitly instead of letting null reach browse().
27+
if (reportPath == null) {
28+
logger.atSevere().log(
29+
"No report path available to display results. "
30+
+ "Ensure an output directory is configured when running the GUI.");
31+
handleError();
32+
return;
2433
}
2534

2635
try {

app/gui/src/test/java/org/mobilitydata/gtfsvalidator/app/gui/GtfsValidatorAppTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public void testValidationConfig() throws URISyntaxException {
6666

6767
ValidationRunnerConfig config = configCaptor.getValue();
6868
assertThat(config.gtfsSource()).isEqualTo(new URI("http://transit/gtfs.zip"));
69-
assertThat(config.outputDirectory()).isEqualTo(Path.of("/path/to/output"));
69+
assertThat(config.outputDirectory()).hasValue(Path.of("/path/to/output"));
7070
assertThat(config.numThreads()).isEqualTo(1);
7171
assertThat(config.countryCode().isUnknown()).isTrue();
7272
}
@@ -84,7 +84,7 @@ public void testValidationConfigWithAdvancedOptions() throws URISyntaxException
8484

8585
ValidationRunnerConfig config = configCaptor.getValue();
8686
assertThat(config.gtfsSource()).isEqualTo(new URI("http://transit/gtfs.zip"));
87-
assertThat(config.outputDirectory()).isEqualTo(Path.of("/path/to/output"));
87+
assertThat(config.outputDirectory()).hasValue(Path.of("/path/to/output"));
8888
assertThat(config.numThreads()).isEqualTo(5);
8989
assertThat(config.countryCode().getCountryCode()).isEqualTo("US");
9090
}

cli/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ shadowJar {
3838
// from minimization.
3939
exclude(project(':main'))
4040
exclude(dependency(libs.httpclient5.get().toString()))
41+
// Keep SLF4J implementation to avoid warnings
42+
// Note that SLF4J comes from some transitive dependencies
43+
exclude(dependency('org.slf4j:slf4j-jdk14'))
4144
}
4245
// Change the JAR name from 'main' to 'gtfs-validator'
4346
archiveBaseName = rootProject.name
@@ -55,6 +58,7 @@ dependencies {
5558
implementation libs.flogger
5659
implementation libs.flogger.system.backend
5760
implementation libs.guava
61+
implementation libs.slf4j.jul
5862

5963
testImplementation libs.junit
6064
testImplementation libs.truth

cli/src/main/java/org/mobilitydata/gtfsvalidator/cli/Arguments.java

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.nio.file.Path;
2424
import java.time.LocalDate;
2525
import java.time.format.DateTimeFormatter;
26+
import java.util.Optional;
2627
import org.mobilitydata.gtfsvalidator.input.CountryCode;
2728
import org.mobilitydata.gtfsvalidator.runner.ValidationRunnerConfig;
2829

@@ -38,8 +39,7 @@ public class Arguments {
3839

3940
@Parameter(
4041
names = {"-o", "--output_base"},
41-
description = "Base directory to store the outputs",
42-
required = true)
42+
description = "Base directory to store the outputs (required if not using --stdout)")
4343
private String outputBase;
4444

4545
@Parameter(
@@ -110,6 +110,11 @@ public class Arguments {
110110
description = "Skips check for new validator version")
111111
private boolean skipValidatorUpdate = false;
112112

113+
@Parameter(
114+
names = {"--stdout"},
115+
description = "Output JSON report to stdout instead of writing to files (conflicts with -o)")
116+
private boolean stdoutOutput = false;
117+
113118
ValidationRunnerConfig toConfig() throws URISyntaxException {
114119
ValidationRunnerConfig.Builder builder = ValidationRunnerConfig.builder();
115120
if (input != null) {
@@ -121,7 +126,10 @@ ValidationRunnerConfig toConfig() throws URISyntaxException {
121126
}
122127
}
123128
if (outputBase != null) {
124-
builder.setOutputDirectory(Path.of(outputBase));
129+
builder.setOutputDirectory(Optional.of(Path.of(outputBase)));
130+
} else if (stdoutOutput) {
131+
// When using stdout, no output directory is needed
132+
builder.setOutputDirectory(Optional.empty());
125133
}
126134
if (countryCode != null) {
127135
builder.setCountryCode(CountryCode.forStringOrUnknown(countryCode));
@@ -141,6 +149,7 @@ ValidationRunnerConfig toConfig() throws URISyntaxException {
141149
builder.setNumThreads(numThreads);
142150
builder.setPrettyJson(pretty);
143151
builder.setSkipValidatorUpdate(skipValidatorUpdate);
152+
builder.setStdoutOutput(stdoutOutput);
144153
return builder.build();
145154
}
146155

@@ -160,11 +169,20 @@ public boolean getExportNoticeSchema() {
160169
return exportNoticeSchema;
161170
}
162171

172+
public boolean getStdoutOutput() {
173+
return stdoutOutput;
174+
}
175+
163176
/**
164177
* @return true if CLI parameter combination is legal, otherwise return false
165178
*/
166179
public boolean validate() {
167180
if (getExportNoticeSchema() && abortAfterNoticeSchemaExport()) {
181+
if (outputBase == null) {
182+
logger.atSevere().log(
183+
"Must provide --output_base when using --export_notices_schema without --input or --url");
184+
return false;
185+
}
168186
return true;
169187
}
170188

@@ -185,6 +203,16 @@ public boolean validate() {
185203
return false;
186204
}
187205

206+
if (stdoutOutput && outputBase != null) {
207+
logger.atSevere().log("Cannot use --stdout with --output_base. Use one or the other.");
208+
return false;
209+
}
210+
211+
if (outputBase == null && !stdoutOutput) {
212+
logger.atSevere().log("Must provide either --output_base or --stdout");
213+
return false;
214+
}
215+
188216
return true;
189217
}
190218

cli/src/test/java/org/mobilitydata/gtfsvalidator/cli/ArgumentsTest.java

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@
1818

1919
import static com.google.common.truth.Truth.assertThat;
2020
import static com.google.common.truth.Truth8.assertThat;
21-
import static org.junit.Assert.assertThrows;
21+
import static org.junit.Assert.assertFalse;
22+
import static org.junit.Assert.assertTrue;
2223

2324
import com.beust.jcommander.JCommander;
24-
import com.beust.jcommander.ParameterException;
2525
import java.io.File;
2626
import java.net.URI;
2727
import java.net.URISyntaxException;
@@ -45,7 +45,7 @@ public void shortNameShouldInitializeArguments() throws URISyntaxException {
4545
new JCommander(underTest).parse(commandLineArgumentAsStringArray);
4646
ValidationRunnerConfig config = underTest.toConfig();
4747
assertThat(config.gtfsSource()).isEqualTo(toFileUri("/tmp/gtfs.zip"));
48-
assertThat((Object) config.outputDirectory()).isEqualTo(Path.of("/tmp/output"));
48+
assertThat(config.outputDirectory()).hasValue(Path.of("/tmp/output"));
4949
assertThat(config.countryCode()).isEqualTo(CountryCode.forStringOrUnknown("au"));
5050
assertThat(config.numThreads()).isEqualTo(4);
5151
assertThat(config.validationReportFileName()).matches("report.json");
@@ -72,7 +72,7 @@ public void shortNameShouldInitializeArguments_url() throws URISyntaxException {
7272
new JCommander(underTest).parse(commandLineArgumentAsStringArray);
7373
ValidationRunnerConfig config = underTest.toConfig();
7474
assertThat(config.gtfsSource()).isEqualTo(new URI("http://host/gtfs.zip"));
75-
assertThat((Object) config.outputDirectory()).isEqualTo(Path.of("/tmp/output"));
75+
assertThat(config.outputDirectory()).hasValue(Path.of("/tmp/output"));
7676
assertThat(config.countryCode()).isEqualTo(CountryCode.forStringOrUnknown("au"));
7777
assertThat(config.numThreads()).isEqualTo(4);
7878
assertThat(config.storageDirectory()).hasValue(Path.of("/tmp/storage"));
@@ -95,7 +95,7 @@ public void longNameShouldInitializeArguments() throws URISyntaxException {
9595
new JCommander(underTest).parse(commandLineArgumentAsStringArray);
9696
ValidationRunnerConfig config = underTest.toConfig();
9797
assertThat(config.gtfsSource()).isEqualTo(toFileUri("/tmp/gtfs.zip"));
98-
assertThat((Object) config.outputDirectory()).isEqualTo(Path.of("/tmp/output"));
98+
assertThat(config.outputDirectory()).hasValue(Path.of("/tmp/output"));
9999
assertThat(config.countryCode()).isEqualTo(CountryCode.forStringOrUnknown("ca"));
100100
assertThat(config.numThreads()).isEqualTo(4);
101101
assertThat(config.validationReportFileName()).matches("report.json");
@@ -131,7 +131,7 @@ public void longNameShouldInitializeArguments_url() throws URISyntaxException {
131131
new JCommander(underTest).parse(commandLineArgumentAsStringArray);
132132
ValidationRunnerConfig config = underTest.toConfig();
133133
assertThat(config.gtfsSource()).isEqualTo(new URI("http://host/gtfs.zip"));
134-
assertThat((Object) config.outputDirectory()).isEqualTo(Path.of("/tmp/output"));
134+
assertThat(config.outputDirectory()).hasValue(Path.of("/tmp/output"));
135135
assertThat(config.countryCode()).isEqualTo(CountryCode.forStringOrUnknown("ca"));
136136
assertThat(config.numThreads()).isEqualTo(4);
137137
assertThat(config.storageDirectory()).hasValue(Path.of("/tmp/storage"));
@@ -152,7 +152,7 @@ public void numThreadsShouldHaveDefaultValueIfNotProvided() throws URISyntaxExce
152152
new JCommander(underTest).parse(commandLineArgumentAsStringArray);
153153
ValidationRunnerConfig config = underTest.toConfig();
154154
assertThat(config.gtfsSource()).isEqualTo(toFileUri("/tmp/gtfs.zip"));
155-
assertThat((Object) config.outputDirectory()).isEqualTo(Path.of("/tmp/output"));
155+
assertThat(config.outputDirectory()).hasValue(Path.of("/tmp/output"));
156156
assertThat(config.countryCode()).isEqualTo(CountryCode.forStringOrUnknown("ca"));
157157
assertThat(config.numThreads()).isEqualTo(1);
158158
}
@@ -182,7 +182,7 @@ public void noUrlNoInput_long_isNotValid() {
182182

183183
@Test
184184
public void noArguments_isNotValid() {
185-
assertThrows(ParameterException.class, () -> validateArguments(new String[] {}));
185+
assertThat(validateArguments(new String[] {})).isFalse();
186186
}
187187

188188
@Test
@@ -259,4 +259,50 @@ public void exportNoticesSchema_schemaAndValidation() {
259259
assertThat(args.getExportNoticeSchema()).isTrue();
260260
assertThat(args.abortAfterNoticeSchemaExport()).isFalse();
261261
}
262+
263+
@Test
264+
public void testStdoutOutput() {
265+
String[] commandLineArgumentAsStringArray = {"--input", "test.zip", "--stdout"};
266+
267+
Arguments args = new Arguments();
268+
new JCommander(args).parse(commandLineArgumentAsStringArray);
269+
270+
assertTrue(args.validate());
271+
assertTrue(args.getStdoutOutput());
272+
}
273+
274+
@Test
275+
public void testStdoutOutputWithOutputBaseConflict() {
276+
String[] commandLineArgumentAsStringArray = {
277+
"--input", "test.zip",
278+
"--output_base", "/tmp/output",
279+
"--stdout"
280+
};
281+
282+
Arguments args = new Arguments();
283+
new JCommander(args).parse(commandLineArgumentAsStringArray);
284+
285+
assertFalse(args.validate());
286+
}
287+
288+
@Test
289+
public void testStdoutOutputWithoutInput() {
290+
String[] commandLineArgumentAsStringArray = {"--stdout"};
291+
292+
Arguments args = new Arguments();
293+
new JCommander(args).parse(commandLineArgumentAsStringArray);
294+
295+
assertFalse(args.validate());
296+
}
297+
298+
@Test
299+
public void exportNoticesSchema_schemaOnlyWithoutOutputBase_isNotValid() {
300+
String[] cliArguments = {"--export_notices_schema"};
301+
Arguments args = new Arguments();
302+
new JCommander(args).parse(cliArguments);
303+
304+
assertThat(args.validate()).isFalse();
305+
assertThat(args.getExportNoticeSchema()).isTrue();
306+
assertThat(args.abortAfterNoticeSchemaExport()).isTrue();
307+
}
262308
}

docs/USAGE.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
| `-e` | `--system_errors_report_name` | Optional | Name of the system errors report (including `.json` extension). |
2727
| `-n` | `--export_notices_schema` | Optional | Export notice schema as a json file. |
2828
| `-p` | `--pretty` | Optional | Pretty JSON validation report. If specified, the JSON validation report will be printed using JSON Pretty print. This does not impact data parsing. |
29+
| `--stdout` | `--stdout` | Optional | Output JSON report to stdout instead of writing to files. Use with `-i` or `-u` but not with `-o`. Enables piping to tools like `jq`. |
2930
| `-d` | `--date` | Optional | The date used to validate the feed for time-based rules, e.g feed_expiration_30_days, in ISO_LOCAL_DATE format like '2001-01-30'. By default, the current date is used. |
3031
| `-svu` | `--skip_validator_update` | Optional | Skip GTFS version validation update check. If specified, the GTFS version validation will be skipped. By default, the GTFS version validation will be performed. |
3132

@@ -61,6 +62,34 @@ java -jar gtfs-validator-v2.0.jar -u https://url/to/dataset.zip -o relative/outp
6162
1. Validate the GTFS data and output the results to the directory located at `relative/output/path`. Validation results are exported to JSON by default.
6263
Please note that since downloading will take time, we recommend validating repeatedly on a local file.
6364

65+
## via stdout output (for scripting and piping)
66+
67+
The `--stdout` option outputs JSON directly to stdout instead of writing files, making it ideal for scripting and piping to other tools.
68+
69+
### Basic stdout usage
70+
```
71+
java -jar gtfs-validator-v2.0.jar -i relative/path/to/dataset.zip --stdout
72+
```
73+
74+
### Pipe to jq for processing
75+
```
76+
java -jar gtfs-validator-v2.0.jar -i relative/path/to/dataset.zip --stdout | jq '.summary.validationTimeSeconds'
77+
```
78+
79+
### Pretty JSON output to stdout
80+
```
81+
java -jar gtfs-validator-v2.0.jar -i relative/path/to/dataset.zip --stdout --pretty
82+
```
83+
84+
### URL-based input with stdout
85+
```
86+
java -jar gtfs-validator-v2.0.jar -u https://url/to/dataset.zip --stdout
87+
```
88+
89+
⚠️ Note that `--stdout` cannot be used with `-o` or `--output_base`. Use one or the other.
90+
91+
⚠️ When using `--stdout`, all system errors and logging output are suppressed to ensure clean JSON output. Only severe-level log messages (hard crashes) will appear on stderr.
92+
6493
## via GitHub Actions - Run the validator on any gtfs archive available on a public url
6594

6695
1. [Fork this repository](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo)

gradle/libs.versions.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,5 @@ jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "j
7070
aspectjrt = { module = "org.aspectj:aspectjrt", version.ref = "aspectjrt" }
7171
aspectjrt-weaver = { module = "org.aspectj:aspectjweaver", version.ref = "aspectjrtweaver" }
7272
findbugs = { module = "com.google.code.findbugs:jsr305", version.ref = "findbugs" }
73-
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jacksonDatabind" }
73+
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jacksonDatabind" }
74+
slf4j-jul = { module = "org.slf4j:slf4j-jdk14", version = "1.7.25" }

main/src/main/java/org/mobilitydata/gtfsvalidator/reportsummary/JsonReportSummaryGenerator.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ public JsonReportSummaryGenerator(
3131
date,
3232
config != null ? config.gtfsSource().toString() : null,
3333
config != null ? config.numThreads() : 0,
34-
config != null ? config.outputDirectory().toString() : null,
35-
config != null ? config.systemErrorsReportFileName() : null,
36-
config != null ? config.validationReportFileName() : null,
37-
config != null ? config.htmlReportFileName() : null,
34+
config != null && config.outputDirectory().isPresent()
35+
? config.outputDirectory().get().toString()
36+
: null,
37+
config != null && !config.stdoutOutput() ? config.systemErrorsReportFileName() : null,
38+
config != null && !config.stdoutOutput() ? config.validationReportFileName() : null,
39+
config != null && !config.stdoutOutput() ? config.htmlReportFileName() : null,
3840
config != null ? config.countryCode().getCountryCode() : null,
3941
config != null ? config.dateForValidation().toString() : null,
4042
feedMetadata != null && feedMetadata.feedInfo != null

0 commit comments

Comments
 (0)