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