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}