All Downloads are FREE. Search and download functionalities are using the official Maven repository.

application.BackwardCompatibilityCheckCommand.kt Maven / Gradle / Ivy

There is a newer version: 2.0.37
Show newest version
package application

import application.BackwardCompatibilityCheckCommand.CompatibilityResult.FAILED
import application.BackwardCompatibilityCheckCommand.CompatibilityResult.PASSED
import io.specmatic.conversions.OpenApiSpecification
import io.specmatic.core.CONTRACT_EXTENSION
import io.specmatic.core.CONTRACT_EXTENSIONS
import io.specmatic.core.Feature
import io.specmatic.core.WSDL
import io.specmatic.core.git.GitCommand
import io.specmatic.core.git.SystemGit
import io.specmatic.core.log.logger
import io.specmatic.core.testBackwardCompatibility
import io.specmatic.stub.isOpenAPI
import org.springframework.stereotype.Component
import picocli.CommandLine.Command
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.concurrent.Callable
import java.util.regex.Pattern
import kotlin.io.path.extension
import kotlin.io.path.pathString
import kotlin.system.exitProcess

const val ONE_INDENT = "  "
const val TWO_INDENTS = "${ONE_INDENT}${ONE_INDENT}"

@Component
@Command(
    name = "backwardCompatibilityCheck",
    mixinStandardHelpOptions = true,
    description = [
"""
Checks backward compatibility of a directory across the current HEAD and the main branch.
DEPRECATED: This command will be removed in the next major release. Use 'backward-compatibility-check' command instead.
"""
    ]
)
class BackwardCompatibilityCheckCommand(
    private val gitCommand: GitCommand = SystemGit(),
) : Callable {

    private val newLine = System.lineSeparator()

    companion object {
        private const val HEAD = "HEAD"
        private const val MARGIN_SPACE = "  "
    }

    override fun call() {
        val filesChangedInCurrentBranch: Set = getOpenAPISpecFilesChangedInCurrentBranch()

        if (filesChangedInCurrentBranch.isEmpty()) {
            logger.log("${newLine}No OpenAPI spec files were changed, skipping the check.$newLine")
            exitProcess(0)
        }

        val filesReferringToChangedSchemaFiles = filesReferringToChangedSchemaFiles(filesChangedInCurrentBranch)

        val specificationsOfChangedExternalisedExamples: Set = getSpecificationsOfChangedExternalisedExamples(filesChangedInCurrentBranch)

        logFilesToBeCheckedForBackwardCompatibility(
            filesChangedInCurrentBranch,
            filesReferringToChangedSchemaFiles,
            specificationsOfChangedExternalisedExamples
        )

        val specificationsToCheck: Set = filesChangedInCurrentBranch + filesReferringToChangedSchemaFiles + specificationsOfChangedExternalisedExamples

        val result = try {
            runBackwardCompatibilityCheckFor(specificationsToCheck)
        } catch(e: Throwable) {
            logger.newLine()
            logger.newLine()
            logger.log(e)
            exitProcess(1)
        }

        println()
        println(result.report)
        exitProcess(result.exitCode)
    }

    private fun getSpecificationsOfChangedExternalisedExamples(filesChangedInCurrentBranch: Set): Set {
        data class CollectedFiles(
            val specifications: MutableSet = mutableSetOf(),
            val examplesMissingSpecifications: MutableList = mutableListOf(),
            val ignoredFiles: MutableList = mutableListOf()
        )

        val collectedFiles = filesChangedInCurrentBranch.fold(CollectedFiles()) { acc, filePath ->
            val path = Paths.get(filePath)
            val examplesDir = path.find { it.toString().endsWith("_examples") || it.toString().endsWith("_tests") }

            if (examplesDir == null) {
                acc.ignoredFiles.add(filePath)
            } else {
                val parentPath = examplesDir.parent
                val strippedPath = parentPath.resolve(examplesDir.fileName.toString().removeSuffix("_examples"))
                val specFiles = findSpecFiles(strippedPath)

                if (specFiles.isNotEmpty()) {
                    acc.specifications.addAll(specFiles.map { it.toString() })
                } else {
                    acc.examplesMissingSpecifications.add(filePath)
                }
            }
            acc
        }

        val result = collectedFiles.specifications.toMutableSet()

        collectedFiles.examplesMissingSpecifications.forEach { filePath ->
            val path = Paths.get(filePath)
            val examplesDir = path.find { it.toString().endsWith("_examples") || it.toString().endsWith("_tests") }
            if (examplesDir != null) {
                val parentPath = examplesDir.parent
                val strippedPath = parentPath.resolve(examplesDir.fileName.toString().removeSuffix("_examples"))
                val specFiles = findSpecFiles(strippedPath)
                if (specFiles.isNotEmpty()) {
                    result.addAll(specFiles.map { it.toString() })
                } else {
                    result.add("${strippedPath}.yaml")
                }
            }
        }

        return result
    }

    private fun Path.find(predicate: (Path) -> Boolean): Path? {
        var current: Path? = this
        while (current != null) {
            if (predicate(current)) {
                return current
            }
            current = current.parent
        }
        return null
    }

    private fun findSpecFiles(path: Path): List {
        val extensions = CONTRACT_EXTENSIONS
        return extensions.map { path.resolveSibling(path.fileName.toString() + it) }
            .filter { Files.exists(it) && (isOpenAPI(it.pathString) || it.extension in listOf(WSDL, CONTRACT_EXTENSION)) }
    }

    private fun runBackwardCompatibilityCheckFor(files: Set): CompatibilityReport {
        val branchWithChanges = gitCommand.currentBranch()
        val treeishWithChanges = if (branchWithChanges == HEAD) gitCommand.detachedHEAD() else branchWithChanges

        try {
            val results = files.mapIndexed { index, specFilePath ->
                try {
                    println("${index.inc()}. Running the check for $specFilePath:")

                    // newer => the file with changes on the branch
                    val (newer, unusedExamples) = OpenApiSpecification.fromFile(specFilePath).toFeature().loadExternalisedExamplesAndListUnloadableExamples()

                    val olderFile = gitCommand.getFileInBranch(
                        specFilePath,
                        treeishWithChanges,
                        gitCommand.defaultBranch()
                    )
                    if (olderFile == null) {
                        println("$specFilePath is a new file.$newLine")
                        return@mapIndexed PASSED
                    }

                    gitCommand.checkout(gitCommand.defaultBranch())

                    // older => the same file on the default (e.g. main) branch
                    val older = OpenApiSpecification.fromFile(olderFile.path).toFeature()

                    val backwardCompatibilityResult = testBackwardCompatibility(older, newer)

                    if (backwardCompatibilityResult.success()) {
                        println(
                            "$newLine The file $specFilePath is backward compatible.$newLine".prependIndent(
                                MARGIN_SPACE
                            )
                        )

                        println()

                        var errorsFound = false

                        if(!examplesAreValid(newer)) {
                            println(
                                "$newLine *** Examples in $specFilePath are not valid. ***$newLine".prependIndent(
                                    MARGIN_SPACE
                                )
                            )

                            println()

                            errorsFound = true
                        }

                        if(unusedExamples.isNotEmpty()) {
                            println(
                                "$newLine *** Some examples for $specFilePath could not be loaded. ***$newLine".prependIndent(
                                    MARGIN_SPACE
                                )
                            )

                            println()

                            errorsFound = true

                        }

                        if(errorsFound) {
                            FAILED
                        }
                        else
                            PASSED
                    } else {
                        println("$newLine ${backwardCompatibilityResult.report().prependIndent(MARGIN_SPACE)}")
                        println(
                            "$newLine *** The file $specFilePath is NOT backward compatible. ***$newLine".prependIndent(
                                MARGIN_SPACE
                            )
                        )

                        println()

                        FAILED
                    }
                } finally {
                    gitCommand.checkout(treeishWithChanges)
                }
            }

            return CompatibilityReport(results)
        } finally {
            gitCommand.checkout(treeishWithChanges)
        }
    }

    private fun examplesAreValid(feature: Feature): Boolean {
        return try {
            feature.validateExamplesOrException()
            true
        } catch (t: Throwable) {
            println()
            false
        }
    }

    private fun logFilesToBeCheckedForBackwardCompatibility(
        changedFiles: Set,
        filesReferringToChangedFiles: Set,
        specificationsOfChangedExternalisedExamples: Set
    ) {

        println("Checking backward compatibility of the following files: $newLine")
        println("${ONE_INDENT}Files that have changed:")
        changedFiles.forEach { println(it.prependIndent(TWO_INDENTS)) }
        println()

        if(filesReferringToChangedFiles.isNotEmpty()) {
            println("${ONE_INDENT}Files referring to the changed files - ")
            filesReferringToChangedFiles.forEach { println(it.prependIndent(TWO_INDENTS)) }
            println()
        }

        if(specificationsOfChangedExternalisedExamples.isNotEmpty()) {
            println("${ONE_INDENT}Specifications whose externalised examples were changed - ")
            filesReferringToChangedFiles.forEach { println(it.prependIndent(TWO_INDENTS)) }
            println()
        }

        println("-".repeat(20))
        println()
    }

    internal fun filesReferringToChangedSchemaFiles(inputFiles: Set): Set {
        if (inputFiles.isEmpty()) return emptySet()

        val inputFileNames = inputFiles.map { File(it).name }
        val result = allOpenApiSpecFiles().filter {
            it.readText().trim().let { specContent ->
                inputFileNames.any { inputFileName ->
                    val pattern = Pattern.compile("\\b$inputFileName\\b")
                    val matcher = pattern.matcher(specContent)
                    matcher.find()
                }
            }
        }.map { it.path }.toSet()

        return result.flatMap {
            filesReferringToChangedSchemaFiles(setOf(it)).ifEmpty { setOf(it) }
        }.toSet()
    }

    internal fun allOpenApiSpecFiles(): List {
        return File(".").walk().toList().filterNot {
            ".git" in it.path
        }.filter { it.isFile && it.isOpenApiSpec() }
    }

    private fun getOpenAPISpecFilesChangedInCurrentBranch(): Set {
        return gitCommand.getFilesChangedInCurrentBranch(
            gitCommand.defaultBranch()
        ).filter {
            File(it).exists() && File(it).isOpenApiSpec()
        }.toSet()
    }

    private fun File.isOpenApiSpec(): Boolean {
        if (this.extension !in CONTRACT_EXTENSIONS) return false
        return OpenApiSpecification.isParsable(this.path)
    }

    class CompatibilityReport(results: List) {
        val report: String
        val exitCode: Int

        init {
            val failed: Boolean = results.any { it == FAILED }
            val failedCount = results.count { it == FAILED }
            val passedCount = results.count { it == PASSED }

            report = "Files checked: ${results.size} (Passed: ${passedCount}, Failed: $failedCount)"
            exitCode = if(failed) 1 else 0
        }

    }

    enum class CompatibilityResult {
        PASSED, FAILED
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy