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

application.RedeclaredAPICommand.kt Maven / Gradle / Ivy

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

import `in`.specmatic.conversions.OpenApiSpecification
import `in`.specmatic.core.Feature
import `in`.specmatic.core.git.SystemGit
import `in`.specmatic.core.log.LogMessage
import `in`.specmatic.core.log.logger
import `in`.specmatic.core.pattern.ContractException
import `in`.specmatic.core.utilities.exceptionCauseMessage
import `in`.specmatic.core.value.JSONArrayValue
import `in`.specmatic.core.value.JSONObjectValue
import `in`.specmatic.core.value.StringValue
import picocli.CommandLine
import picocli.CommandLine.Option
import java.io.File
import java.util.concurrent.Callable

fun fetchAllContracts(directory: String): List> =
    listOfAllContractFiles(File(directory)).mapNotNull {
        loadContractData(it)
    }

fun loadContractData(it: File) = try {
    Pair(OpenApiSpecification.fromYAML(it.readText(), it.path).toFeature(), it.path)
} catch (e: Throwable) {
    logger.debug(exceptionCauseMessage(e))
    null
}

@CommandLine.Command(name = "redeclared",
    mixinStandardHelpOptions = true,
    description = ["Checks if new APIs in this file have been re-declared"])
class ReDeclaredAPICommand: Callable {
    @CommandLine.Command(name = "file", description = ["Check the specified contract for re-declarations"])
    fun file(@CommandLine.Parameters(paramLabel = "contractPath") contractFilePath: String): Int {
        val reDeclarations = findReDeclaredContracts(ContractToCheck(contractFilePath, SystemGit()))

        if(reDeclarations.isNotEmpty()) {
            logger.log("Some APIs in $contractFilePath have been declared in other files as well.")
            logger.newLine()
        }

        reDeclarations.forEach { (newPath, contracts) ->
            logger.log(newPath)
            logger.log(contracts.joinToString("\n") { "- $it" })
        }

        return if(reDeclarations.isNotEmpty())
            1
        else
            0
    }

    class JSONArrayLogMessage(val json: JSONArrayValue): LogMessage {
        override fun toJSONObject(): JSONObjectValue {
            return JSONObjectValue(mapOf("list" to json))
        }

        override fun toLogString(): String {
            return json.displayableValue()
        }

    }

    @CommandLine.Command(name = "entire-repo", description = ["Check all contracts in the repo for re-declarations"])
    fun entireRepo(@Option(names = ["--json"]) json: Boolean, @Option(names = ["--baseDirectory"]) suppliedBaseDirectory: String? = null, @Option(names = ["--systemLevel"]) systemLevel: Int = 0, @Option(names = ["--ignoreAPI"]) ignoreAPIs: List? = emptyList()): Int {
        val baseDirectory = suppliedBaseDirectory ?: SystemGit().gitRoot()
        val contracts: List> = fetchAllContracts(baseDirectory)

        val ignorableAPIs = ignoreAPIs ?: emptyList()

        val reDeclarations: Map> = findReDeclarationsAmongstContracts(contracts, baseDirectory, systemLevel).filterKeys {
            it !in ignorableAPIs
        }

        logReDeclarations(json, reDeclarations)

        return if(reDeclarations.isNotEmpty())
            1
        else
            0
    }

    @CommandLine.Command(name = "branch", description = ["Check all new or updated contracts in the branch for re-declarations"])
    fun branch(@Option(names = ["--json"]) json: Boolean, @Option(names = ["--main-branch"], defaultValue = "master") mainBranch: String): Int {
        val relativePaths = SystemGit().getChangesFromMainBranch(mainBranch).filter { File(it).exists() }.filter { it.endsWith("yaml") }
        val reDeclarations = relativePaths.flatMap {
            findReDeclaredContracts(ContractToCheck(it, SystemGit()))
        }.groupBy {
            it.apiURLPath
        }.mapValues { entry ->
            entry.value.flatMap { it.contractsContainingAPI }.distinct()
        }.filter {
            it.value.size > 1
        }

        logReDeclarations(json, reDeclarations)

        return if(reDeclarations.isNotEmpty())
            1
        else
            0
    }

    private fun logReDeclarations(
        json: Boolean,
        reDeclarations: Map>
    ) {
        val sorted = reDeclarations.entries.sortedBy { (api, _) -> api }

        if (json) printJSON(sorted) else printText(reDeclarations, sorted)
    }

    private fun printText(
        reDeclarations: Map>,
        sorted: List>>
    ) {
        if (reDeclarations.isNotEmpty()) {
            logger.log("Some APIs have been declared in multiple files.")
            logger.newLine()
        }

        sorted.forEach { (newPath, contracts) ->
            logger.log(newPath)
            logger.log(contracts.joinToString("\n"))
            if(contracts.map { File(it).readText() }.distinct().size == 1)
                logger.log("NOTE: These files are exact duplicates")
            logger.newLine()
        }

        logger.log("Count of APIs re-declared: ${reDeclarations.size}")
    }

    private fun printJSON(sorted: List>>) {
        val reDeclarationsJSON = JSONArrayValue(sorted.map { (api, files) ->
            val jsonFileList = JSONArrayValue(files.map { StringValue(it) })
            JSONObjectValue(mapOf("api" to StringValue(api), "files" to jsonFileList))
        })

        logger.log(JSONArrayLogMessage(reDeclarationsJSON))
    }

    override fun call() {
        CommandLine(GitCompatibleCommand()).usage(System.out)
    }
}

data class APIReDeclarations(val apiURLPath: String, val contractsContainingAPI: List)

fun findReDeclarationsAmongstContracts(contracts: List>, baseDirectory: String = "", systemLevel: Int = 0): Map> {
    val declarations = contracts.flatMap { (feature, filePath) ->
        pathsFromFeature(feature).map { urlPath -> Pair(urlPath, filePath) }
    }.groupBy { (urlPath, _) -> urlPath }.mapValues { (_, value) ->
        value.map { (_, path) -> path }
    }

    val multipleDeclarations = declarations.filter { (_, filePaths) -> filePaths.size > 1 }.let { reDeclarations ->
        if(systemLevel > 0) {
            val canonicalBase = File(baseDirectory).canonicalFile

            reDeclarations.filterValues { paths ->
                val distinctPathLevels: List = paths.map {
                    val relativePathParts = File(it).canonicalFile.parentFile.relativeTo(canonicalBase).path.removePrefix("/").split("/")

                    (0 until systemLevel).mapNotNull { level ->
                        relativePathParts.getOrNull(level)
                    }.joinToString("/")
                }.distinct()

                distinctPathLevels.size == 1
            }
        } else {
            reDeclarations
        }
    }

    return multipleDeclarations
}

fun findReDeclaredContracts(
    contractToCheck: ContractToCheck,
): List {
    return try {
        val paths: List = contractToCheck.getPathsInContract() ?: emptyList()
        val contracts: List> = contractToCheck.fetchAllOtherContracts()

        findReDeclarations(paths, contracts)
    } catch(e: Throwable) {
        logger.log("Unhandled exception caught when parsing contract contra${contractToCheck.path}")
        emptyList()
    }
}

fun findReDeclarations(
    newPaths: List,
    contracts: List>
): List {
    val newPathToContractMap = newPaths.map { newPath ->
        val matchingContracts = contracts.filter { (feature, _) ->
            feature.scenarios.map { it.httpRequestPattern.httpPathPattern!!.path }.any { scenarioPath ->
                scenarioPath == newPath
            }
        }.map { it.second }

        APIReDeclarations(newPath, matchingContracts)
    }

    return newPathToContractMap
}

fun urlPaths(newerContractYaml: String, contractPath: String): List? {
    return try {
        val specification = OpenApiSpecification.fromYAML(newerContractYaml, contractPath)
        if(specification.isOpenAPI31()) {
            logger.log("$contractPath is written using OpenAPI 3.1, which is not yet supported")
            return emptyList()
        }

        val newContract = specification.toFeature()
        pathsFromFeature(newContract)
    } catch(e: ContractException) {
        logger.debug(exceptionCauseMessage(e))
        null
    }
}

private fun pathsFromFeature(newContract: Feature) =
    newContract.scenarios.map { it.httpRequestPattern.httpPathPattern!!.path }.sorted().distinct()

open class CanonicalFile(val file: File) {
    val path: String = file.path

    constructor (path: String) : this(File(path).canonicalFile)
    fun readText(): String = file.readText()
}

fun listOfAllContractFiles(dir: File): List {
    val fileGroups = dir.listFiles()!!.groupBy { it.isDirectory }

    val files = (fileGroups[false] ?: emptyList()).filter { it.extension == "yaml" }.map { it.canonicalFile }
    val dirs = (fileGroups[true] ?: emptyList()).filter { it.name != ".git" }.map { it.canonicalFile }

    val dirFiles = dirs.flatMap { listOfAllContractFiles(it) }

    return files.plus(dirFiles)
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy