1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package gov.nist.secauto.metaschema.cli.commands;
7   
8   import gov.nist.secauto.metaschema.cli.processor.CLIProcessor.CallingContext;
9   import gov.nist.secauto.metaschema.cli.processor.ExitCode;
10  import gov.nist.secauto.metaschema.cli.processor.ExitStatus;
11  import gov.nist.secauto.metaschema.cli.processor.InvalidArgumentException;
12  import gov.nist.secauto.metaschema.cli.processor.OptionUtils;
13  import gov.nist.secauto.metaschema.cli.processor.command.AbstractCommandExecutor;
14  import gov.nist.secauto.metaschema.cli.processor.command.AbstractTerminalCommand;
15  import gov.nist.secauto.metaschema.cli.processor.command.DefaultExtraArgument;
16  import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
17  import gov.nist.secauto.metaschema.core.util.CustomCollectors;
18  import gov.nist.secauto.metaschema.core.util.ObjectUtils;
19  import gov.nist.secauto.metaschema.core.util.UriUtils;
20  import gov.nist.secauto.metaschema.databind.IBindingContext;
21  import gov.nist.secauto.metaschema.databind.io.Format;
22  import gov.nist.secauto.metaschema.databind.io.IBoundLoader;
23  
24  import org.apache.commons.cli.CommandLine;
25  import org.apache.commons.cli.Option;
26  import org.apache.logging.log4j.LogManager;
27  import org.apache.logging.log4j.Logger;
28  
29  import java.io.FileNotFoundException;
30  import java.io.IOException;
31  import java.io.OutputStreamWriter;
32  import java.io.Writer;
33  import java.net.URI;
34  import java.net.URISyntaxException;
35  import java.nio.charset.StandardCharsets;
36  import java.nio.file.Files;
37  import java.nio.file.Path;
38  import java.nio.file.Paths;
39  import java.nio.file.StandardOpenOption;
40  import java.util.Collection;
41  import java.util.List;
42  import java.util.Locale;
43  
44  import edu.umd.cs.findbugs.annotations.NonNull;
45  
46  public abstract class AbstractConvertSubcommand
47      extends AbstractTerminalCommand {
48    private static final Logger LOGGER = LogManager.getLogger(AbstractConvertSubcommand.class);
49  
50    @NonNull
51    private static final String COMMAND = "convert";
52    @NonNull
53    private static final List<ExtraArgument> EXTRA_ARGUMENTS = ObjectUtils.notNull(List.of(
54        new DefaultExtraArgument("source-file-or-URL", true),
55        new DefaultExtraArgument("destination-file", false)));
56  
57    @NonNull
58    private static final Option OVERWRITE_OPTION = ObjectUtils.notNull(
59        Option.builder()
60            .longOpt("overwrite")
61            .desc("overwrite the destination if it exists")
62            .build());
63    @NonNull
64    private static final Option TO_OPTION = ObjectUtils.notNull(
65        Option.builder()
66            .longOpt("to")
67            .required()
68            .hasArg().argName("FORMAT")
69            .desc("convert to format: xml, json, or yaml")
70            .build());
71  
72    @Override
73    public String getName() {
74      return COMMAND;
75    }
76  
77    @Override
78    public Collection<? extends Option> gatherOptions() {
79      return ObjectUtils.notNull(List.of(
80          OVERWRITE_OPTION,
81          TO_OPTION));
82    }
83  
84    @Override
85    public List<ExtraArgument> getExtraArguments() {
86      return EXTRA_ARGUMENTS;
87    }
88  
89    @SuppressWarnings("PMD.PreserveStackTrace") // intended
90    @Override
91    public void validateOptions(CallingContext callingContext, CommandLine cmdLine) throws InvalidArgumentException {
92  
93      try {
94        String toFormatText = cmdLine.getOptionValue(TO_OPTION);
95        Format.valueOf(toFormatText.toUpperCase(Locale.ROOT));
96      } catch (IllegalArgumentException ex) {
97        InvalidArgumentException newEx = new InvalidArgumentException(
98            String.format("Invalid '%s' argument. The format must be one of: %s.",
99                OptionUtils.toArgument(TO_OPTION),
100               Format.names().stream()
101                   .collect(CustomCollectors.joiningWithOxfordComma("and"))));
102       newEx.setOption(TO_OPTION);
103       newEx.addSuppressed(ex);
104       throw newEx;
105     }
106 
107     List<String> extraArgs = cmdLine.getArgList();
108     if (extraArgs.isEmpty() || extraArgs.size() > 2) {
109       throw new InvalidArgumentException("Illegal number of arguments.");
110     }
111   }
112 
113   protected abstract static class AbstractConversionCommandExecutor
114       extends AbstractCommandExecutor {
115 
116     /**
117      * Construct a new command executor.
118      *
119      * @param callingContext
120      *          the context of the command execution
121      * @param commandLine
122      *          the parsed command line details
123      */
124     protected AbstractConversionCommandExecutor(
125         @NonNull CallingContext callingContext,
126         @NonNull CommandLine commandLine) {
127       super(callingContext, commandLine);
128     }
129 
130     /**
131      * Get the binding context to use for data processing.
132      *
133      * @return the context
134      */
135     @NonNull
136     protected abstract IBindingContext getBindingContext();
137 
138     @SuppressWarnings({
139         "PMD.OnlyOneReturn", // readability
140         "PMD.CyclomaticComplexity", "PMD.CognitiveComplexity" // reasonable
141     })
142     @Override
143     public ExitStatus execute() {
144       CommandLine cmdLine = getCommandLine();
145 
146       List<String> extraArgs = cmdLine.getArgList();
147 
148       Path destination = null;
149       if (extraArgs.size() > 1) {
150         destination = Paths.get(extraArgs.get(1)).toAbsolutePath();
151       }
152 
153       if (destination != null) {
154         if (Files.exists(destination)) {
155           if (!cmdLine.hasOption(OVERWRITE_OPTION)) {
156             return ExitCode.INVALID_ARGUMENTS.exitMessage(
157                 String.format("The provided destination '%s' already exists and the '%s' option was not provided.",
158                     destination,
159                     OptionUtils.toArgument(OVERWRITE_OPTION)));
160           }
161           if (!Files.isWritable(destination)) {
162             return ExitCode.IO_ERROR.exitMessage(
163                 "The provided destination '" + destination + "' is not writable.");
164           }
165         } else {
166           Path parent = destination.getParent();
167           if (parent != null) {
168             try {
169               Files.createDirectories(parent);
170             } catch (IOException ex) {
171               return ExitCode.INVALID_TARGET.exit().withThrowable(ex); // NOPMD readability
172             }
173           }
174         }
175       }
176 
177       String sourceName = ObjectUtils.notNull(extraArgs.get(0));
178       URI cwd = ObjectUtils.notNull(Paths.get("").toAbsolutePath().toUri());
179 
180       URI source;
181       try {
182         source = UriUtils.toUri(sourceName, cwd);
183       } catch (URISyntaxException ex) {
184         return ExitCode.IO_ERROR.exitMessage("Cannot load source '%s' as it is not a valid file or URI.")
185             .withThrowable(ex);
186       }
187       assert source != null;
188 
189       String toFormatText = cmdLine.getOptionValue(TO_OPTION);
190       Format toFormat = Format.valueOf(toFormatText.toUpperCase(Locale.ROOT));
191 
192       IBindingContext bindingContext = getBindingContext();
193       try {
194         IBoundLoader loader = bindingContext.newBoundLoader();
195         if (LOGGER.isInfoEnabled()) {
196           LOGGER.info("Converting '{}'.", source);
197         }
198 
199         if (destination == null) {
200           // write to STDOUT
201           OutputStreamWriter writer = new OutputStreamWriter(System.out, StandardCharsets.UTF_8);
202           handleConversion(source, toFormat, writer, loader);
203         } else {
204           try (Writer writer = Files.newBufferedWriter(
205               destination,
206               StandardCharsets.UTF_8,
207               StandardOpenOption.CREATE,
208               StandardOpenOption.WRITE,
209               StandardOpenOption.TRUNCATE_EXISTING)) {
210             assert writer != null;
211             handleConversion(source, toFormat, writer, loader);
212           }
213         }
214       } catch (IOException | IllegalArgumentException ex) {
215         return ExitCode.PROCESSING_ERROR.exit().withThrowable(ex); // NOPMD readability
216       }
217       if (destination != null && LOGGER.isInfoEnabled()) {
218         LOGGER.info("Generated {} file: {}", toFormat.toString(), destination);
219       }
220       return ExitCode.OK.exit();
221     }
222 
223     /**
224      * Called to perform a content conversion.
225      *
226      * @param source
227      *          the resource to convert
228      * @param toFormat
229      *          the format to convert to
230      * @param writer
231      *          the writer to use to write converted content
232      * @param loader
233      *          the Metaschema loader to use to load the content to convert
234      * @throws FileNotFoundException
235      *           if the requested resource was not found
236      * @throws IOException
237      *           if there was an error reading or writing content
238      */
239     protected abstract void handleConversion(
240         @NonNull URI source,
241         @NonNull Format toFormat,
242         @NonNull Writer writer,
243         @NonNull IBoundLoader loader) throws FileNotFoundException, IOException;
244   }
245 }