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.processor.CLIProcessor.CallingContext;
009import gov.nist.secauto.metaschema.cli.processor.ExitCode;
010import gov.nist.secauto.metaschema.cli.processor.ExitStatus;
011import gov.nist.secauto.metaschema.cli.processor.InvalidArgumentException;
012import gov.nist.secauto.metaschema.cli.processor.OptionUtils;
013import gov.nist.secauto.metaschema.cli.processor.command.AbstractCommandExecutor;
014import gov.nist.secauto.metaschema.cli.processor.command.AbstractTerminalCommand;
015import gov.nist.secauto.metaschema.cli.processor.command.DefaultExtraArgument;
016import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
017import gov.nist.secauto.metaschema.core.util.CustomCollectors;
018import gov.nist.secauto.metaschema.core.util.ObjectUtils;
019import gov.nist.secauto.metaschema.core.util.UriUtils;
020import gov.nist.secauto.metaschema.databind.IBindingContext;
021import gov.nist.secauto.metaschema.databind.io.Format;
022import gov.nist.secauto.metaschema.databind.io.IBoundLoader;
023
024import org.apache.commons.cli.CommandLine;
025import org.apache.commons.cli.Option;
026import org.apache.logging.log4j.LogManager;
027import org.apache.logging.log4j.Logger;
028
029import java.io.FileNotFoundException;
030import java.io.IOException;
031import java.io.OutputStreamWriter;
032import java.io.Writer;
033import java.net.URI;
034import java.net.URISyntaxException;
035import java.nio.charset.StandardCharsets;
036import java.nio.file.Files;
037import java.nio.file.Path;
038import java.nio.file.Paths;
039import java.nio.file.StandardOpenOption;
040import java.util.Collection;
041import java.util.List;
042import java.util.Locale;
043
044import edu.umd.cs.findbugs.annotations.NonNull;
045
046public abstract class AbstractConvertSubcommand
047    extends AbstractTerminalCommand {
048  private static final Logger LOGGER = LogManager.getLogger(AbstractConvertSubcommand.class);
049
050  @NonNull
051  private static final String COMMAND = "convert";
052  @NonNull
053  private static final List<ExtraArgument> EXTRA_ARGUMENTS = ObjectUtils.notNull(List.of(
054      new DefaultExtraArgument("source-file-or-URL", true),
055      new DefaultExtraArgument("destination-file", false)));
056
057  @NonNull
058  private static final Option OVERWRITE_OPTION = ObjectUtils.notNull(
059      Option.builder()
060          .longOpt("overwrite")
061          .desc("overwrite the destination if it exists")
062          .build());
063  @NonNull
064  private static final Option TO_OPTION = ObjectUtils.notNull(
065      Option.builder()
066          .longOpt("to")
067          .required()
068          .hasArg().argName("FORMAT")
069          .desc("convert to format: xml, json, or yaml")
070          .build());
071
072  @Override
073  public String getName() {
074    return COMMAND;
075  }
076
077  @Override
078  public Collection<? extends Option> gatherOptions() {
079    return ObjectUtils.notNull(List.of(
080        OVERWRITE_OPTION,
081        TO_OPTION));
082  }
083
084  @Override
085  public List<ExtraArgument> getExtraArguments() {
086    return EXTRA_ARGUMENTS;
087  }
088
089  @SuppressWarnings("PMD.PreserveStackTrace") // intended
090  @Override
091  public void validateOptions(CallingContext callingContext, CommandLine cmdLine) throws InvalidArgumentException {
092
093    try {
094      String toFormatText = cmdLine.getOptionValue(TO_OPTION);
095      Format.valueOf(toFormatText.toUpperCase(Locale.ROOT));
096    } catch (IllegalArgumentException ex) {
097      InvalidArgumentException newEx = new InvalidArgumentException(
098          String.format("Invalid '%s' argument. The format must be one of: %s.",
099              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}