application.ExamplesCommand.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of specmatic-executable Show documentation
Show all versions of specmatic-executable Show documentation
Command-line standalone executable jar for Specmatic
package application
import io.specmatic.core.Feature
import io.specmatic.core.Result
import io.specmatic.core.Results
import io.specmatic.core.SPECMATIC_STUB_DICTIONARY
import io.specmatic.core.examples.server.ExamplesInteractiveServer
import io.specmatic.core.examples.server.ExamplesInteractiveServer.Companion.externaliseInlineExamples
import io.specmatic.core.examples.server.ExamplesInteractiveServer.Companion.validateSingleExample
import io.specmatic.core.examples.server.defaultExternalExampleDirFrom
import io.specmatic.core.examples.server.loadExternalExamples
import io.specmatic.core.log.*
import io.specmatic.core.parseContractFileToFeature
import io.specmatic.core.pattern.ContractException
import io.specmatic.core.utilities.*
import io.specmatic.mock.ScenarioStub
import picocli.CommandLine.*
import java.io.File
import java.lang.Thread.sleep
import java.util.concurrent.Callable
import kotlin.system.exitProcess
private const val SUCCESS_EXIT_CODE = 0
private const val FAILURE_EXIT_CODE = 1
@Command(
name = "examples",
mixinStandardHelpOptions = true,
description = ["Generate externalised JSON example files with API requests and responses"],
subcommands = [
ExamplesCommand.Validate::class,
ExamplesCommand.Interactive::class,
ExamplesCommand.Transform::class,
ExamplesCommand.Export::class
]
)
class ExamplesCommand : Callable {
@Option(
names = ["--filter-name"],
description = ["Use only APIs with this value in their name"],
defaultValue = "\${env:SPECMATIC_FILTER_NAME}"
)
var filterName: String = ""
@Option(
names = ["--filter-not-name"],
description = ["Use only APIs which do not have this value in their name"],
defaultValue = "\${env:SPECMATIC_FILTER_NOT_NAME}"
)
var filterNotName: String = ""
@Option(
names = ["--extensive"],
description = ["Generate all examples (by default, generates one example per 2xx API)"],
defaultValue = "false"
)
var extensive: Boolean = false
@Parameters(index = "0", description = ["Contract file path"], arity = "0..1")
var contractFile: File? = null
@Option(names = ["--debug"], description = ["Debug logs"])
var verbose = false
@Option(names = ["--dictionary"], description = ["External Dictionary File Path, defaults to dictionary.json"])
var dictionaryFile: File? = null
@Option(
names= ["--filter"],
description = [
"""
Filter tests matching the specified filtering criteria
You can filter tests based on the following keys:
- `METHOD`: HTTP methods (e.g., GET, POST)
- `PATH`: Request paths (e.g., /users, /product)
- `STATUS`: HTTP response status codes (e.g., 200, 400)
- `HEADERS`: Request headers (e.g., Accept, X-Request-ID)
- `QUERY-PARAM`: Query parameters (e.g., status, productId)
- `EXAMPLE-NAME`: Example name (e.g., create-product, active-status)
To specify multiple values for the same filter, separate them with commas.
For example, to filter by HTTP methods:
--filter="METHOD=GET,POST"
You can supply multiple filters as well.
For example:
--filter="METHOD=GET,POST" --filter="PATH=/users"
"""
],
required = false
)
var filter: List = emptyList()
@Option(
names= ["--filter-not"],
description = [
"""
Filter tests not matching the specified criteria
This option supports the same filtering keys and syntax as the --filter option.
For example:
--filterNot="STATUS=400" --filterNot="METHOD=PATCH,PUT"
"""
],
required = false
)
var filterNot: List = emptyList()
override fun call(): Int {
if (contractFile == null) {
println("No contract file provided. Use a subcommand or provide a contract file. Use --help for more details.")
return FAILURE_EXIT_CODE
}
if (!contractFile!!.exists()) {
logger.log("Could not find file ${contractFile!!.path}")
return FAILURE_EXIT_CODE
}
configureLogger(this.verbose)
try {
dictionaryFile?.also {
System.setProperty(SPECMATIC_STUB_DICTIONARY, it.path)
}
ExamplesInteractiveServer.generate(
contractFile!!,
ExamplesInteractiveServer.ScenarioFilter(filterName, filterNotName, filter, filterNot),
extensive,
)
} catch (e: Throwable) {
logger.log(e)
return FAILURE_EXIT_CODE
}
return SUCCESS_EXIT_CODE
}
@Command(
name = "validate",
mixinStandardHelpOptions = true,
description = ["Validate the examples"]
)
class Validate : Callable {
@Option(
names= ["--filter"],
description = [
"""
Filter tests matching the specified filtering criteria
You can filter tests based on the following keys:
- `METHOD`: HTTP methods (e.g., GET, POST)
- `PATH`: Request paths (e.g., /users, /product)
- `STATUS`: HTTP response status codes (e.g., 200, 400)
- `HEADERS`: Request headers (e.g., Accept, X-Request-ID)
- `QUERY-PARAM`: Query parameters (e.g., status, productId)
- `EXAMPLE-NAME`: Example name (e.g., create-product, active-status)
To specify multiple values for the same filter, separate them with commas.
For example, to filter by HTTP methods:
--filter="METHOD=GET,POST"
You can supply multiple filters as well.
For example:
--filter="METHOD=GET,POST" --filter="PATH=/users"
"""
],
required = false
)
var filter: List = emptyList()
@Option(
names= ["--filter-not"],
description = [
"""
Filter tests not matching the specified criteria
This option supports the same filtering keys and syntax as the --filter option.
For example:
--filterNot="STATUS=400" --filterNot="METHOD=PATCH,PUT"
"""
],
required = false
)
var filterNot: List = emptyList()
@Option(names = ["--contract-file", "--spec-file"], description = ["Contract file path"], required = false)
var contractFile: File? = null
@Option(names = ["--example-file"], description = ["Example file path"], required = false)
val exampleFile: File? = null
@Option(names = ["--examples-dir"], description = ["External examples directory path for a single API specification (If you are not following the default naming convention for external examples directory)"], required = false)
val examplesDir: File? = null
@Option(names = ["--specs-dir"], description = ["Directory with the API specification files"], required = false)
val specsDir: File? = null
@Option(
names = ["--examples-base-dir"],
description = ["Base directory which contains multiple external examples directories each named as per the Specmatic naming convention to associate them with the corresponding API specification"],
required = false
)
val examplesBaseDir: File? = null
@Option(names = ["--debug"], description = ["Debug logs"])
var verbose = false
@Option(
names = ["--filter-name"],
description = ["Validate examples of only APIs with this value in their name"],
defaultValue = "\${env:SPECMATIC_FILTER_NAME}"
)
var filterName: String = ""
@Option(
names = ["--filter-not-name"],
description = ["Validate examples of only APIs which do not have this value in their name"],
defaultValue = "\${env:SPECMATIC_FILTER_NOT_NAME}"
)
var filterNotName: String = ""
override fun call(): Int {
if (contractFile != null && exampleFile != null) return validateExampleFile(contractFile!!, exampleFile)
if (contractFile != null && examplesDir != null) {
val (exitCode, validationResults) = validateExamplesDir(contractFile!!, examplesDir)
printValidationResult(validationResults, "Example directory")
if (exitCode == 1) return FAILURE_EXIT_CODE
if (validationResults.containsFailure()) return FAILURE_EXIT_CODE
return SUCCESS_EXIT_CODE
}
if (contractFile != null) return validateImplicitExamplesFrom(contractFile!!)
if (specsDir != null && examplesBaseDir != null) {
val exitCode = validateAllExamplesAssociatedToEachSpecIn(specsDir, examplesBaseDir)
return exitCode
}
if (specsDir != null) {
val exitCode = validateAllExamplesAssociatedToEachSpecIn(specsDir, specsDir)
return exitCode
}
logger.log("Invalid combination of CLI options. Please refer to the help section using --help command to understand how to use this command")
return FAILURE_EXIT_CODE
}
private fun validateExampleFile(contractFile: File, exampleFile: File): Int {
if (!contractFile.exists()) {
logger.log("Could not find file ${contractFile.path}")
return FAILURE_EXIT_CODE
}
configureLogger(this.verbose)
try {
validateSingleExample(contractFile, exampleFile).throwOnFailure()
logger.log("The provided example ${exampleFile.name} is valid.")
return SUCCESS_EXIT_CODE
} catch (e: ContractException) {
logger.log("The provided example ${exampleFile.name} is invalid. Reason:\n")
logger.log(exceptionCauseMessage(e))
return FAILURE_EXIT_CODE
}
}
private fun validateExamplesDir(contractFile: File, examplesDir: File, enableLogging: Boolean = true): Pair> {
val feature = parseContractFileToFeature(contractFile)
val (externalExampleDir, externalExamples) = loadExternalExamples(examplesDir = examplesDir)
if (!externalExampleDir.exists()) {
logger.log("$externalExampleDir does not exist, did not find any files to validate")
return FAILURE_EXIT_CODE to emptyMap()
}
if (externalExamples.none()) {
logger.log("No example files found in $externalExampleDir")
return FAILURE_EXIT_CODE to emptyMap()
}
return SUCCESS_EXIT_CODE to validateExternalExamples(feature, externalExamples, enableLogging)
}
private fun validateAllExamplesAssociatedToEachSpecIn(
specsDir: File,
examplesBaseDir: File
): Int {
val validationResults = specsDir.walk().filter { it.isFile }.flatMapIndexed { index, it ->
val associatedExamplesDir = examplesBaseDir.associatedExampleDirFor(it) ?: return@flatMapIndexed emptyList()
logger.log("${index.inc()}. Validating examples in ${associatedExamplesDir.name} associated to ${it.name}...${System.lineSeparator()}")
val results = validateExamplesDir(it, associatedExamplesDir, false).second.entries.map { entry ->
entry.toPair()
}
printValidationResult(results.toMap(), "The ${associatedExamplesDir.name} Directory")
logger.log(System.lineSeparator())
results
}.toMap()
logger.log("Summary:")
printValidationResult(validationResults, "Overall")
if (validationResults.containsFailure()) return FAILURE_EXIT_CODE
return SUCCESS_EXIT_CODE
}
private fun validateImplicitExamplesFrom(contractFile: File): Int {
val feature = parseContractFileToFeature(contractFile)
val (validateInline, validateExternal) = getValidateInlineAndValidateExternalFlags()
val inlineExampleValidationResults = if (!validateInline) emptyMap()
else validateInlineExamples(feature)
val externalExampleValidationResults = if (!validateExternal) emptyMap()
else {
val (exitCode, validationResults)
= validateExamplesDir(contractFile, defaultExternalExampleDirFrom(contractFile))
if(exitCode == 1) exitProcess(1)
validationResults
}
val hasFailures =
inlineExampleValidationResults.containsFailure() || externalExampleValidationResults.containsFailure()
printValidationResult(inlineExampleValidationResults, "Inline example")
printValidationResult(externalExampleValidationResults, "Example file")
if (hasFailures) return FAILURE_EXIT_CODE
return SUCCESS_EXIT_CODE
}
private fun validateInlineExamples(feature: Feature): Map {
return ExamplesInteractiveServer.validateExamples(
feature,
examples = feature.stubsFromExamples.mapValues { (_, stub) ->
stub.map { (request, response) ->
ScenarioStub(request, response)
}
},
inline = true,
scenarioFilter = ExamplesInteractiveServer.ScenarioFilter(
filterName,
filterNotName,
filter,
filterNot
)
)
}
private fun validateExternalExamples(
feature: Feature,
externalExamples: List,
enableLogging: Boolean = true
): Map {
return ExamplesInteractiveServer.validateExamples(
feature,
examples = externalExamples,
scenarioFilter = ExamplesInteractiveServer.ScenarioFilter(
filterName,
filterNotName,
filter,
filterNot
),
enableLogging = enableLogging
)
}
private fun getValidateInlineAndValidateExternalFlags(): Pair {
return when {
!Flags.getBooleanValue("VALIDATE_INLINE_EXAMPLES") && !Flags.getBooleanValue(
"IGNORE_INLINE_EXAMPLES"
) -> true to true
else -> Flags.getBooleanValue("VALIDATE_INLINE_EXAMPLES") to Flags.getBooleanValue("IGNORE_INLINE_EXAMPLES")
}
}
private fun printValidationResult(validationResults: Map, tag: String) {
if (validationResults.isEmpty())
return
val titleTag = tag.split(" ").joinToString(" ") { if (it.isBlank()) it else it.capitalizeFirstChar() }
if (validationResults.containsFailure()) {
println()
logger.log("=============== $titleTag Validation Results ===============")
validationResults.forEach { (exampleFileName, result) ->
if (!result.isSuccess()) {
logger.log(System.lineSeparator() + "$tag $exampleFileName has the following validation error(s):")
logger.log(result.reportString())
}
}
}
println()
val summaryTitle = "=============== $titleTag Validation Summary ==============="
logger.log(summaryTitle)
logger.log(Results(validationResults.values.toList()).summary())
logger.log("=".repeat(summaryTitle.length))
}
private fun Map.containsFailure(): Boolean {
return this.any { it.value is Result.Failure }
}
private fun File.associatedExampleDirFor(specFile: File): File? {
return this.walk().firstOrNull { exampleDir ->
exampleDir.isFile.not() && exampleDir.nameWithoutExtension == "${specFile.nameWithoutExtension}_examples"
}
}
}
@Command(
name = "interactive",
mixinStandardHelpOptions = true,
description = ["Run the example generation interactively"]
)
class Interactive : Callable {
@Option(
names= ["--filter"],
description = [
"""
Filter tests matching the specified filtering criteria
You can filter tests based on the following keys:
- `METHOD`: HTTP methods (e.g., GET, POST)
- `PATH`: Request paths (e.g., /users, /product)
- `STATUS`: HTTP response status codes (e.g., 200, 400)
- `HEADERS`: Request headers (e.g., Accept, X-Request-ID)
- `QUERY-PARAM`: Query parameters (e.g., status, productId)
- `EXAMPLE-NAME`: Example name (e.g., create-product, active-status)
To specify multiple values for the same filter, separate them with commas.
For example, to filter by HTTP methods:
--filter="METHOD=GET,POST"
You can supply multiple filters as well.
For example:
--filter="METHOD=GET,POST" --filter="PATH=/users"
"""
],
required = false
)
var filter: List = emptyList()
@Option(
names= ["--filter-not"],
description = [
"""
Filter tests not matching the specified criteria
This option supports the same filtering keys and syntax as the --filter option.
For example:
--filterNot="STATUS=400" --filterNot="METHOD=PATCH,PUT"
"""
],
required = false
)
var filterNot: List = emptyList()
@Option(names = ["--contract-file"], description = ["Contract file path"], required = false)
var contractFile: File? = null
@Option(
names = ["--filter-name"],
description = ["Use only APIs with this value in their name"],
defaultValue = "\${env:SPECMATIC_FILTER_NAME}"
)
var filterName: String = ""
@Option(
names = ["--filter-not-name"],
description = ["Use only APIs which do not have this value in their name"],
defaultValue = "\${env:SPECMATIC_FILTER_NOT_NAME}"
)
var filterNotName: String = ""
@Option(names = ["--debug"], description = ["Debug logs"])
var verbose = false
@Option(names = ["--dictionary"], description = ["External Dictionary File Path"])
var dictFile: File? = null
@Option(names = ["--testBaseURL"], description = ["The baseURL of system to test"], required = false)
var testBaseURL: String? = null
@Option(
names = ["--allow-only-mandatory-keys-in-payload"],
description = ["Generate examples with only mandatory keys in the json request and response payloads"],
required = false
)
var allowOnlyMandatoryKeysInJSONObject: Boolean = false
var server: ExamplesInteractiveServer? = null
override fun call() {
configureLogger(verbose)
try {
if (contractFile != null && !contractFile!!.exists())
exitWithMessage("Could not find file ${contractFile!!.path}")
server = ExamplesInteractiveServer(
"0.0.0.0",
9001,
testBaseURL,
contractFile,
filterName,
filterNotName,
filter,
filterNot,
dictFile,
allowOnlyMandatoryKeysInJSONObject
)
addShutdownHook()
consoleLog(StringLog("Examples Interactive server is running on http://0.0.0.0:9001/_specmatic/examples. Ctrl + C to stop."))
while (true) sleep(10000)
} catch (e: Exception) {
logger.log(exceptionCauseMessage(e))
exitWithMessage(e.message.orEmpty())
}
}
private fun addShutdownHook() {
Runtime.getRuntime().addShutdownHook(object : Thread() {
override fun run() {
try {
println("Shutting down examples interactive server...")
server?.close()
} catch (e: InterruptedException) {
currentThread().interrupt()
} catch (e: Throwable) {
logger.log(e)
}
}
})
}
}
@Command(
name = "transform",
mixinStandardHelpOptions = true,
description = ["Transform existing examples"]
)
class Transform: Callable {
@Option(names = ["--contract-file"], description = ["Contract file path"], required = true)
lateinit var contractFile: File
@Option(names = ["--overlay-file"], description = ["Overlay file path"], required = false)
val overlayFile: File? = null
@Option(names = ["--examples-dir"], description = ["Directory where existing examples reside"], required = true)
lateinit var examplesDir: File
@Option(names = ["--only-mandatory-keys-in-payload"], description = ["Transform existing examples so that they contain only mandatory keys in payload"], required = false)
var allowOnlyMandatoryKeysInPayload: Boolean = false
@Option(names = ["--debug"], description = ["Debug Logs"])
var verbose: Boolean = false
override fun call() {
configureLogger(verbose)
if(allowOnlyMandatoryKeysInPayload) {
ExamplesInteractiveServer.transformExistingExamples(
contractFile,
overlayFile,
examplesDir
)
} else {
logger.log("Please choose one of the transformations from the available command-line parameters.")
}
}
}
@Command(
name = "export",
mixinStandardHelpOptions = true,
description = ["Export the inline examples from the contract file"]
)
class Export: Callable {
@Option(names = ["--contract-file"], description = ["Contract file path"], required = true)
lateinit var contractFile: File
override fun call() {
try {
val examplesDir = externaliseInlineExamples(contractFile)
consoleLog("${System.lineSeparator()}The inline examples were successfully exported to $examplesDir")
exitProcess(0)
} catch(e: Exception) {
exitWithMessage("Failed while exporting the inline examples from ${contractFile.nameWithoutExtension}:\n${e.message}")
}
}
}
}
private fun configureLogger(verbose: Boolean) {
val logPrinters = listOf(ConsolePrinter)
logger = if (verbose)
Verbose(CompositePrinter(logPrinters))
else
NonVerbose(CompositePrinter(logPrinters))
}
© 2015 - 2024 Weber Informatics LLC | Privacy Policy