@@ -44,6 +44,12 @@
gbfs-validator-java
${gbfs-validator.version}
+
+
+ org.mobilitydata
+ gbfs-validator-java-loader
+ ${gbfs-validator.version}
+
diff --git a/example/src/main/java/org/mobilitydata/gbfs/validator/example/GbfsValidatorExample.java b/example/src/main/java/org/mobilitydata/gbfs/validator/example/GbfsValidatorExample.java
index 6d4e1fcc..1366f18d 100644
--- a/example/src/main/java/org/mobilitydata/gbfs/validator/example/GbfsValidatorExample.java
+++ b/example/src/main/java/org/mobilitydata/gbfs/validator/example/GbfsValidatorExample.java
@@ -9,21 +9,25 @@
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import org.mobilitydata.gbfs.validation.GbfsValidator;
import org.mobilitydata.gbfs.validation.GbfsValidatorFactory;
import org.mobilitydata.gbfs.validation.model.FileValidationError;
import org.mobilitydata.gbfs.validation.model.FileValidationResult;
import org.mobilitydata.gbfs.validation.model.ValidationResult;
+import org.mobilitydata.gbfs.validator.loader.LoadedFile;
+import org.mobilitydata.gbfs.validator.loader.Loader;
/**
* Example showing how to validate a GBFS feed using gbfs-validator-java.
*
- * This example:
+ *
Two use cases are demonstrated:
*
- * - Fetches gbfs.json from a public GBFS feed
- * - Validates the file using GbfsValidatorFactory
- * - Prints the validation results to stdout
+ * - Single-file validation via {@link GbfsValidator#validateFile}
+ * - Full-feed validation via {@link Loader} + {@link GbfsValidator#validate}:
+ * the Loader fetches gbfs.json and automatically discovers and loads all
+ * linked feed files, so no manual URL construction is needed.
*
*
* Usage:
@@ -42,57 +46,54 @@ public class GbfsValidatorExample {
public static void main(String[] args) throws IOException, InterruptedException {
System.out.println("=== GBFS Validator Java Example ===\n");
+ GbfsValidator validator = GbfsValidatorFactory.getGbfsJsonValidator();
+
// --- Example 1: Validate a single file ---
+ // Useful when you already have the file content and just want schema validation.
System.out.println("Example 1: Validate a single file");
System.out.println("Fetching: " + GBFS_FEED_URL);
- String fileContents = fetchUrl(GBFS_FEED_URL);
-
- GbfsValidator validator = GbfsValidatorFactory.getGbfsJsonValidator();
- InputStream fileStream = new ByteArrayInputStream(
- fileContents.getBytes(StandardCharsets.UTF_8)
- );
-
- // The API expects file names WITHOUT the .json extension (e.g. "gbfs", not "gbfs.json")
- FileValidationResult fileResult = validator.validateFile("gbfs", fileStream);
+ String gbfsContent = fetchUrl(GBFS_FEED_URL);
+ InputStream gbfsStream = new ByteArrayInputStream(gbfsContent.getBytes(StandardCharsets.UTF_8));
+ FileValidationResult fileResult = validator.validateFile("gbfs", gbfsStream);
printFileResult(fileResult);
- // --- Example 2: Validate a full feed (multiple files) ---
+ // --- Example 2: Validate a full feed ---
+ // The Loader fetches gbfs.json, parses the feed URLs from its discovery data,
+ // and loads all linked files — handling language prefixes and auth automatically.
System.out.println("\nExample 2: Validate a full feed");
- Map feedFiles = new HashMap<>();
-
- // Keys must be the GBFS file type name (no .json extension)
- feedFiles.put("gbfs", new ByteArrayInputStream(
- fileContents.getBytes(StandardCharsets.UTF_8)
- ));
-
- // Fetch additional files — URL uses .json, but map key does not
- String[] additionalFileNames = {
- "system_information",
- "station_information",
- "station_status",
- "free_bike_status",
- };
- String baseUrl = GBFS_FEED_URL.substring(0, GBFS_FEED_URL.lastIndexOf('/') + 1);
- for (String fileType : additionalFileNames) {
- try {
- String content = fetchUrl(baseUrl + fileType + ".json");
- feedFiles.put(fileType, new ByteArrayInputStream(
- content.getBytes(StandardCharsets.UTF_8)
- ));
- System.out.println(" Loaded: " + fileType);
- } catch (Exception e) {
- System.out.println(" Skipped: " + fileType + " (" + e.getMessage() + ")");
+ Loader loader = new Loader();
+ try {
+ List loadedFiles = loader.load(GBFS_FEED_URL);
+
+ Map fileMap = new HashMap<>();
+ for (LoadedFile file : loadedFiles) {
+ // Keep the discovery file (no language) and only "en" language files.
+ // If a feed does not publish "en", swap "en" for the desired language code.
+ String lang = file.language();
+ if (lang != null && !lang.equals("en")) {
+ continue;
+ }
+ if (file.fileContents() != null) {
+ System.out.println(" Loaded: " + file.fileName() + " (" + file.url() + ")");
+ fileMap.put(file.fileName(), file.fileContents());
+ } else {
+ file.loaderErrors().forEach(e ->
+ System.out.println(" Skipped: " + file.fileName()
+ + " (" + e.error() + ": " + e.message() + ")")
+ );
+ }
}
- }
- ValidationResult feedResult = validator.validate(feedFiles);
- printFeedResult(feedResult);
+ ValidationResult feedResult = validator.validate(fileMap);
+ printFeedResult(feedResult);
+ } finally {
+ loader.close();
+ }
}
private static void printFileResult(FileValidationResult result) {
System.out.println(" File : " + result.file());
System.out.println(" Version : " + result.version());
- System.out.println(" Schema : " + result.schema());
System.out.println(" Exists : " + result.exists());
System.out.println(" Required: " + result.required());
System.out.println(" Errors : " + result.errorsCount());
@@ -115,14 +116,17 @@ private static void printFileResult(FileValidationResult result) {
private static void printFeedResult(ValidationResult result) {
System.out.println(" Summary : " + result.summary());
- System.out.println(" Files validated: " + result.files().size());
- result.files().forEach((name, fileResult) -> {
+ var presentFiles = result.files().entrySet().stream()
+ .filter(e -> e.getValue().exists())
+ .toList();
+ System.out.println(" Files validated: " + presentFiles.size());
+ presentFiles.forEach(e -> {
System.out.printf(" %-35s errors=%d version=%s%n",
- name, fileResult.errorsCount(), fileResult.version());
+ e.getKey(), e.getValue().errorsCount(), e.getValue().version());
});
- long totalErrors = result.files().values().stream()
- .mapToLong(FileValidationResult::errorsCount)
+ long totalErrors = presentFiles.stream()
+ .mapToLong(e -> e.getValue().errorsCount())
.sum();
System.out.println("\n Total errors across all files: " + totalErrors);
@@ -139,12 +143,11 @@ private static String fetchUrl(String url) throws IOException, InterruptedExcept
.uri(URI.create(url))
.GET()
.build();
- HttpResponse response = client.send(
- request, HttpResponse.BodyHandlers.ofString()
- );
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode() + " for " + url);
}
return response.body();
}
}
+
diff --git a/gbfs-validator-java-cli/src/main/java/org/mobilitydata/gbfs/validator/cli/formatter/ConsoleReportFormatter.java b/gbfs-validator-java-cli/src/main/java/org/mobilitydata/gbfs/validator/cli/formatter/ConsoleReportFormatter.java
index d5549c22..814266ae 100644
--- a/gbfs-validator-java-cli/src/main/java/org/mobilitydata/gbfs/validator/cli/formatter/ConsoleReportFormatter.java
+++ b/gbfs-validator-java-cli/src/main/java/org/mobilitydata/gbfs/validator/cli/formatter/ConsoleReportFormatter.java
@@ -82,6 +82,8 @@ public String format(
if (fileResult.required()) {
sb.append(" [REQUIRED]");
+ } else if (fileResult.conditionallyRequired()) {
+ sb.append(" [CONDITIONALLY REQUIRED]");
}
if (!fileResult.exists()) {
diff --git a/gbfs-validator-java/src/main/java/org/mobilitydata/gbfs/validation/model/FileValidationResult.java b/gbfs-validator-java/src/main/java/org/mobilitydata/gbfs/validation/model/FileValidationResult.java
index 4273ecf6..42f751ca 100644
--- a/gbfs-validator-java/src/main/java/org/mobilitydata/gbfs/validation/model/FileValidationResult.java
+++ b/gbfs-validator-java/src/main/java/org/mobilitydata/gbfs/validation/model/FileValidationResult.java
@@ -27,6 +27,8 @@
* The result of validating a single GBFS file
* @param file The name of the file that was validated
* @param required Whether the file is required in the given version of GBFS
+ * @param conditionallyRequired Whether the file is conditionally required (e.g. station_status or
+ * vehicle_status must be present — neither is individually required, but at least one must exist)
* @param exists Whether the file existed in the validation input
* @param errorsCount The number of errors found while validating the file
* @param schema The schema used to validate the file
@@ -38,6 +40,7 @@
public record FileValidationResult(
String file,
boolean required,
+ boolean conditionallyRequired,
boolean exists,
int errorsCount,
String schema,
@@ -61,6 +64,8 @@ public String toString() {
'\'' +
", required=" +
required +
+ ", conditionallyRequired=" +
+ conditionallyRequired +
", exists=" +
exists +
", errorsCount=" +
@@ -86,6 +91,7 @@ public String toString() {
public boolean sameAs(FileValidationResult other) {
if (other == null) return false;
if (required != other.required) return false;
+ if (conditionallyRequired != other.conditionallyRequired) return false;
if (exists != other.exists) return false;
if (errorsCount != other.errorsCount) return false; // This should ideally reflect both validation and system errors count
if (!Objects.equals(file, other.file)) return false;
diff --git a/gbfs-validator-java/src/main/java/org/mobilitydata/gbfs/validation/validator/FileValidator.java b/gbfs-validator-java/src/main/java/org/mobilitydata/gbfs/validation/validator/FileValidator.java
index 96098728..bb88a172 100644
--- a/gbfs-validator-java/src/main/java/org/mobilitydata/gbfs/validation/validator/FileValidator.java
+++ b/gbfs-validator-java/src/main/java/org/mobilitydata/gbfs/validation/validator/FileValidator.java
@@ -80,13 +80,14 @@ public FileValidationResult validate(
return new FileValidationResult(
feedName,
isRequired(feedName),
+ false,
feed != null,
errorsCount,
schema.toString(),
Optional.ofNullable(feed).map(JSONObject::toString).orElse(null),
version.getVersionString(),
validationErrors,
- java.util.Collections.emptyList() // Added for systemErrors
+ java.util.Collections.emptyList()
);
}
@@ -130,6 +131,7 @@ public FileValidationResult validateMissingFile(String file) {
file,
isRequired,
false,
+ false,
isRequired ? 1 : 0,
version.getSchema(file).toString(),
null,
diff --git a/gbfs-validator-java/src/main/java/org/mobilitydata/gbfs/validation/validator/GbfsJsonValidator.java b/gbfs-validator-java/src/main/java/org/mobilitydata/gbfs/validation/validator/GbfsJsonValidator.java
index db813273..33c220c8 100644
--- a/gbfs-validator-java/src/main/java/org/mobilitydata/gbfs/validation/validator/GbfsJsonValidator.java
+++ b/gbfs-validator-java/src/main/java/org/mobilitydata/gbfs/validation/validator/GbfsJsonValidator.java
@@ -131,6 +131,7 @@ public ValidationResult validate(Map rawFeeds) {
List missingFiles = findMissingFiles(version, fileValidations);
handleMissingFiles(fileValidations, missingFiles, version); // This creates FVRs for missing files
+ checkStatusFilePresence(fileValidations, version);
ValidationSummary summary = new ValidationSummary(
version.getVersionString(),
@@ -147,6 +148,60 @@ public ValidationResult validate(Map rawFeeds) {
return new ValidationResult(summary, fileValidations);
}
+ /**
+ * Per GBFS spec, a feed must be dock-based, free-floating, or hybrid, so at least one of
+ * station_status / vehicle_status (v3+) or station_status / free_bike_status (pre-v3) must
+ * be present. If neither is present, both are flagged as required with an error.
+ */
+ private void checkStatusFilePresence(
+ Map fileValidations,
+ Version version
+ ) {
+ String freeFloatingFile = version.getFileNames().contains("vehicle_status")
+ ? "vehicle_status"
+ : "free_bike_status";
+
+ boolean stationStatusAbsent = !isPresent(fileValidations, "station_status");
+ boolean freeFloatingAbsent = !isPresent(fileValidations, freeFloatingFile);
+
+ if (stationStatusAbsent && freeFloatingAbsent) {
+ markAsConditionallyRequired(fileValidations, "station_status");
+ markAsConditionallyRequired(fileValidations, freeFloatingFile);
+ }
+ }
+
+ private boolean isPresent(
+ Map fileValidations,
+ String file
+ ) {
+ FileValidationResult result = fileValidations.get(file);
+ return result != null && result.exists();
+ }
+
+ private void markAsConditionallyRequired(
+ Map fileValidations,
+ String file
+ ) {
+ FileValidationResult existing = fileValidations.get(file);
+ if (existing != null) {
+ fileValidations.put(
+ file,
+ new FileValidationResult(
+ existing.file(),
+ false,
+ true,
+ false,
+ 1,
+ existing.schema(),
+ null,
+ existing.version(),
+ Collections.emptyList(),
+ Collections.emptyList()
+ )
+ );
+ }
+ }
+
private Version detectVersionFromParsedFeeds(
Map parsedFeeds
) {
@@ -357,6 +412,7 @@ private FileValidationResult createParsingErrorResult(
return new FileValidationResult(
feedName,
supportedFeed && schemaVersion.isFileRequired(feedName),
+ false,
true,
0,
supportedFeed ? schemaVersion.getSchema(feedName).toString() : null,
diff --git a/gbfs-validator-java/src/test/java/org/mobilitydata/gbfs/validation/model/ValidationResultTest.java b/gbfs-validator-java/src/test/java/org/mobilitydata/gbfs/validation/model/ValidationResultTest.java
index a3ebcb99..6829a928 100644
--- a/gbfs-validator-java/src/test/java/org/mobilitydata/gbfs/validation/model/ValidationResultTest.java
+++ b/gbfs-validator-java/src/test/java/org/mobilitydata/gbfs/validation/model/ValidationResultTest.java
@@ -64,6 +64,7 @@ private FileValidationResult generateFileValidationResult(
return new FileValidationResult(
"gbfs",
true,
+ false,
true,
2,
null,
diff --git a/gbfs-validator-java/src/test/java/org/mobilitydata/gbfs/validation/validator/GbfsJsonValidatorTest.java b/gbfs-validator-java/src/test/java/org/mobilitydata/gbfs/validation/validator/GbfsJsonValidatorTest.java
index b9e601cf..5bc8d8d7 100644
--- a/gbfs-validator-java/src/test/java/org/mobilitydata/gbfs/validation/validator/GbfsJsonValidatorTest.java
+++ b/gbfs-validator-java/src/test/java/org/mobilitydata/gbfs/validation/validator/GbfsJsonValidatorTest.java
@@ -40,9 +40,9 @@ void testEmptyDeliveryMapValidation() {
Map deliveryMap = new HashMap<>();
ValidationResult result = validator.validate(deliveryMap);
- // The expected error count is 2, because there are two required files
- // missing in an empty delivery, gbfs.json, and system_information.json
- Assertions.assertEquals(2, result.summary().errorsCount());
+ // The expected error count is 4: gbfs.json, system_information.json, station_status.json,
+ // and free_bike_status.json are all required (directly or conditionally) but missing.
+ Assertions.assertEquals(4, result.summary().errorsCount());
}
@Test
@@ -59,6 +59,10 @@ void testSuccessfulV1_0Validation() {
"system_hours",
getFixture("fixtures/v1.0/system_hours.json")
);
+ deliveryMap.put(
+ "free_bike_status",
+ getFixture("fixtures/v1.0/free_bike_status.json")
+ );
ValidationResult result = validator.validate(deliveryMap);
@@ -86,6 +90,10 @@ void testSuccessfulV1_1Validation() {
"system_hours",
getFixture("fixtures/v1.1/system_hours.json")
);
+ deliveryMap.put(
+ "free_bike_status",
+ getFixture("fixtures/v1.1/free_bike_status.json")
+ );
ValidationResult result = validator.validate(deliveryMap);
@@ -113,6 +121,10 @@ void testSuccessfulV2_0Validation() {
"system_hours",
getFixture("fixtures/v2.0/system_hours.json")
);
+ deliveryMap.put(
+ "free_bike_status",
+ getFixture("fixtures/v2.0/free_bike_status.json")
+ );
ValidationResult result = validator.validate(deliveryMap);
@@ -475,7 +487,8 @@ void testMissingRequiredFile() {
Assertions.assertTrue(result.files().get("system_information").required());
Assertions.assertFalse(result.files().get("system_information").exists());
- Assertions.assertEquals(1, result.summary().errorsCount());
+ // 3 errors: system_information, station_status, and free_bike_status are all missing
+ Assertions.assertEquals(3, result.summary().errorsCount());
}
@Test
@@ -504,6 +517,16 @@ void testMissingNotRequiredFile() {
"system_information",
getFixture("fixtures/v2.2/system_information.json")
);
+ // Minimal station_status (empty stations) — satisfies the conditional requirement
+ // without triggering custom-rule violations that depend on other files.
+ String minimalStationStatus =
+ "{\"last_updated\":1609866235,\"ttl\":0,\"version\":\"2.2\",\"data\":{\"stations\":[]}}";
+ deliveryMap.put(
+ "station_status",
+ new ByteArrayInputStream(
+ minimalStationStatus.getBytes(java.nio.charset.StandardCharsets.UTF_8)
+ )
+ );
ValidationResult result = validator.validate(deliveryMap);
@@ -629,6 +652,128 @@ void testReadError() throws IOException {
);
}
+ @Test
+ void testMissingBothStatusFilesV3IsInvalid() {
+ GbfsJsonValidator validator = new GbfsJsonValidator();
+
+ // v3.0 feed with neither station_status nor vehicle_status
+ Map deliveryMap = new HashMap<>();
+ deliveryMap.put("gbfs", getFixture("fixtures/v3.0/gbfs.json"));
+ deliveryMap.put(
+ "system_information",
+ getFixture("fixtures/v3.0/system_information.json")
+ );
+
+ ValidationResult result = validator.validate(deliveryMap);
+
+ assertTrue(
+ result.summary().errorsCount() > 0,
+ "Feed missing both station_status and vehicle_status should be invalid"
+ );
+
+ FileValidationResult stationStatus = result.files().get("station_status");
+ FileValidationResult vehicleStatus = result.files().get("vehicle_status");
+
+ assertNotNull(stationStatus);
+ assertNotNull(vehicleStatus);
+ assertTrue(
+ stationStatus.conditionallyRequired(),
+ "station_status should be marked conditionallyRequired when vehicle_status is also absent"
+ );
+ assertTrue(
+ vehicleStatus.conditionallyRequired(),
+ "vehicle_status should be marked conditionallyRequired when station_status is also absent"
+ );
+ assertEquals(1, stationStatus.errorsCount());
+ assertEquals(1, vehicleStatus.errorsCount());
+ }
+
+ @Test
+ void testMissingBothStatusFilesPreV3IsInvalid() {
+ GbfsJsonValidator validator = new GbfsJsonValidator();
+
+ // v2.3 feed with neither station_status nor free_bike_status
+ Map deliveryMap = new HashMap<>();
+ deliveryMap.put("gbfs", getFixture("fixtures/v2.3/gbfs.json"));
+ deliveryMap.put(
+ "system_information",
+ getFixture("fixtures/v2.3/system_information.json")
+ );
+
+ ValidationResult result = validator.validate(deliveryMap);
+
+ assertTrue(
+ result.summary().errorsCount() > 0,
+ "Feed missing both station_status and free_bike_status should be invalid"
+ );
+
+ FileValidationResult stationStatus = result.files().get("station_status");
+ FileValidationResult freeBikeStatus = result
+ .files()
+ .get("free_bike_status");
+
+ assertNotNull(stationStatus);
+ assertNotNull(freeBikeStatus);
+ assertTrue(stationStatus.conditionallyRequired());
+ assertTrue(freeBikeStatus.conditionallyRequired());
+ assertEquals(1, stationStatus.errorsCount());
+ assertEquals(1, freeBikeStatus.errorsCount());
+ }
+
+ @Test
+ void testOnlyStationStatusPresentV3IsValid() {
+ GbfsJsonValidator validator = new GbfsJsonValidator();
+
+ // v3.0 dock-based feed — vehicle_status absent, but station_status present
+ Map deliveryMap = new HashMap<>();
+ deliveryMap.put("gbfs", getFixture("fixtures/v3.0/gbfs.json"));
+ deliveryMap.put(
+ "system_information",
+ getFixture("fixtures/v3.0/system_information.json")
+ );
+ deliveryMap.put(
+ "station_status",
+ getFixture("fixtures/v3.0/station_status.json")
+ );
+
+ ValidationResult result = validator.validate(deliveryMap);
+
+ FileValidationResult vehicleStatus = result.files().get("vehicle_status");
+ assertNotNull(vehicleStatus);
+ assertFalse(
+ vehicleStatus.required(),
+ "vehicle_status should not be required when station_status is present"
+ );
+ assertEquals(0, vehicleStatus.errorsCount());
+ }
+
+ @Test
+ void testOnlyVehicleStatusPresentV3IsValid() {
+ GbfsJsonValidator validator = new GbfsJsonValidator();
+
+ // v3.0 free-floating feed — station_status absent, but vehicle_status present
+ Map deliveryMap = new HashMap<>();
+ deliveryMap.put("gbfs", getFixture("fixtures/v3.0/gbfs.json"));
+ deliveryMap.put(
+ "system_information",
+ getFixture("fixtures/v3.0/system_information.json")
+ );
+ deliveryMap.put(
+ "vehicle_status",
+ getFixture("fixtures/v3.0/vehicle_status.json")
+ );
+
+ ValidationResult result = validator.validate(deliveryMap);
+
+ FileValidationResult stationStatus = result.files().get("station_status");
+ assertNotNull(stationStatus);
+ assertFalse(
+ stationStatus.required(),
+ "station_status should not be required when vehicle_status is present"
+ );
+ assertEquals(0, stationStatus.errorsCount());
+ }
+
// Helper class for testing IOException during read
private static class ThrowingInputStream extends InputStream {
diff --git a/gbfs-validator-java/src/test/resources/fixtures/v1.0/free_bike_status.json b/gbfs-validator-java/src/test/resources/fixtures/v1.0/free_bike_status.json
new file mode 100644
index 00000000..1a45cc4e
--- /dev/null
+++ b/gbfs-validator-java/src/test/resources/fixtures/v1.0/free_bike_status.json
@@ -0,0 +1 @@
+{"last_updated":1609866235,"ttl":0,"data":{"bikes":[]}}
diff --git a/gbfs-validator-java/src/test/resources/fixtures/v1.1/free_bike_status.json b/gbfs-validator-java/src/test/resources/fixtures/v1.1/free_bike_status.json
new file mode 100644
index 00000000..e8c3612e
--- /dev/null
+++ b/gbfs-validator-java/src/test/resources/fixtures/v1.1/free_bike_status.json
@@ -0,0 +1 @@
+{"last_updated":1609866235,"ttl":0,"version":"1.1","data":{"bikes":[]}}
diff --git a/gbfs-validator-java/src/test/resources/fixtures/v2.0/free_bike_status.json b/gbfs-validator-java/src/test/resources/fixtures/v2.0/free_bike_status.json
new file mode 100644
index 00000000..4c59714f
--- /dev/null
+++ b/gbfs-validator-java/src/test/resources/fixtures/v2.0/free_bike_status.json
@@ -0,0 +1 @@
+{"last_updated":1609866235,"ttl":0,"version":"2.0","data":{"bikes":[]}}