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: 2.0.37
Show newest version
package application

import io.specmatic.conversions.OpenApiSpecification
import io.specmatic.core.Feature
import io.specmatic.core.git.SystemGit
import io.specmatic.core.log.LogMessage
import io.specmatic.core.log.logger
import io.specmatic.core.pattern.ContractException
import io.specmatic.core.utilities.exceptionCauseMessage
import io.specmatic.core.value.JSONArrayValue
import io.specmatic.core.value.JSONObjectValue
import io.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