diff --git a/.github/workflows/build-external.yml b/.github/workflows/build-external.yml
index 54e27864..ef480f90 100644
--- a/.github/workflows/build-external.yml
+++ b/.github/workflows/build-external.yml
@@ -26,3 +26,7 @@ jobs:
${{ runner.os }}-
- name: Run maven build
run: mvn install -PprettierCheck -Dprettier.nodePath=node -Dprettier.npmPath=npm
+ - name: codecov
+ uses: codecov/codecov-action@v5
+ with:
+ files: ./**/target/site/jacoco/jacoco.xml
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 06207efb..f5de419f 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -31,7 +31,6 @@ jobs:
${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
${{ runner.os }}-maven-
${{ runner.os }}-
-
- name: Run maven build
run: mvn verify -s .github/workflows/settings.xml -PprettierCheck -Dprettier.nodePath=node -Dprettier.npmPath=npm
- name: Sonar Scan
@@ -47,6 +46,10 @@ jobs:
-Dsonar.projectName=${SONAR_PROJECT_NAME} \
-Dsonar.host.url=https://sonarcloud.io \
-Dsonar.token=${SONAR_TOKEN}
+ - name: codecov
+ uses: codecov/codecov-action@v5
+ with:
+ files: ./**/target/site/jacoco/jacoco.xml
- name: Upload artifact
uses: actions/upload-artifact@v4.6.2
with:
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 808241ab..fb3d6922 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -56,6 +56,10 @@ jobs:
-Dsonar.projectName=${SONAR_PROJECT_NAME} \
-Dsonar.host.url=https://sonarcloud.io \
-Dsonar.token=${SONAR_TOKEN}
+ - name: codecov
+ uses: codecov/codecov-action@v5
+ with:
+ files: ./**/target/site/jacoco/jacoco.xml
- name: Upload artifact
uses: actions/upload-artifact@v4.6.2
with:
diff --git a/gbfs-validator-java-cli/pom.xml b/gbfs-validator-java-cli/pom.xml
new file mode 100644
index 00000000..915be9d9
--- /dev/null
+++ b/gbfs-validator-java-cli/pom.xml
@@ -0,0 +1,187 @@
+
+
+ 4.0.0
+
+ org.entur.gbfs
+ gbfs-validator-java-parent
+ 2.0.57-SNAPSHOT
+
+
+ gbfs-validator-java-cli
+ jar
+ GBFS Validator CLI
+ Command-line interface for GBFS validation
+ https://github.com/entur/gbfs-validator-java
+
+
+ 17
+ 17
+ UTF-8
+ 4.7.7
+ 2.15.2
+ 5.10.2
+ 0.8.13
+
+
+
+
+
+
+
+ org.entur.gbfs
+ gbfs-validator-java
+ ${project.version}
+
+
+ org.entur.gbfs
+ gbfs-validator-java-loader
+ ${project.version}
+
+
+
+
+ info.picocli
+ picocli
+ ${picocli.version}
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jackson.version}
+
+
+
+
+ org.slf4j
+ slf4j-simple
+ 2.0.17
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.version}
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ 5.11.0
+ test
+
+
+ com.github.tomakehurst
+ wiremock-jre8
+ 2.35.1
+ test
+
+
+
+
+
+
+ src/main/resources
+ true
+
+
+
+
+
+ maven-compiler-plugin
+ 3.14.1
+
+ 17
+ 17
+ UTF-8
+
+
+ info.picocli
+ picocli-codegen
+ ${picocli.version}
+
+
+
+ -Aproject=${project.groupId}/${project.artifactId}
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.6.1
+
+
+ package
+
+ shade
+
+
+
+
+ org.entur.gbfs.validator.cli.GbfsValidatorCli
+
+
+ gbfs-validator-cli
+
+
+
+ *:*
+
+ META-INF/*.SF
+ META-INF/*.DSA
+ META-INF/*.RSA
+
+
+
+
+
+
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ ${jacoco-maven-plugin.version}
+
+
+ default-prepare-agent
+
+ prepare-agent
+
+
+
+ default-report
+ prepare-package
+
+ report
+
+
+
+ default-check
+
+ check
+
+
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.0.0
+
+
+
+
diff --git a/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/AuthOptions.java b/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/AuthOptions.java
new file mode 100644
index 00000000..0d52137d
--- /dev/null
+++ b/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/AuthOptions.java
@@ -0,0 +1,73 @@
+package org.entur.gbfs.validator.cli;
+
+import picocli.CommandLine.ArgGroup;
+import picocli.CommandLine.Option;
+
+public class AuthOptions {
+
+ @Option(
+ names = { "--auth-type" },
+ description = "Authentication type: basic, bearer, or oauth"
+ )
+ AuthType authType;
+
+ @ArgGroup(exclusive = false)
+ BasicAuthOptions basicAuth;
+
+ @ArgGroup(exclusive = false)
+ BearerAuthOptions bearerAuth;
+
+ @ArgGroup(exclusive = false)
+ OAuthOptions oauthOptions;
+
+ public static class BasicAuthOptions {
+
+ @Option(
+ names = { "--username" },
+ description = "Username for Basic Authentication",
+ required = true
+ )
+ String username;
+
+ @Option(
+ names = { "--password" },
+ description = "Password for Basic Authentication",
+ required = true
+ )
+ String password;
+ }
+
+ public static class BearerAuthOptions {
+
+ @Option(
+ names = { "--token" },
+ description = "Bearer token",
+ required = true
+ )
+ String token;
+ }
+
+ public static class OAuthOptions {
+
+ @Option(
+ names = { "--client-id" },
+ description = "OAuth client ID",
+ required = true
+ )
+ String clientId;
+
+ @Option(
+ names = { "--client-secret" },
+ description = "OAuth client secret",
+ required = true
+ )
+ String clientSecret;
+
+ @Option(
+ names = { "--token-url" },
+ description = "OAuth token endpoint URL",
+ required = true
+ )
+ String tokenUrl;
+ }
+}
diff --git a/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/AuthType.java b/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/AuthType.java
new file mode 100644
index 00000000..d514728d
--- /dev/null
+++ b/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/AuthType.java
@@ -0,0 +1,7 @@
+package org.entur.gbfs.validator.cli;
+
+public enum AuthType {
+ BASIC,
+ BEARER,
+ OAUTH,
+}
diff --git a/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/AuthenticationHandler.java b/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/AuthenticationHandler.java
new file mode 100644
index 00000000..88611011
--- /dev/null
+++ b/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/AuthenticationHandler.java
@@ -0,0 +1,49 @@
+package org.entur.gbfs.validator.cli;
+
+import org.entur.gbfs.validator.loader.auth.Authentication;
+import org.entur.gbfs.validator.loader.auth.BasicAuth;
+import org.entur.gbfs.validator.loader.auth.BearerTokenAuth;
+import org.entur.gbfs.validator.loader.auth.OAuthClientCredentialsGrantAuth;
+
+public class AuthenticationHandler {
+
+ public static Authentication buildAuthentication(AuthOptions authOptions) {
+ if (authOptions == null || authOptions.authType == null) {
+ return null;
+ }
+
+ return switch (authOptions.authType) {
+ case BASIC -> {
+ if (authOptions.basicAuth == null) {
+ throw new IllegalArgumentException(
+ "Basic auth selected but --username and --password not provided"
+ );
+ }
+ yield new BasicAuth(
+ authOptions.basicAuth.username,
+ authOptions.basicAuth.password
+ );
+ }
+ case BEARER -> {
+ if (authOptions.bearerAuth == null) {
+ throw new IllegalArgumentException(
+ "Bearer auth selected but --token not provided"
+ );
+ }
+ yield new BearerTokenAuth(authOptions.bearerAuth.token);
+ }
+ case OAUTH -> {
+ if (authOptions.oauthOptions == null) {
+ throw new IllegalArgumentException(
+ "OAuth auth selected but credentials not provided"
+ );
+ }
+ yield new OAuthClientCredentialsGrantAuth(
+ authOptions.oauthOptions.clientId,
+ authOptions.oauthOptions.clientSecret,
+ authOptions.oauthOptions.tokenUrl
+ );
+ }
+ };
+ }
+}
diff --git a/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/GbfsValidatorCli.java b/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/GbfsValidatorCli.java
new file mode 100644
index 00000000..9acf89c5
--- /dev/null
+++ b/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/GbfsValidatorCli.java
@@ -0,0 +1,180 @@
+package org.entur.gbfs.validator.cli;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import org.entur.gbfs.validation.GbfsValidator;
+import org.entur.gbfs.validation.GbfsValidatorFactory;
+import org.entur.gbfs.validation.model.ValidationResult;
+import org.entur.gbfs.validator.cli.formatter.ConsoleReportFormatter;
+import org.entur.gbfs.validator.cli.formatter.JsonReportFormatter;
+import org.entur.gbfs.validator.cli.formatter.ReportFormatter;
+import org.entur.gbfs.validator.loader.LoadedFile;
+import org.entur.gbfs.validator.loader.Loader;
+import org.entur.gbfs.validator.loader.auth.Authentication;
+import picocli.CommandLine;
+import picocli.CommandLine.ArgGroup;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+@Command(
+ name = "gbfs-validator",
+ mixinStandardHelpOptions = true,
+ versionProvider = VersionProvider.class,
+ description = "Validate GBFS feeds against JSON schemas",
+ headerHeading = "Usage:%n%n",
+ synopsisHeading = "%n",
+ descriptionHeading = "%nDescription:%n%n",
+ parameterListHeading = "%nParameters:%n",
+ optionListHeading = "%nOptions:%n"
+)
+public class GbfsValidatorCli implements Callable {
+
+ @Option(
+ names = { "-u", "--url" },
+ description = "URL of the GBFS feed (discovery endpoint)",
+ required = true
+ )
+ private String feedUrl;
+
+ @Option(
+ names = { "-s", "--save-report" },
+ description = "Local path to output report file"
+ )
+ private File reportFile;
+
+ @Option(
+ names = { "-pr", "--print-report" },
+ description = "Print report to standard output (yes/no, default: yes)",
+ defaultValue = "yes"
+ )
+ private String printReport;
+
+ @Option(
+ names = { "-vb", "--verbose" },
+ description = "Verbose mode - show detailed error information"
+ )
+ private boolean verbose;
+
+ @Option(
+ names = { "--format" },
+ description = "Output format: text or json (default: text)",
+ defaultValue = "text"
+ )
+ private String format;
+
+ @ArgGroup(exclusive = false, heading = "%nAuthentication Options:%n")
+ private AuthOptions authOptions;
+
+ @Override
+ public Integer call() throws Exception {
+ Loader loader = null;
+
+ try {
+ Authentication auth = AuthenticationHandler.buildAuthentication(
+ authOptions
+ );
+
+ loader = new Loader();
+ List loadedFiles = loader.load(feedUrl, auth);
+
+ boolean hasFatalLoaderErrors = hasFatalLoaderErrors(loadedFiles);
+ if (hasFatalLoaderErrors && hasNoValidContent(loadedFiles)) {
+ System.err.println("ERROR: Failed to load any feeds from " + feedUrl);
+ return 2;
+ }
+
+ Map fileMap = buildFileMap(loadedFiles);
+
+ GbfsValidator validator = GbfsValidatorFactory.getGbfsJsonValidator();
+ ValidationResult result = validator.validate(fileMap);
+
+ String report = formatReport(result, loadedFiles);
+ outputReport(report);
+
+ return determineExitCode(hasFatalLoaderErrors, result);
+ } catch (Exception e) {
+ System.err.println("ERROR: " + e.getMessage());
+ if (verbose) {
+ e.printStackTrace(System.err);
+ }
+ return 2;
+ } finally {
+ if (loader != null) {
+ loader.close();
+ }
+ }
+ }
+
+ private boolean hasFatalLoaderErrors(List loadedFiles) {
+ return loadedFiles
+ .stream()
+ .anyMatch(file ->
+ file.loaderErrors() != null && !file.loaderErrors().isEmpty()
+ );
+ }
+
+ private boolean hasNoValidContent(List loadedFiles) {
+ return loadedFiles.stream().noneMatch(file -> file.fileContents() != null);
+ }
+
+ private Map buildFileMap(List loadedFiles) {
+ Map fileMap = new HashMap<>();
+ for (LoadedFile file : loadedFiles) {
+ if (file.fileContents() != null) {
+ fileMap.put(file.fileName(), file.fileContents());
+ }
+ }
+ return fileMap;
+ }
+
+ private String formatReport(
+ ValidationResult result,
+ List loadedFiles
+ ) {
+ ReportFormatter formatter = createFormatter(format);
+ return formatter.format(result, loadedFiles, verbose);
+ }
+
+ private void outputReport(String report) throws IOException {
+ if ("yes".equalsIgnoreCase(printReport)) {
+ System.out.println(report);
+ }
+
+ if (reportFile != null) {
+ ReportWriter.writeReport(reportFile, report);
+ if (!"yes".equalsIgnoreCase(printReport)) {
+ System.out.println("Report saved to: " + reportFile.getAbsolutePath());
+ }
+ }
+ }
+
+ private int determineExitCode(
+ boolean hasFatalLoaderErrors,
+ ValidationResult result
+ ) {
+ if (hasFatalLoaderErrors) {
+ return 2;
+ } else if (result.summary().errorsCount() > 0) {
+ return 1;
+ } else {
+ return 0;
+ }
+ }
+
+ private ReportFormatter createFormatter(String format) {
+ return switch (format.toLowerCase()) {
+ case "json" -> new JsonReportFormatter();
+ default -> new ConsoleReportFormatter();
+ };
+ }
+
+ public static void main(String[] args) {
+ int exitCode = new CommandLine(new GbfsValidatorCli()).execute(args);
+ System.exit(exitCode);
+ }
+}
diff --git a/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/ReportWriter.java b/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/ReportWriter.java
new file mode 100644
index 00000000..83cda9a2
--- /dev/null
+++ b/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/ReportWriter.java
@@ -0,0 +1,18 @@
+package org.entur.gbfs.validator.cli;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.StandardOpenOption;
+
+public class ReportWriter {
+
+ public static void writeReport(File file, String content) throws IOException {
+ Files.writeString(
+ file.toPath(),
+ content,
+ StandardOpenOption.CREATE,
+ StandardOpenOption.TRUNCATE_EXISTING
+ );
+ }
+}
diff --git a/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/VersionProvider.java b/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/VersionProvider.java
new file mode 100644
index 00000000..8f7505f9
--- /dev/null
+++ b/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/VersionProvider.java
@@ -0,0 +1,28 @@
+package org.entur.gbfs.validator.cli;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Properties;
+import picocli.CommandLine;
+
+public class VersionProvider implements CommandLine.IVersionProvider {
+
+ @Override
+ public String[] getVersion() throws Exception {
+ Properties properties = new Properties();
+ try (
+ InputStream input = getClass()
+ .getClassLoader()
+ .getResourceAsStream("version.properties")
+ ) {
+ if (input == null) {
+ return new String[] { "Unknown version" };
+ }
+ properties.load(input);
+ String version = properties.getProperty("version", "Unknown version");
+ return new String[] { version };
+ } catch (IOException e) {
+ return new String[] { "Unknown version" };
+ }
+ }
+}
diff --git a/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/formatter/ConsoleReportFormatter.java b/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/formatter/ConsoleReportFormatter.java
new file mode 100644
index 00000000..d8315887
--- /dev/null
+++ b/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/formatter/ConsoleReportFormatter.java
@@ -0,0 +1,147 @@
+package org.entur.gbfs.validator.cli.formatter;
+
+import java.util.List;
+import org.entur.gbfs.validation.model.FileValidationError;
+import org.entur.gbfs.validation.model.FileValidationResult;
+import org.entur.gbfs.validation.model.ValidationResult;
+import org.entur.gbfs.validation.model.ValidatorError;
+import org.entur.gbfs.validator.loader.LoadedFile;
+import org.entur.gbfs.validator.loader.LoaderError;
+
+public class ConsoleReportFormatter implements ReportFormatter {
+
+ private static final String HEADER = "=" + "=".repeat(79);
+ private static final String SUBHEADER = "-" + "-".repeat(79);
+
+ @Override
+ public String format(
+ ValidationResult result,
+ List loadedFiles,
+ boolean verbose
+ ) {
+ StringBuilder sb = new StringBuilder();
+
+ // Header
+ sb.append(HEADER).append("\n");
+ sb.append("GBFS Validation Report\n");
+ sb.append(HEADER).append("\n\n");
+
+ // Summary
+ sb.append("Version: ").append(result.summary().version()).append("\n");
+ sb.append("Timestamp: ").append(result.summary().timestamp()).append("\n");
+ sb
+ .append("Total Errors: ")
+ .append(result.summary().errorsCount())
+ .append("\n");
+ sb.append("Files Validated: ").append(result.files().size()).append("\n\n");
+
+ // Loader Errors (if any)
+ boolean hasLoaderErrors = loadedFiles
+ .stream()
+ .anyMatch(file ->
+ file.loaderErrors() != null && !file.loaderErrors().isEmpty()
+ );
+
+ if (hasLoaderErrors) {
+ sb.append(SUBHEADER).append("\n");
+ sb.append("LOADER ERRORS\n");
+ sb.append(SUBHEADER).append("\n\n");
+
+ for (LoadedFile file : loadedFiles) {
+ if (file.loaderErrors() != null && !file.loaderErrors().isEmpty()) {
+ sb.append("File: ").append(file.fileName()).append("\n");
+ for (LoaderError error : file.loaderErrors()) {
+ sb
+ .append(" ✗ ")
+ .append(error.error())
+ .append(": ")
+ .append(error.message())
+ .append("\n");
+ }
+ sb.append("\n");
+ }
+ }
+ }
+
+ // File Results
+ sb.append(SUBHEADER).append("\n");
+ sb.append("VALIDATION RESULTS\n");
+ sb.append(SUBHEADER).append("\n\n");
+
+ for (var entry : result.files().entrySet()) {
+ String fileName = entry.getKey();
+ FileValidationResult fileResult = entry.getValue();
+
+ if (fileResult == null) {
+ continue;
+ }
+
+ // File status
+ String status = getFileStatus(fileResult);
+ sb.append(status).append(" ").append(fileName);
+
+ if (fileResult.required()) {
+ sb.append(" [REQUIRED]");
+ }
+
+ if (!fileResult.exists()) {
+ sb.append(" - NOT FOUND");
+ } else if (fileResult.errorsCount() > 0) {
+ sb.append(" - ").append(fileResult.errorsCount()).append(" error(s)");
+ }
+
+ sb.append("\n");
+
+ // Validator errors (system errors)
+ if (
+ fileResult.validatorErrors() != null &&
+ !fileResult.validatorErrors().isEmpty()
+ ) {
+ for (ValidatorError error : fileResult.validatorErrors()) {
+ sb.append(" ✗ SYSTEM ERROR: ").append(error.message()).append("\n");
+ }
+ }
+
+ // Validation errors (schema violations)
+ if (
+ verbose && fileResult.errors() != null && !fileResult.errors().isEmpty()
+ ) {
+ for (FileValidationError error : fileResult.errors()) {
+ sb.append(" ✗ ").append(error.message()).append("\n");
+ sb.append(" Path: ").append(error.violationPath()).append("\n");
+ sb.append(" Schema: ").append(error.schemaPath()).append("\n");
+ }
+ } else if (!verbose && fileResult.errorsCount() > 0) {
+ sb.append(" (Use --verbose to see detailed errors)\n");
+ }
+
+ sb.append("\n");
+ }
+
+ // Footer
+ sb.append(HEADER).append("\n");
+ String resultText = result.summary().errorsCount() == 0
+ ? "VALID"
+ : "INVALID";
+ sb.append("Result: ").append(resultText).append("\n");
+ sb.append(HEADER).append("\n");
+
+ return sb.toString();
+ }
+
+ private String getFileStatus(FileValidationResult fileResult) {
+ if (!fileResult.exists()) {
+ return "⚠";
+ } else if (
+ fileResult.errorsCount() == 0 &&
+ (
+ fileResult.validatorErrors() == null ||
+ fileResult.validatorErrors().isEmpty()
+ )
+ ) {
+ return "✓";
+ } else {
+ return "✗";
+ }
+ }
+}
diff --git a/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/formatter/JsonReportFormatter.java b/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/formatter/JsonReportFormatter.java
new file mode 100644
index 00000000..f959cf15
--- /dev/null
+++ b/gbfs-validator-java-cli/src/main/java/org/entur/gbfs/validator/cli/formatter/JsonReportFormatter.java
@@ -0,0 +1,66 @@
+package org.entur.gbfs.validator.cli.formatter;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.entur.gbfs.validation.model.ValidationResult;
+import org.entur.gbfs.validator.loader.LoadedFile;
+
+public class JsonReportFormatter implements ReportFormatter {
+
+ private final ObjectMapper objectMapper;
+
+ public JsonReportFormatter() {
+ this.objectMapper = new ObjectMapper();
+ this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
+ }
+
+ @Override
+ public String format(
+ ValidationResult result,
+ List loadedFiles,
+ boolean verbose
+ ) {
+ try {
+ Map report = new HashMap<>();
+
+ // Validation result
+ report.put("summary", result.summary());
+ report.put("files", result.files());
+
+ // Loader errors
+ List