001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package dev.metaschema.cli.commands;
007
008import org.apache.commons.cli.CommandLine;
009import org.apache.commons.cli.Option;
010import org.apache.logging.log4j.LogManager;
011import org.apache.logging.log4j.Logger;
012
013import java.io.File;
014import java.io.FileNotFoundException;
015import java.io.IOException;
016import java.net.URI;
017import java.net.UnknownHostException;
018import java.nio.file.Path;
019import java.nio.file.Paths;
020import java.util.Collection;
021import java.util.List;
022import java.util.Locale;
023import java.util.Set;
024
025import dev.metaschema.cli.processor.CLIProcessor;
026import dev.metaschema.cli.processor.CallingContext;
027import dev.metaschema.cli.processor.ExitCode;
028import dev.metaschema.cli.processor.command.AbstractCommandExecutor;
029import dev.metaschema.cli.processor.command.AbstractTerminalCommand;
030import dev.metaschema.cli.processor.command.CommandExecutionException;
031import dev.metaschema.cli.processor.command.ExtraArgument;
032import dev.metaschema.cli.util.LoggingValidationHandler;
033import dev.metaschema.core.configuration.DefaultConfiguration;
034import dev.metaschema.core.configuration.IMutableConfiguration;
035import dev.metaschema.core.metapath.MetapathException;
036import dev.metaschema.core.metapath.format.IPathFormatter;
037import dev.metaschema.core.metapath.format.PathFormatSelection;
038import dev.metaschema.core.model.IModule;
039import dev.metaschema.core.model.MetaschemaException;
040import dev.metaschema.core.model.constraint.ConstraintValidationException;
041import dev.metaschema.core.model.constraint.IConstraintSet;
042import dev.metaschema.core.model.constraint.ValidationFeature;
043import dev.metaschema.core.model.validation.AggregateValidationResult;
044import dev.metaschema.core.model.validation.IValidationResult;
045import dev.metaschema.core.util.IVersionInfo;
046import dev.metaschema.core.util.ObjectUtils;
047import dev.metaschema.databind.IBindingContext;
048import dev.metaschema.databind.IBindingContext.ISchemaValidationProvider;
049import dev.metaschema.databind.io.Format;
050import dev.metaschema.databind.io.IBoundLoader;
051import dev.metaschema.modules.sarif.SarifValidationHandler;
052import edu.umd.cs.findbugs.annotations.NonNull;
053import edu.umd.cs.findbugs.annotations.Nullable;
054
055/**
056 * Used by implementing classes to provide a content validation command.
057 */
058public abstract class AbstractValidateContentCommand
059    extends AbstractTerminalCommand {
060  private static final Logger LOGGER = LogManager.getLogger(AbstractValidateContentCommand.class);
061  @NonNull
062  private static final String COMMAND = "validate";
063  @NonNull
064  private static final List<ExtraArgument> EXTRA_ARGUMENTS = ObjectUtils.notNull(List.of(
065      ExtraArgument.newInstance("file-or-URI-to-validate", true, URI.class)));
066
067  @NonNull
068  private static final Option CONSTRAINTS_OPTION = ObjectUtils.notNull(
069      Option.builder("c")
070          .hasArgs()
071          .argName("URL")
072          .type(URI.class)
073          .desc("additional constraint definitions")
074          .get());
075  @NonNull
076  private static final Option SARIF_OUTPUT_FILE_OPTION = ObjectUtils.notNull(
077      Option.builder("o")
078          .hasArg()
079          .argName("FILE")
080          .type(File.class)
081          .desc("write SARIF results to the provided FILE")
082          .numberOfArgs(1)
083          .get());
084  @NonNull
085  private static final Option SARIF_INCLUDE_PASS_OPTION = ObjectUtils.notNull(
086      Option.builder()
087          .longOpt("sarif-include-pass")
088          .desc("include pass results in SARIF")
089          .get());
090  @NonNull
091  private static final Option NO_SCHEMA_VALIDATION_OPTION = ObjectUtils.notNull(
092      Option.builder()
093          .longOpt("disable-schema-validation")
094          .desc("do not perform schema validation")
095          .get());
096  @NonNull
097  private static final Option NO_CONSTRAINT_VALIDATION_OPTION = ObjectUtils.notNull(
098      Option.builder()
099          .longOpt("disable-constraint-validation")
100          .desc("do not perform constraint validation")
101          .get());
102  @NonNull
103  private static final Option PATH_FORMAT_OPTION = ObjectUtils.notNull(
104      Option.builder()
105          .longOpt("path-format")
106          .hasArg()
107          .argName("FORMAT")
108          .type(PathFormatSelection.class)
109          .desc("path format in validation output: auto (default, selects based on document format), "
110              + "metapath, xpath, jsonpointer")
111          .get());
112  @NonNull
113  private static final Option PARALLEL_THREADS_OPTION = ObjectUtils.notNull(
114      Option.builder()
115          .longOpt("threads")
116          .hasArg()
117          .argName("count")
118          .type(Number.class)
119          .desc("number of threads for parallel constraint validation (default: 1, experimental)")
120          .get());
121
122  @Override
123  public String getName() {
124    return COMMAND;
125  }
126
127  @SuppressWarnings("null")
128  @Override
129  public Collection<? extends Option> gatherOptions() {
130    return List.of(
131        MetaschemaCommands.AS_FORMAT_OPTION,
132        CONSTRAINTS_OPTION,
133        SARIF_OUTPUT_FILE_OPTION,
134        SARIF_INCLUDE_PASS_OPTION,
135        NO_SCHEMA_VALIDATION_OPTION,
136        NO_CONSTRAINT_VALIDATION_OPTION,
137        PATH_FORMAT_OPTION,
138        PARALLEL_THREADS_OPTION);
139  }
140
141  @Override
142  public List<ExtraArgument> getExtraArguments() {
143    return EXTRA_ARGUMENTS;
144  }
145
146  /**
147   * Drives the validation execution.
148   */
149  protected abstract class AbstractValidationCommandExecutor
150      extends AbstractCommandExecutor {
151
152    /**
153     * Construct a new command executor.
154     *
155     * @param callingContext
156     *          the context of the command execution
157     * @param commandLine
158     *          the parsed command line details
159     */
160    public AbstractValidationCommandExecutor(
161        @NonNull CallingContext callingContext,
162        @NonNull CommandLine commandLine) {
163      super(callingContext, commandLine);
164    }
165
166    /**
167     * Get the binding context to use for data processing.
168     *
169     * @param constraintSets
170     *          the constraints to configure in the resulting binding context
171     * @return the context
172     * @throws CommandExecutionException
173     *           if a error occurred while getting the binding context
174     */
175    @NonNull
176    protected abstract IBindingContext getBindingContext(@NonNull Set<IConstraintSet> constraintSets)
177        throws CommandExecutionException;
178
179    /**
180     * Get the module to use for validation.
181     * <p>
182     * This module is used to generate schemas and as a source of built-in
183     * constraints.
184     *
185     * @param commandLine
186     *          the provided command line argument information
187     * @param bindingContext
188     *          the context used to access Metaschema module information based on
189     *          Java class bindings
190     * @return the loaded Metaschema module
191     * @throws CommandExecutionException
192     *           if an error occurred while loading the module
193     */
194    @NonNull
195    protected abstract IModule getModule(
196        @NonNull CommandLine commandLine,
197        @NonNull IBindingContext bindingContext)
198        throws CommandExecutionException;
199
200    /**
201     * Get the schema validation implementation requested based on the provided
202     * command line arguments.
203     * <p>
204     * It is typical for this call to result in the dynamic generation of a schema
205     * to use for validation.
206     *
207     * @param module
208     *          the Metaschema module to generate the schema from
209     * @param commandLine
210     *          the provided command line argument information
211     * @param bindingContext
212     *          the context used to access Metaschema module information based on
213     *          Java class bindings
214     * @return the provider
215     */
216    @NonNull
217    protected abstract ISchemaValidationProvider getSchemaValidationProvider(
218        @NonNull IModule module,
219        @NonNull CommandLine commandLine,
220        @NonNull IBindingContext bindingContext);
221
222    /**
223     * Execute the validation operation.
224     */
225    @Override
226    public void execute() throws CommandExecutionException {
227      CommandLine cmdLine = getCommandLine();
228      @SuppressWarnings("synthetic-access")
229      URI currentWorkingDirectory = ObjectUtils.notNull(getCurrentWorkingDirectory().toUri());
230
231      Set<IConstraintSet> constraintSets = MetaschemaCommands.loadConstraintSets(
232          cmdLine,
233          CONSTRAINTS_OPTION,
234          currentWorkingDirectory);
235
236      List<String> extraArgs = cmdLine.getArgList();
237
238      URI source = MetaschemaCommands.handleSource(
239          ObjectUtils.requireNonNull(extraArgs.get(0)),
240          currentWorkingDirectory);
241
242      IBindingContext bindingContext = getBindingContext(constraintSets);
243      IBoundLoader loader = bindingContext.newBoundLoader();
244      Format asFormat = MetaschemaCommands.determineSourceFormat(
245          cmdLine,
246          MetaschemaCommands.AS_FORMAT_OPTION,
247          loader,
248          source);
249
250      IValidationResult validationResult = validate(source, asFormat, cmdLine, bindingContext);
251      handleOutput(source, validationResult, asFormat, cmdLine, bindingContext);
252
253      if (validationResult == null || validationResult.isPassing()) {
254        if (LOGGER.isInfoEnabled()) {
255          LOGGER.info("The file '{}' is valid.", source);
256        }
257      } else if (LOGGER.isErrorEnabled()) {
258        LOGGER.error("The file '{}' is invalid.", source);
259      }
260
261      if (validationResult != null && !validationResult.isPassing()) {
262        throw new CommandExecutionException(ExitCode.FAIL);
263      }
264    }
265
266    @SuppressWarnings("PMD.CyclomaticComplexity")
267    @Nullable
268    private IValidationResult validate(
269        @NonNull URI source,
270        @NonNull Format asFormat,
271        @NonNull CommandLine commandLine,
272        @NonNull IBindingContext bindingContext) throws CommandExecutionException {
273
274      if (LOGGER.isInfoEnabled()) {
275        LOGGER.info("Validating '{}' as {}.", source, asFormat.name());
276      }
277
278      IValidationResult validationResult = null;
279      try {
280        // get the module, but don't register it
281        IModule module = getModule(commandLine, bindingContext);
282        if (!commandLine.hasOption(NO_SCHEMA_VALIDATION_OPTION)) {
283          // perform schema validation
284          validationResult = getSchemaValidationProvider(module, commandLine, bindingContext)
285              .validateWithSchema(source, asFormat, bindingContext);
286        }
287
288        if (!commandLine.hasOption(NO_CONSTRAINT_VALIDATION_OPTION)) {
289          IMutableConfiguration<ValidationFeature<?>> configuration = new DefaultConfiguration<>();
290          if (commandLine.hasOption(SARIF_OUTPUT_FILE_OPTION) && commandLine.hasOption(SARIF_INCLUDE_PASS_OPTION)) {
291            configuration.enableFeature(ValidationFeature.VALIDATE_GENERATE_PASS_FINDINGS);
292          }
293
294          // Configure parallel validation if requested
295          if (commandLine.hasOption(PARALLEL_THREADS_OPTION)) {
296            String threadValue = commandLine.getOptionValue(PARALLEL_THREADS_OPTION);
297            int threadCount;
298            try {
299              threadCount = Integer.parseInt(threadValue);
300            } catch (NumberFormatException ex) {
301              throw new CommandExecutionException(
302                  ExitCode.INVALID_ARGUMENTS,
303                  String.format("Invalid thread count '%s': must be a positive integer", threadValue),
304                  ex);
305            }
306            if (threadCount < 1) {
307              throw new CommandExecutionException(
308                  ExitCode.INVALID_ARGUMENTS,
309                  String.format("Thread count must be at least 1, got: %d", threadCount));
310            }
311            if (threadCount > 1) {
312              if (LOGGER.isWarnEnabled()) {
313                LOGGER.warn("Parallel constraint validation is an experimental feature. "
314                    + "Using {} threads.", threadCount);
315              }
316              configuration.set(ValidationFeature.PARALLEL_THREADS, threadCount);
317            }
318          }
319
320          // perform constraint validation
321          bindingContext.registerModule(module); // ensure the module is registered
322          IValidationResult constraintValidationResult = bindingContext.validateWithConstraints(source, configuration);
323          validationResult = validationResult == null
324              ? constraintValidationResult
325              : AggregateValidationResult.aggregate(validationResult, constraintValidationResult);
326        }
327      } catch (FileNotFoundException ex) {
328        throw new CommandExecutionException(
329            ExitCode.IO_ERROR,
330            String.format("Resource not found at '%s'", source),
331            ex);
332      } catch (UnknownHostException ex) {
333        throw new CommandExecutionException(
334            ExitCode.IO_ERROR,
335            String.format("Unknown host for '%s'.", source),
336            ex);
337      } catch (IOException ex) {
338        throw new CommandExecutionException(ExitCode.IO_ERROR, ex.getLocalizedMessage(), ex);
339      } catch (MetapathException | MetaschemaException | ConstraintValidationException ex) {
340        throw new CommandExecutionException(ExitCode.PROCESSING_ERROR, ex.getLocalizedMessage(), ex);
341      }
342      return validationResult;
343    }
344
345    private void handleOutput(
346        @NonNull URI source,
347        @Nullable IValidationResult validationResult,
348        @NonNull Format asFormat,
349        @NonNull CommandLine commandLine,
350        @NonNull IBindingContext bindingContext) throws CommandExecutionException {
351      if (commandLine.hasOption(SARIF_OUTPUT_FILE_OPTION)) {
352        Path sarifFile = ObjectUtils.notNull(Paths.get(commandLine.getOptionValue(SARIF_OUTPUT_FILE_OPTION)));
353
354        IVersionInfo version
355            = getCallingContext().getCLIProcessor().getVersionInfos().get(CLIProcessor.COMMAND_VERSION);
356
357        try {
358          SarifValidationHandler sarifHandler = new SarifValidationHandler(source, version);
359          if (validationResult != null) {
360            sarifHandler.addFindings(validationResult.getFindings());
361          }
362          sarifHandler.write(sarifFile, bindingContext);
363        } catch (IOException ex) {
364          throw new CommandExecutionException(ExitCode.IO_ERROR, ex.getLocalizedMessage(), ex);
365        }
366      } else if (validationResult != null && !validationResult.getFindings().isEmpty()) {
367        LOGGER.info("Validation identified the following issues:");
368        IPathFormatter pathFormatter = resolvePathFormatter(commandLine, asFormat);
369        LoggingValidationHandler.withPathFormatter(pathFormatter).handleResults(validationResult);
370      }
371
372    }
373
374    /**
375     * Resolve the path formatter based on command line option and document format.
376     *
377     * @param commandLine
378     *          the parsed command line
379     * @param asFormat
380     *          the document format
381     * @return the resolved path formatter
382     */
383    @NonNull
384    private IPathFormatter resolvePathFormatter(
385        @NonNull CommandLine commandLine,
386        @NonNull Format asFormat) {
387      PathFormatSelection selection = PathFormatSelection.AUTO;
388
389      if (commandLine.hasOption(PATH_FORMAT_OPTION)) {
390        String value = commandLine.getOptionValue(PATH_FORMAT_OPTION);
391        if (value != null) {
392          selection = parsePathFormatSelection(value);
393        }
394      }
395
396      return Format.resolvePathFormatter(selection, asFormat);
397    }
398
399    /**
400     * Parse the path format selection from a string value.
401     *
402     * @param value
403     *          the string value from the command line
404     * @return the parsed selection, defaults to AUTO if unrecognized
405     */
406    @NonNull
407    private PathFormatSelection parsePathFormatSelection(@NonNull String value) {
408      switch (value.toLowerCase(Locale.ROOT)) {
409      case "auto":
410        return PathFormatSelection.AUTO;
411      case "metapath":
412        return PathFormatSelection.METAPATH;
413      case "xpath":
414        return PathFormatSelection.XPATH;
415      case "jsonpointer":
416      case "json-pointer":
417        return PathFormatSelection.JSON_POINTER;
418      default:
419        LOGGER.warn("Unrecognized path format '{}', using auto", value);
420        return PathFormatSelection.AUTO;
421      }
422    }
423  }
424}