in.specmatic.stub.api.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of specmatic-core Show documentation
Show all versions of specmatic-core Show documentation
Turn your contracts into executable specifications. Contract Driven Development - Collaboratively Design & Independently Deploy MicroServices & MicroFrontends.
Deprecation Notice for group ID "in.specmatic"
******************************************************************************************************
Updates for "specmatic-core" will no longer be available under the deprecated group ID "in.specmatic".
Please update your dependencies to use the new group ID "io.specmatic".
******************************************************************************************************
The newest version!
@file:JvmName("API")
package `in`.specmatic.stub
import `in`.specmatic.core.*
import `in`.specmatic.core.git.SystemGit
import `in`.specmatic.core.log.StringLog
import `in`.specmatic.core.log.consoleLog
import `in`.specmatic.core.log.logger
import `in`.specmatic.core.utilities.ContractPathData
import `in`.specmatic.core.utilities.contractStubPaths
import `in`.specmatic.core.utilities.examplesDirFor
import `in`.specmatic.core.utilities.exitIfDoesNotExist
import `in`.specmatic.core.value.StringValue
import `in`.specmatic.mock.NoMatchingScenario
import `in`.specmatic.mock.ScenarioStub
import org.yaml.snakeyaml.Yaml
import java.io.File
private const val HTTP_STUB_SHUTDOWN_TIMEOUT = 2000L
// Used by stub client code
fun createStubFromContractAndData(contractGherkin: String, dataDirectory: String, host: String = "localhost", port: Int = 9000): ContractStub {
val contractBehaviour = parseGherkinStringToFeature(contractGherkin)
val mocks = (File(dataDirectory).listFiles()?.filter { it.name.endsWith(".json") } ?: emptyList()).map { file ->
consoleLog(StringLog("Loading data from ${file.name}"))
stringToMockScenario(StringValue(file.readText(Charsets.UTF_8)))
.also {
contractBehaviour.matchingStub(it, ContractAndStubMismatchMessages)
}
}
return HttpStub(contractBehaviour, mocks, host, port, ::consoleLog)
}
// Used by stub client code
fun allContractsFromDirectory(dirContainingContracts: String): List =
File(dirContainingContracts).listFiles()?.filter { it.extension == CONTRACT_EXTENSION }?.map { it.absolutePath } ?: emptyList()
fun createStub(host: String = "localhost", port: Int = 9000): ContractStub {
return createStub(host, port, false)
}
fun createStub(host: String = "localhost", port: Int = 9000, strict: Boolean = false): ContractStub {
return createStub(host, port, timeoutMillis = HTTP_STUB_SHUTDOWN_TIMEOUT, strict)
}
// Used by stub client code
fun createStub(
dataDirPaths: List,
host: String = "localhost",
port: Int = 9000
): ContractStub {
return createStub(dataDirPaths, host, port, false)
}
fun createStub(
dataDirPaths: List,
host: String = "localhost",
port: Int = 9000,
strict: Boolean = false
): ContractStub {
return createStub(dataDirPaths, host, port, timeoutMillis = HTTP_STUB_SHUTDOWN_TIMEOUT, strict = strict)
}
fun createStubFromContracts(
contractPaths: List,
dataDirPaths: List,
host: String = "localhost",
port: Int = 9000
): ContractStub {
return createStubFromContracts(
contractPaths,
dataDirPaths,
host,
port,
timeoutMillis = HTTP_STUB_SHUTDOWN_TIMEOUT
)
}
// Used by stub client code
fun createStubFromContracts(contractPaths: List, host: String = "localhost", port: Int = 9000): ContractStub {
return createStubFromContracts(
contractPaths,
host,
port,
timeoutMillis = HTTP_STUB_SHUTDOWN_TIMEOUT
)
}
internal fun createStub(
dataDirPaths: List,
host: String = "localhost",
port: Int = 9000,
timeoutMillis: Long,
strict: Boolean = false
): ContractStub {
// TODO - see if these two can be extracted out.
val configFileName = getConfigFileName()
exitIfDoesNotExist("config file", configFileName)
val contractPathData = contractStubPaths(configFileName)
val contractInfo = loadContractStubsFromFiles(contractPathData, dataDirPaths)
val features = contractInfo.map { it.first }
val httpExpectations = contractInfoToHttpExpectations(contractInfo)
return HttpStub(
features,
httpExpectations,
host,
port,
::consoleLog,
specmaticConfigPath = File(configFileName).canonicalPath,
timeoutMillis = timeoutMillis,
strictMode = strict
)
}
internal fun createStub(host: String = "localhost", port: Int = 9000, timeoutMillis: Long, strict: Boolean = false, givenConfigFileName: String? = null): ContractStub {
val workingDirectory = WorkingDirectory()
// TODO - see if these two can be extracted out.
val configFileName = givenConfigFileName ?: getConfigFileName()
exitIfDoesNotExist("config file", configFileName)
val stubs = loadContractStubsFromImplicitPaths(contractStubPaths(configFileName))
val features = stubs.map { it.first }
val expectations = contractInfoToHttpExpectations(stubs)
return HttpStub(
features,
expectations,
host,
port,
log = ::consoleLog,
workingDirectory = workingDirectory,
specmaticConfigPath = File(configFileName).canonicalPath,
timeoutMillis = timeoutMillis,
strictMode = strict
)
}
internal fun createStubFromContracts(
contractPaths: List,
dataDirPaths: List,
host: String = "localhost",
port: Int = 9000,
timeoutMillis: Long
): HttpStub {
val contractInfo = loadContractStubsFromFiles(contractPaths.map { ContractPathData("", it) }, dataDirPaths)
val features = contractInfo.map { it.first }
val httpExpectations = contractInfoToHttpExpectations(contractInfo)
return HttpStub(
features,
httpExpectations,
host,
port,
::consoleLog,
specmaticConfigPath = File(getGlobalConfigFileName()).canonicalPath,
timeoutMillis = timeoutMillis
)
}
internal fun createStubFromContracts(
contractPaths: List,
host: String = "localhost",
port: Int = 9000,
timeoutMillis: Long
): ContractStub {
val defaultImplicitDirs: List = implicitContractDataDirs(contractPaths)
val completeList = if(customImplicitStubBase() != null) {
defaultImplicitDirs.plus(implicitContractDataDirs(contractPaths, customImplicitStubBase()))
} else
defaultImplicitDirs
return createStubFromContracts(contractPaths, completeList, host, port, timeoutMillis)
}
fun loadContractStubsFromImplicitPaths(contractPathDataList: List): List>> {
return contractPathDataList.map { Pair(File(it.path), it) }.flatMap { (contractPath, contractSource) ->
when {
contractPath.isFile && contractPath.extension in CONTRACT_EXTENSIONS -> {
consoleLog(StringLog("Loading $contractPath"))
if(hasOpenApiFileExtension(contractPath.path) && !isOpenAPI(contractPath.path.trim())) {
logger.log("Ignoring ${contractPath.path} as it is not an OpenAPI specification")
emptyList()
}
else try {
val feature = parseContractFileToFeature(contractPath, CommandHook(HookName.stub_load_contract), contractSource.provider, contractSource.repository, contractSource.branch, contractSource.specificationPath)
val implicitDataDirs = implicitDirsForSpecifications(contractPath)
val stubData = when {
implicitDataDirs.any { it.isDirectory } -> {
implicitDataDirs.filter { it.isDirectory }.flatMap { implicitDataDir ->
consoleLog("Loading stub expectations from ${implicitDataDir.path}".prependIndent(" "))
logIgnoredFiles(implicitDataDir)
val stubDataFiles = filesInDir(implicitDataDir)?.toList()?.filter { it.extension == "json" }.orEmpty().sorted()
printDataFiles(stubDataFiles)
stubDataFiles.mapNotNull {
try {
Pair(it.path, stringToMockScenario(StringValue(it.readText())))
} catch (e: Throwable) {
logger.log(e, "Could not load stub file ${it.canonicalPath}...")
null
}
}
}
}
else -> emptyList()
}
loadContractStubs(listOf(Pair(contractPath.path, feature)), stubData)
} catch(e: Throwable) {
logger.log("Could not load ${contractPath.canonicalPath}")
logger.log(e)
emptyList()
}
}
contractPath.isDirectory -> {
loadContractStubsFromImplicitPaths(contractPath.listFiles()?.toList()?.map { ContractPathData("", it.absolutePath) } ?: emptyList())
}
else -> emptyList()
}
}
}
fun implicitDirsForSpecifications(contractPath: File) =
listOf(implicitContractDataDir(contractPath.path)).plus(
if (customImplicitStubBase() != null) listOf(
implicitContractDataDir(contractPath.path, customImplicitStubBase())
) else emptyList()
).sorted()
fun hasOpenApiFileExtension(contractPath: String): Boolean =
OPENAPI_FILE_EXTENSIONS.any { contractPath.trim().endsWith(".$it") }
private fun logIgnoredFiles(implicitDataDir: File) {
val ignoredFiles = implicitDataDir.listFiles()?.toList()?.filter { it.extension != "json" }?.filter { it.isFile } ?: emptyList()
if (ignoredFiles.isNotEmpty()) {
consoleLog(StringLog("Ignoring the following files:".prependIndent(" ")))
for (file in ignoredFiles) {
consoleLog(StringLog(file.absolutePath.prependIndent(" ")))
}
}
}
fun loadContractStubsFromFiles(contractPathDataList: List, dataDirPaths: List): List>> {
val contactPathsString = contractPathDataList.joinToString(System.lineSeparator()) { it.path }
consoleLog(StringLog("Loading the following contracts:${System.lineSeparator()}$contactPathsString"))
consoleLog(StringLog(""))
val features = contractPathDataList.mapNotNull { contractPathData ->
loadIfOpenAPISpecification(contractPathData)
}
return loadExpectationsForFeatures(features, dataDirPaths)
}
fun loadExpectationsForFeatures(
features: List>,
dataDirPaths: List
): List>> {
val dataDirFileList = allDirsInTree(dataDirPaths).sorted()
val dataFiles = dataDirFileList.flatMap {
consoleLog(StringLog("Loading stub expectations from ${it.path}".prependIndent(" ")))
logIgnoredFiles(it)
it.listFiles()?.toList() ?: emptyList()
}.filter { it.extension == "json" }.sorted()
printDataFiles(dataFiles)
val mockData = dataFiles.mapNotNull {
try {
Pair(it.path, stringToMockScenario(StringValue(it.readText())))
} catch (e: Throwable) {
logger.log(e, " Could not load stub file ${it.canonicalPath}")
null
}
}
return loadContractStubs(features, mockData)
}
private fun printDataFiles(dataFiles: List) {
if (dataFiles.isNotEmpty()) {
val dataFilesString = dataFiles.joinToString(System.lineSeparator()) { it.path.prependIndent(" ") }
consoleLog(StringLog("Reading the following stub files:${System.lineSeparator()}$dataFilesString".prependIndent(" ")))
}
}
class StubMatchExceptionReport(val request: HttpRequest, val e: NoMatchingScenario) {
fun withoutFluff(fluffLevel: Int): StubMatchExceptionReport {
return StubMatchExceptionReport(request, e.withoutFluff(fluffLevel))
}
fun hasErrors(): Boolean {
return e.results.hasResults()
}
val message: String
get() = e.report(request)
}
data class StubMatchErrorReport(val exceptionReport: StubMatchExceptionReport, val contractFilePath: String) {
fun withoutFluff(fluffLevel: Int): StubMatchErrorReport {
return this.copy(exceptionReport = exceptionReport.withoutFluff(fluffLevel))
}
fun hasErrors(): Boolean {
return exceptionReport.hasErrors()
}
}
data class StubMatchResults(val feature: Feature?, val errorReport: StubMatchErrorReport?)
fun stubMatchErrorMessage(
matchResults: List,
stubFile: String
): String {
val matchResultsWithErrorReports = matchResults.mapNotNull { it.errorReport }
val errorReports: List = matchResultsWithErrorReports.map {
it.withoutFluff(0)
}.filter {
it.hasErrors()
}.ifEmpty {
matchResultsWithErrorReports.map {
it.withoutFluff(1)
}.filter {
it.hasErrors()
}
}
if(errorReports.isEmpty() || matchResults.isEmpty())
return "$stubFile didn't match any of the contracts\n${matchResults.firstOrNull()?.errorReport?.exceptionReport?.request?.requestNotRecognized()?.prependIndent(" ")}".trim()
return errorReports.joinToString("${System.lineSeparator()}${System.lineSeparator()}") { (exceptionReport, contractFilePath) ->
"$stubFile didn't match $contractFilePath${System.lineSeparator()}${
exceptionReport.message.prependIndent(
" "
)
}"
}
}
fun loadContractStubs(features: List>, stubData: List>): List>> {
val contractInfoFromStubs: List>> = stubData.mapNotNull { (stubFile, stub) ->
val matchResults = features.map { (specFile, feature) ->
try {
feature.matchingStub(stub.request, stub.response, ContractAndStubMismatchMessages)
StubMatchResults(feature, null)
} catch (e: NoMatchingScenario) {
StubMatchResults(null, StubMatchErrorReport(StubMatchExceptionReport(stub.request, e), specFile))
}
}
when (val feature = matchResults.firstNotNullOfOrNull { it.feature }) {
null -> {
val errorMessage = stubMatchErrorMessage(matchResults, stubFile).prependIndent(" ")
consoleLog(StringLog(errorMessage))
null
}
else -> Pair(feature, stub)
}
}.groupBy { it.first }.mapValues { (_, value) -> value.map { it.second } }.entries.map { Pair(it.key, it.value) }
val stubbedFeatures = contractInfoFromStubs.map { it.first }
val missingFeatures = features.map { it.second }.filter { it !in stubbedFeatures }
return contractInfoFromStubs.plus(missingFeatures.map { Pair(it, emptyList()) })
}
fun allDirsInTree(dataDirPath: String): List = allDirsInTree(listOf(dataDirPath))
fun allDirsInTree(dataDirPaths: List): List =
dataDirPaths.map { File(it) }.filter {
it.isDirectory
}.flatMap {
val fileList: List = it.listFiles()?.toList()?.filterNotNull() ?: emptyList()
pathToFileListRecursive(fileList).plus(it)
}
private fun pathToFileListRecursive(dataDirFiles: List): List =
dataDirFiles.filter {
it.isDirectory
}.map {
val fileList: List = it.listFiles()?.toList()?.filterNotNull() ?: emptyList()
pathToFileListRecursive(fileList).plus(it)
}.flatten()
private fun filesInDir(implicitDataDir: File): List? {
val files = implicitDataDir.listFiles()?.map {
when {
it.isDirectory -> {
filesInDir(it) ?: emptyList()
}
it.isFile -> {
listOf(it)
}
else -> {
logger.debug("Could not recognise ${it.absolutePath}, ignoring it.")
emptyList()
}
}
}
return files?.flatten()
}
fun implicitContractDataDirs(contractPaths: List, customBase: String? = null) =
contractPaths.map { implicitContractDataDir(it, customBase).absolutePath }
fun customImplicitStubBase(): String? = System.getenv("SPECMATIC_CUSTOM_IMPLICIT_STUB_BASE") ?: System.getProperty("customImplicitStubBase")
fun implicitContractDataDir(contractPath: String, customBase: String? = null): File {
val contractFile = File(contractPath)
return if(customBase == null)
examplesDirFor("${contractFile.absoluteFile.parent}/${contractFile.name}", DATA_DIR_SUFFIX)
else {
val gitRoot: String = File(SystemGit().inGitRootOf(contractPath).workingDirectory).canonicalPath
val fullContractPath = File(contractPath).canonicalPath
val relativeContractPath = File(fullContractPath).relativeTo(File(gitRoot))
File(gitRoot).resolve(customBase).resolve(relativeContractPath).let {
examplesDirFor("${it.parent}/${it.name}", DATA_DIR_SUFFIX)
}
}
}
fun loadIfOpenAPISpecification(contractPathData: ContractPathData): Pair? {
if (recognizedExtensionButNotOpenAPI(contractPathData) || isOpenAPI(contractPathData.path))
return Pair(contractPathData.path, parseContractFileToFeature(contractPathData.path, CommandHook(HookName.stub_load_contract), contractPathData.provider, contractPathData.repository, contractPathData.branch, contractPathData.specificationPath))
logger.log("Ignoring ${contractPathData.path} as it does not have a recognized specification extension")
return null
}
private fun recognizedExtensionButNotOpenAPI(contractPathData: ContractPathData) =
!hasOpenApiFileExtension(contractPathData.path) && File(contractPathData.path).extension in CONTRACT_EXTENSIONS
fun isOpenAPI(path: String): Boolean =
try {
Yaml().load>(File(path).reader()).contains("openapi")
} catch(e: Throwable) {
logger.log(e, "Could not parse $path")
false
}