001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package gov.nist.secauto.metaschema.cli.commands;
007
008import gov.nist.secauto.metaschema.cli.commands.metapath.MetapathCommand;
009import gov.nist.secauto.metaschema.cli.processor.ExitCode;
010import gov.nist.secauto.metaschema.cli.processor.OptionUtils;
011import gov.nist.secauto.metaschema.cli.processor.command.CommandExecutionException;
012import gov.nist.secauto.metaschema.cli.processor.command.ICommand;
013import gov.nist.secauto.metaschema.core.metapath.MetapathException;
014import gov.nist.secauto.metaschema.core.model.IConstraintLoader;
015import gov.nist.secauto.metaschema.core.model.IModule;
016import gov.nist.secauto.metaschema.core.model.MetaschemaException;
017import gov.nist.secauto.metaschema.core.model.constraint.IConstraintSet;
018import gov.nist.secauto.metaschema.core.util.CollectionUtil;
019import gov.nist.secauto.metaschema.core.util.CustomCollectors;
020import gov.nist.secauto.metaschema.core.util.DeleteOnShutdown;
021import gov.nist.secauto.metaschema.core.util.ObjectUtils;
022import gov.nist.secauto.metaschema.core.util.UriUtils;
023import gov.nist.secauto.metaschema.databind.IBindingContext;
024import gov.nist.secauto.metaschema.databind.io.Format;
025import gov.nist.secauto.metaschema.databind.io.IBoundLoader;
026import gov.nist.secauto.metaschema.databind.model.metaschema.IBindingModuleLoader;
027import gov.nist.secauto.metaschema.schemagen.ISchemaGenerator.SchemaFormat;
028
029import org.apache.commons.cli.CommandLine;
030import org.apache.commons.cli.Option;
031
032import java.io.FileNotFoundException;
033import java.io.IOException;
034import java.net.URI;
035import java.net.URISyntaxException;
036import java.nio.file.Files;
037import java.nio.file.Path;
038import java.nio.file.Paths;
039import java.util.Arrays;
040import java.util.LinkedHashSet;
041import java.util.List;
042import java.util.Locale;
043import java.util.Set;
044
045import edu.umd.cs.findbugs.annotations.NonNull;
046
047/**
048 * This class provides a variety of utility methods for processing
049 * Metaschema-related commands.
050 * <p>
051 * These methods handle the errors produced using the
052 * {@link CommandExecutionException}, which will return an exceptional result to
053 * the command line interface (CLI) processor. This approach keeps the command
054 * implementations fairly clean and simple.
055 */
056@SuppressWarnings("PMD.GodClass")
057public final class MetaschemaCommands {
058  /**
059   * A list of the Metaschema-related command pathways, for reuse in this and
060   * other CLI applications.
061   */
062  @NonNull
063  public static final List<ICommand> COMMANDS = ObjectUtils.notNull(List.of(
064      new ValidateModuleCommand(),
065      new GenerateSchemaCommand(),
066      new GenerateDiagramCommand(),
067      new ValidateContentUsingModuleCommand(),
068      new ConvertContentUsingModuleCommand(),
069      new MetapathCommand()));
070
071  /**
072   * Used by commands to declare a required Metaschema module for processing.
073   *
074   * @since 2.0.0
075   */
076  @NonNull
077  public static final Option METASCHEMA_REQUIRED_OPTION = ObjectUtils.notNull(
078      Option.builder("m")
079          .hasArg()
080          .argName("FILE_OR_URL")
081          .required()
082          .desc("metaschema resource")
083          .numberOfArgs(1)
084          .build());
085  /**
086   * Used by commands to declare an optional Metaschema module for processing.
087   *
088   * @since 2.0.0
089   */
090  @NonNull
091  public static final Option METASCHEMA_OPTIONAL_OPTION = ObjectUtils.notNull(
092      Option.builder("m")
093          .hasArg()
094          .argName("FILE_OR_URL")
095          .desc("metaschema resource")
096          .numberOfArgs(1)
097          .build());
098  /**
099   * Used by commands to protect existing files from being overwritten, unless
100   * this option is provided.
101   */
102  @NonNull
103  public static final Option OVERWRITE_OPTION = ObjectUtils.notNull(
104      Option.builder()
105          .longOpt("overwrite")
106          .desc("overwrite the destination if it exists")
107          .build());
108  /**
109   * Used by commands to identify the target format for a content conversion
110   * operation.
111   *
112   * @since 2.0.0
113   */
114  @NonNull
115  public static final Option TO_OPTION = ObjectUtils.notNull(
116      Option.builder()
117          .longOpt("to")
118          .required()
119          .hasArg().argName("FORMAT")
120          .desc("convert to format: " + Arrays.stream(Format.values())
121              .map(Enum::name)
122              .collect(CustomCollectors.joiningWithOxfordComma("or")))
123          .numberOfArgs(1)
124          .build());
125  /**
126   * Used by commands to identify the source format for a content-related
127   * operation.
128   *
129   * @since 2.0.0
130   */
131  @NonNull
132  public static final Option AS_FORMAT_OPTION = ObjectUtils.notNull(
133      Option.builder()
134          .longOpt("as")
135          .hasArg()
136          .argName("FORMAT")
137          .desc("source format: " + Arrays.stream(Format.values())
138              .map(Enum::name)
139              .collect(CustomCollectors.joiningWithOxfordComma("or")))
140          .numberOfArgs(1)
141          .build());
142  /**
143   * Used by commands that produce schemas to identify the schema format to
144   * produce.
145   *
146   * @since 2.0.0
147   */
148  @NonNull
149  public static final Option AS_SCHEMA_FORMAT_OPTION = ObjectUtils.notNull(
150      Option.builder()
151          .longOpt("as")
152          .required()
153          .hasArg()
154          .argName("FORMAT")
155          .desc("schema format: " + Arrays.stream(SchemaFormat.values())
156              .map(Enum::name)
157              .collect(CustomCollectors.joiningWithOxfordComma("or")))
158          .numberOfArgs(1)
159          .build());
160
161  /**
162   * Get the provided source path or URI string as an absolute {@link URI} for the
163   * resource.
164   *
165   * @param pathOrUri
166   *          the resource
167   * @param currentWorkingDirectory
168   *          the current working directory the URI will be resolved against to
169   *          ensure it is absolute
170   * @return the absolute URI for the resource
171   * @throws CommandExecutionException
172   *           if the resulting URI is not a well-formed URI
173   * @since 2.0.0
174   */
175  @NonNull
176  public static URI handleSource(
177      @NonNull String pathOrUri,
178      @NonNull URI currentWorkingDirectory) throws CommandExecutionException {
179    try {
180      return getResourceUri(pathOrUri, currentWorkingDirectory);
181    } catch (URISyntaxException ex) {
182      throw new CommandExecutionException(
183          ExitCode.INVALID_ARGUMENTS,
184          String.format(
185              "Cannot load source '%s' as it is not a valid file or URI.",
186              pathOrUri),
187          ex);
188    }
189  }
190
191  /**
192   * Get the provided destination path as an absolute {@link Path} for the
193   * resource.
194   * <p>
195   * This method checks if the path exists and if so, if the overwrite option is
196   * set. The method also ensures that the parent directory is created, if it
197   * doesn't already exist.
198   *
199   * @param path
200   *          the resource
201   * @param commandLine
202   *          the provided command line argument information
203   * @return the absolute URI for the resource
204   * @throws CommandExecutionException
205   *           if the path exists and cannot be overwritten or is not writable
206   * @since 2.0.0
207   */
208  public static Path handleDestination(
209      @NonNull String path,
210      @NonNull CommandLine commandLine) throws CommandExecutionException {
211    Path retval = Paths.get(path).toAbsolutePath();
212
213    if (Files.exists(retval)) {
214      if (!commandLine.hasOption(OVERWRITE_OPTION)) {
215        throw new CommandExecutionException(
216            ExitCode.INVALID_ARGUMENTS,
217            String.format("The provided destination '%s' already exists and the '%s' option was not provided.",
218                retval,
219                OptionUtils.toArgument(OVERWRITE_OPTION)));
220      }
221      if (!Files.isWritable(retval)) {
222        throw new CommandExecutionException(
223            ExitCode.IO_ERROR,
224            String.format(
225                "The provided destination '%s' is not writable.", retval));
226      }
227    } else {
228      Path parent = retval.getParent();
229      if (parent != null) {
230        try {
231          Files.createDirectories(parent);
232        } catch (IOException ex) {
233          throw new CommandExecutionException(
234              ExitCode.INVALID_TARGET,
235              ex);
236        }
237      }
238    }
239    return retval;
240  }
241
242  /**
243   * Parse the command line options to get the selected format.
244   *
245   * @param commandLine
246   *          the provided command line argument information
247   * @param option
248   *          the option specifying the format, which must be present on the
249   *          command line
250   * @return the format
251   * @throws CommandExecutionException
252   *           if the format option was not provided or was an invalid choice
253   * @since 2.0.0
254   */
255  @SuppressWarnings("PMD.PreserveStackTrace")
256  @NonNull
257  public static Format getFormat(
258      @NonNull CommandLine commandLine,
259      @NonNull Option option) throws CommandExecutionException {
260    // use the option
261    String toFormatText = commandLine.getOptionValue(option);
262    if (toFormatText == null) {
263      throw new CommandExecutionException(
264          ExitCode.INVALID_ARGUMENTS,
265          String.format("The '%s' argument was not provided.",
266              option.hasLongOpt()
267                  ? "--" + option.getLongOpt()
268                  : "-" + option.getOpt()));
269    }
270    try {
271      return Format.valueOf(toFormatText.toUpperCase(Locale.ROOT));
272    } catch (IllegalArgumentException ex) {
273      throw new CommandExecutionException(
274          ExitCode.INVALID_ARGUMENTS,
275          String.format("Invalid '%s' argument. The format must be one of: %s.",
276              option.hasLongOpt()
277                  ? "--" + option.getLongOpt()
278                  : "-" + option.getOpt(),
279              Arrays.stream(Format.values())
280                  .map(Enum::name)
281                  .collect(CustomCollectors.joiningWithOxfordComma("or"))));
282    }
283  }
284
285  /**
286   * Parse the command line options to get the selected schema format.
287   *
288   * @param commandLine
289   *          the provided command line argument information
290   * @param option
291   *          the option specifying the format, which must be present on the
292   *          command line
293   * @return the format
294   * @throws CommandExecutionException
295   *           if the format option was not provided or was an invalid choice
296   * @since 2.0.0
297   */
298  @SuppressWarnings("PMD.PreserveStackTrace")
299  @NonNull
300  public static SchemaFormat getSchemaFormat(
301      @NonNull CommandLine commandLine,
302      @NonNull Option option) throws CommandExecutionException {
303    // use the option
304    String toFormatText = commandLine.getOptionValue(option);
305    if (toFormatText == null) {
306      throw new CommandExecutionException(
307          ExitCode.INVALID_ARGUMENTS,
308          String.format("Option '%s' not provided.",
309              option.hasLongOpt()
310                  ? "--" + option.getLongOpt()
311                  : "-" + option.getOpt()));
312    }
313    try {
314      return SchemaFormat.valueOf(toFormatText.toUpperCase(Locale.ROOT));
315    } catch (IllegalArgumentException ex) {
316      throw new CommandExecutionException(
317          ExitCode.INVALID_ARGUMENTS,
318          String.format("Invalid '%s' argument. The schema format must be one of: %s.",
319              option.hasLongOpt()
320                  ? "--" + option.getLongOpt()
321                  : "-" + option.getOpt(),
322              Arrays.stream(SchemaFormat.values())
323                  .map(Enum::name)
324                  .collect(CustomCollectors.joiningWithOxfordComma("or"))),
325          ex);
326    }
327  }
328
329  /**
330   * Detect the source format for content identified using the provided option.
331   * <p>
332   * This method will first check if the source format is explicitly declared on
333   * the command line. If so, this format will be returned.
334   * <p>
335   * If not, then the content will be analyzed to determine the format.
336   *
337   * @param commandLine
338   *          the provided command line argument information
339   * @param option
340   *          the option specifying the format, which must be present on the
341   *          command line
342   * @param loader
343   *          the content loader to use to load the content instance
344   * @param resource
345   *          the resource to load
346   * @return the identified content format
347   * @throws CommandExecutionException
348   *           if an error occurred while determining the source format
349   * @since 2.0.0
350   */
351  @SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.OnlyOneReturn" })
352  @NonNull
353  public static Format determineSourceFormat(
354      @NonNull CommandLine commandLine,
355      @NonNull Option option,
356      @NonNull IBoundLoader loader,
357      @NonNull URI resource) throws CommandExecutionException {
358    if (commandLine.hasOption(option)) {
359      // use the option
360      return getFormat(commandLine, option);
361    }
362
363    // attempt to determine the format
364    try {
365      return loader.detectFormat(resource);
366    } catch (FileNotFoundException ex) {
367      // this case was already checked for
368      throw new CommandExecutionException(
369          ExitCode.IO_ERROR,
370          String.format("The provided source '%s' does not exist.", resource),
371          ex);
372    } catch (IOException ex) {
373      throw new CommandExecutionException(
374          ExitCode.IO_ERROR,
375          String.format("Unable to determine source format. Use '%s' to specify the format. %s",
376              option.hasLongOpt()
377                  ? "--" + option.getLongOpt()
378                  : "-" + option.getOpt(),
379              ex.getLocalizedMessage()),
380          ex);
381    }
382  }
383
384  /**
385   * Load a Metaschema module based on the provided command line option.
386   *
387   * @param commandLine
388   *          the provided command line argument information
389   * @param option
390   *          the option specifying the module to load, which must be present on
391   *          the command line
392   * @param currentWorkingDirectory
393   *          the URI of the current working directory
394   * @param bindingContext
395   *          the context used to access Metaschema module information based on
396   *          Java class bindings
397   * @return the loaded module
398   * @throws CommandExecutionException
399   *           if an error occurred while loading the module
400   * @since 2.0.0
401   */
402  @NonNull
403  public static IModule loadModule(
404      @NonNull CommandLine commandLine,
405      @NonNull Option option,
406      @NonNull URI currentWorkingDirectory,
407      @NonNull IBindingContext bindingContext) throws CommandExecutionException {
408    String moduleName = commandLine.getOptionValue(option);
409    if (moduleName == null) {
410      throw new CommandExecutionException(
411          ExitCode.INVALID_ARGUMENTS,
412          String.format("Unable to determine the module to load. Use '%s' to specify the module.",
413              option.hasLongOpt()
414                  ? "--" + option.getLongOpt()
415                  : "-" + option.getOpt()));
416    }
417
418    URI moduleUri;
419    try {
420      moduleUri = UriUtils.toUri(moduleName, currentWorkingDirectory);
421    } catch (URISyntaxException ex) {
422      throw new CommandExecutionException(
423          ExitCode.INVALID_ARGUMENTS,
424          String.format("Cannot load module as '%s' is not a valid file or URL. %s",
425              ex.getInput(),
426              ex.getLocalizedMessage()),
427          ex);
428    }
429    return loadModule(moduleUri, bindingContext);
430  }
431
432  /**
433   * Load a Metaschema module from the provided relative resource path.
434   * <p>
435   * This method will resolve the provided resource against the current working
436   * directory to create an absolute URI.
437   *
438   * @param moduleResource
439   *          the relative path to the module resource to load
440   * @param currentWorkingDirectory
441   *          the URI of the current working directory
442   * @param bindingContext
443   *          the context used to access Metaschema module information based on
444   *          Java class bindings
445   * @return the loaded module
446   * @throws CommandExecutionException
447   *           if an error occurred while loading the module
448   * @since 2.0.0
449   */
450  @NonNull
451  public static IModule loadModule(
452      @NonNull String moduleResource,
453      @NonNull URI currentWorkingDirectory,
454      @NonNull IBindingContext bindingContext) throws CommandExecutionException {
455    try {
456      URI moduleUri = getResourceUri(
457          moduleResource,
458          currentWorkingDirectory);
459      return loadModule(moduleUri, bindingContext);
460    } catch (URISyntaxException ex) {
461      throw new CommandExecutionException(
462          ExitCode.INVALID_ARGUMENTS,
463          String.format("Cannot load module as '%s' is not a valid file or URL. %s",
464              ex.getInput(),
465              ex.getLocalizedMessage()),
466          ex);
467    }
468  }
469
470  /**
471   * Load a Metaschema module from the provided resource path.
472   *
473   * @param moduleResource
474   *          the absolute path to the module resource to load
475   * @param bindingContext
476   *          the context used to access Metaschema module information based on
477   *          Java class bindings
478   * @return the loaded module
479   * @throws CommandExecutionException
480   *           if an error occurred while loading the module
481   * @since 2.0.0
482   */
483  @NonNull
484  public static IModule loadModule(
485      @NonNull URI moduleResource,
486      @NonNull IBindingContext bindingContext) throws CommandExecutionException {
487    // TODO: ensure the resource URI is absolute
488    try {
489      IBindingModuleLoader loader = bindingContext.newModuleLoader();
490      loader.allowEntityResolution();
491      return loader.load(moduleResource);
492    } catch (IOException | MetaschemaException ex) {
493      throw new CommandExecutionException(ExitCode.PROCESSING_ERROR, ex);
494    }
495  }
496
497  /**
498   * For a given resource location, resolve the location into an absolute URI.
499   *
500   * @param location
501   *          the resource location
502   * @param currentWorkingDirectory
503   *          the URI of the current working directory
504   * @return the resolved URI
505   * @throws URISyntaxException
506   *           if the location is not a valid URI
507   */
508  @NonNull
509  public static URI getResourceUri(
510      @NonNull String location,
511      @NonNull URI currentWorkingDirectory) throws URISyntaxException {
512    return UriUtils.toUri(location, currentWorkingDirectory);
513  }
514
515  /**
516   * Load a set of external Metaschema module constraints based on the provided
517   * command line option.
518   *
519   * @param commandLine
520   *          the provided command line argument information
521   * @param option
522   *          the option specifying the constraints to load, which must be present
523   *          on the command line
524   * @param currentWorkingDirectory
525   *          the URI of the current working directory
526   * @return the set of loaded constraints
527   * @throws CommandExecutionException
528   *           if an error occurred while loading the module
529   * @since 2.0.0
530   */
531  @NonNull
532  public static Set<IConstraintSet> loadConstraintSets(
533      @NonNull CommandLine commandLine,
534      @NonNull Option option,
535      @NonNull URI currentWorkingDirectory) throws CommandExecutionException {
536    Set<IConstraintSet> constraintSets;
537    if (commandLine.hasOption(option)) {
538      IConstraintLoader constraintLoader = IBindingContext.getConstraintLoader();
539      constraintSets = new LinkedHashSet<>();
540      String[] args = commandLine.getOptionValues(option);
541      for (String arg : args) {
542        assert arg != null;
543        try {
544          URI constraintUri = ObjectUtils.requireNonNull(UriUtils.toUri(arg, currentWorkingDirectory));
545          constraintSets.addAll(constraintLoader.load(constraintUri));
546        } catch (URISyntaxException | IOException | MetaschemaException | MetapathException ex) {
547          throw new CommandExecutionException(
548              ExitCode.IO_ERROR,
549              String.format("Unable to process constraint set '%s'. %s",
550                  arg,
551                  ex.getLocalizedMessage()),
552              ex);
553        }
554      }
555    } else {
556      constraintSets = CollectionUtil.emptySet();
557    }
558    return constraintSets;
559  }
560
561  /**
562   * Create a temporary directory for ephemeral files that will be deleted on
563   * shutdown.
564   *
565   * @return the temp directory path
566   * @throws IOException
567   *           if an error occurred while creating the temporary directory
568   */
569  @NonNull
570  public static Path newTempDir() throws IOException {
571    Path retval = Files.createTempDirectory("metaschema-cli-");
572    DeleteOnShutdown.register(retval);
573    return ObjectUtils.notNull(retval);
574  }
575
576  /**
577   * Create a new {@link IBindingContext} that is configured for dynamic
578   * compilation.
579   *
580   * @return the binding context
581   * @throws CommandExecutionException
582   *           if an error occurred while creating the binding context
583   * @since 2.0.0
584   */
585  @NonNull
586  public static IBindingContext newBindingContextWithDynamicCompilation() throws CommandExecutionException {
587    return newBindingContextWithDynamicCompilation(CollectionUtil.emptySet());
588  }
589
590  /**
591   * Create a new {@link IBindingContext} that is configured for dynamic
592   * compilation and to use the provided constraints.
593   *
594   * @param constraintSets
595   *          the Metaschema module constraints to dynamicly bind to loaded
596   *          modules
597   * @return the binding context
598   * @throws CommandExecutionException
599   *           if an error occurred while creating the binding context
600   * @since 2.0.0
601   */
602  @NonNull
603  public static IBindingContext newBindingContextWithDynamicCompilation(@NonNull Set<IConstraintSet> constraintSets)
604      throws CommandExecutionException {
605    try {
606      Path tempDir = newTempDir();
607      return IBindingContext.builder()
608          .compilePath(tempDir)
609          .constraintSet(constraintSets)
610          .build();
611    } catch (IOException ex) {
612      throw new CommandExecutionException(ExitCode.RUNTIME_ERROR,
613          String.format("Unable to initialize the binding context. %s", ex.getLocalizedMessage()),
614          ex);
615    }
616  }
617
618  private MetaschemaCommands() {
619    // disable construction
620  }
621}