Add compare command to openapi-generator

This commit is contained in:
Niels van Velzen 2023-01-03 14:37:30 +01:00 committed by Niels van Velzen
parent 4aee90b7e0
commit 61de6651f5
18 changed files with 498 additions and 14 deletions

View File

@ -1,5 +1,6 @@
plugins {
alias(libs.plugins.download)
alias(libs.plugins.kotlin.serialization)
kotlin("jvm")
id("application")
}
@ -23,6 +24,9 @@ dependencies {
// Kotlin code generation
implementation(libs.kotlinpoet)
// Compare reporters
implementation(libs.kotlinx.serialization.json)
// Dependency Injection
implementation(libs.koin)

View File

@ -8,22 +8,30 @@ import org.jellyfin.openapi.builder.model.ModelsBuilder
import org.jellyfin.openapi.builder.openapi.OpenApiApiServicesBuilder
import org.jellyfin.openapi.builder.openapi.OpenApiConstantsBuilder
import org.jellyfin.openapi.builder.openapi.OpenApiModelsBuilder
import org.jellyfin.openapi.compare.InfoComparator
import org.jellyfin.openapi.compare.ModelComparator
import org.jellyfin.openapi.compare.OperationComparator
import org.jellyfin.openapi.compare.model.CompareResult
import org.jellyfin.openapi.constants.Packages
import org.jellyfin.openapi.model.GeneratorContext
import org.jellyfin.openapi.model.GeneratorResult
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
private val logger = KotlinLogging.logger { }
class Generator(
private val openApiApiServicesBuilder: OpenApiApiServicesBuilder,
private val apisBuilder: ApisBuilder,
private val openApiModelsBuilder: OpenApiModelsBuilder,
private val modelsBuilder: ModelsBuilder,
private val apiClientExtensionsBuilder: ApiClientExtensionsBuilder,
private val openApiConstantsBuilder: OpenApiConstantsBuilder,
) {
private fun generateInternal(openApiJson: String): GeneratorResult {
class Generator : KoinComponent {
private val openApiApiServicesBuilder by inject<OpenApiApiServicesBuilder>()
private val apisBuilder by inject<ApisBuilder>()
private val openApiModelsBuilder by inject<OpenApiModelsBuilder>()
private val modelsBuilder by inject<ModelsBuilder>()
private val apiClientExtensionsBuilder by inject<ApiClientExtensionsBuilder>()
private val openApiConstantsBuilder by inject<OpenApiConstantsBuilder>()
private val infoComparator by inject<InfoComparator>()
private val operationComparator by inject<OperationComparator>()
private val modelComparator by inject<ModelComparator>()
private fun generateInternal(openApiJson: String): GeneratorContext {
val openApiSpecification = OpenAPIV3Parser().readContents(openApiJson)
openApiSpecification.messages.forEach { message -> logger.warn { message } }
@ -41,7 +49,7 @@ class Generator(
apiClientExtensionsBuilder.build(context, context.apiServices)
openApiConstantsBuilder.build(context, context.info)
return context.toGeneratorResult()
return context
}
fun verify(
@ -52,7 +60,7 @@ class Generator(
val verification = Verification(apiOutputDir, modelsOutputDir)
val result = generateInternal(openApiJson)
return verification.verify(result)
return verification.verify(result.toGeneratorResult())
}
fun generate(
@ -60,7 +68,7 @@ class Generator(
apiOutputDir: File,
modelsOutputDir: File,
) {
val result = generateInternal(openApiJson)
val result = generateInternal(openApiJson).toGeneratorResult()
// Clear output directories
modelsOutputDir.deleteRecursively()
@ -78,4 +86,21 @@ class Generator(
file.writeTo(directory)
}
}
fun compare(
oldOpenApiJson: String,
newOpenApiJson: String,
): CompareResult {
// Create generator contexts for both schemas
val oldSchema = generateInternal(oldOpenApiJson)
val newSchema = generateInternal(newOpenApiJson)
// Construct and return compare result
return CompareResult(
binaryDifference = oldOpenApiJson != newOpenApiJson,
info = infoComparator.compare(oldSchema, newSchema),
api = operationComparator.compare(oldSchema, newSchema),
model = modelComparator.compare(oldSchema, newSchema),
)
}
}

View File

@ -25,14 +25,28 @@ import org.jellyfin.openapi.builder.openapi.OpenApiModelBuilder
import org.jellyfin.openapi.builder.openapi.OpenApiModelsBuilder
import org.jellyfin.openapi.builder.openapi.OpenApiReturnTypeBuilder
import org.jellyfin.openapi.builder.openapi.OpenApiTypeBuilder
import org.jellyfin.openapi.cli.CompareCommand
import org.jellyfin.openapi.cli.GenerateCommand
import org.jellyfin.openapi.cli.MainCommand
import org.jellyfin.openapi.cli.VerifyCommand
import org.jellyfin.openapi.compare.InfoComparator
import org.jellyfin.openapi.compare.ModelComparator
import org.jellyfin.openapi.compare.OperationComparator
import org.jellyfin.openapi.compare.reporter.CompareReporter
import org.jellyfin.openapi.compare.reporter.JsonCompareReporter
import org.koin.dsl.bind
import org.koin.dsl.module
val mainModule = module {
single { Generator(get(), get(), get(), get(), get(), get()) }
single { Generator() }
// Comparators
single { InfoComparator() }
single { OperationComparator() }
single { ModelComparator() }
// Compare reporters
single { JsonCompareReporter() } bind CompareReporter::class
// OpenAPI
single { OpenApiTypeBuilder(getAll()) }
@ -72,4 +86,5 @@ val mainModule = module {
single { MainCommand() }
single { GenerateCommand() } bind CliktCommand::class
single { VerifyCommand() } bind CliktCommand::class
single { CompareCommand() } bind CliktCommand::class
}

View File

@ -0,0 +1,68 @@
package org.jellyfin.openapi.cli
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.types.choice
import com.github.ajalt.clikt.parameters.types.file
import org.jellyfin.openapi.Generator
import org.jellyfin.openapi.compare.reporter.CompareReporter
import org.koin.core.component.inject
class CompareCommand : BaseCommand() {
private val generator by inject<Generator>()
private val reporters by lazy { getKoin().getAll<CompareReporter>() }
private val oldOpenApiFile by argument(
help = "The old OpenAPI JSON file"
).file(
mustExist = true,
canBeFile = true,
canBeDir = false,
mustBeReadable = true,
)
private val newOpenApiFile by argument(
help = "The new OpenAPI JSON file"
).file(
mustExist = true,
canBeFile = true,
canBeDir = false,
mustBeReadable = true,
)
private val reporter by option(
names = arrayOf("--format", "-f"),
help = "The format to use"
).choice(
choices = reporters.associateBy { it.name }
).default(
value = reporters.first()
)
private val output by option(
names = arrayOf("--output", "-o"),
help = "The output"
).file(
canBeFile = true,
canBeDir = false,
)
override fun run() {
// Read OpenAPI json
val oldOpenApiJson = oldOpenApiFile.readText()
val newOpenApiJson = newOpenApiFile.readText()
// Compare specifications
val result = generator.compare(oldOpenApiJson, newOpenApiJson)
val formatted = reporter.format(result)
// Write output
if (output != null) {
output!!.writeText(formatted)
println("Output written to ${output!!.absolutePath}")
} else {
println(formatted)
}
}
}

View File

@ -0,0 +1,17 @@
package org.jellyfin.openapi.compare
import org.jellyfin.openapi.compare.model.CompareValueDiff
import org.jellyfin.openapi.compare.model.buildCompareValueDiffCollection
import org.jellyfin.openapi.model.GeneratorContext
class InfoComparator {
fun compare(
oldSchema: GeneratorContext,
newSchema: GeneratorContext,
): Collection<CompareValueDiff> = buildCompareValueDiffCollection(oldSchema.info, newSchema.info) {
detect({ title }, "Title")
detect({ description }, "Description")
detect({ version }, "API version")
detect({ extensions["x-jellyfin-version"] }, "Jellyfin version")
}
}

View File

@ -0,0 +1,91 @@
package org.jellyfin.openapi.compare
import org.jellyfin.openapi.compare.model.CompareModel
import org.jellyfin.openapi.compare.model.CompareModelConstant
import org.jellyfin.openapi.compare.model.CompareModelProperty
import org.jellyfin.openapi.compare.model.buildCompareCollectionDiff
import org.jellyfin.openapi.compare.model.buildCompareValueDiffCollection
import org.jellyfin.openapi.compare.model.emptyCompareCollectionDiff
import org.jellyfin.openapi.model.ApiModel
import org.jellyfin.openapi.model.EnumApiModel
import org.jellyfin.openapi.model.GeneratorContext
import org.jellyfin.openapi.model.ObjectApiModel
class ModelComparator {
private fun compareObjectModelProperties(
oldModel: ObjectApiModel?,
newModel: ObjectApiModel,
) = buildCompareCollectionDiff(
first = oldModel?.properties ?: newModel.properties,
second = newModel.properties,
keySelector = { name },
comparator = { oldProperty, newProperty -> oldProperty == newProperty },
createModel = { oldProperty, newProperty ->
CompareModelProperty(
newProperty.name,
buildCompareValueDiffCollection(oldProperty, newProperty) {
detect({ name }, "Name")
detect({ description }, "Description")
detect({ deprecated }, "Deprecated")
detect({ defaultValue }, "Default value")
// Check type and nullability separately
detect({ type.copy(nullable = false) }, "Type")
detect({ type.isNullable }, "Nullable")
}
)
},
)
private fun compareEnumModelConstants(
oldModel: EnumApiModel?,
newModel: EnumApiModel,
) = buildCompareCollectionDiff(
first = oldModel?.constants ?: newModel.constants,
second = newModel.constants,
keySelector = { this },
comparator = { oldConstant, newConstant -> oldConstant == newConstant },
createModel = { _, newConstant ->
CompareModelConstant(
name = newConstant,
changes = buildCompareValueDiffCollection(oldModel, newModel) {
detect({ name }, "Name")
}
)
},
)
private fun compareModel(
oldModel: ApiModel?,
newModel: ApiModel,
) = CompareModel(
name = newModel.name,
description = newModel.description,
properties = when {
newModel is ObjectApiModel -> compareObjectModelProperties(oldModel as? ObjectApiModel?, newModel)
else -> emptyCompareCollectionDiff()
},
constants = when {
newModel is EnumApiModel -> compareEnumModelConstants(oldModel as? EnumApiModel?, newModel)
else -> emptyCompareCollectionDiff()
},
changes = buildCompareValueDiffCollection(oldModel, newModel) {
detect({ name }, "Name")
detect({ description }, "Description")
detect({ deprecated }, "Deprecated")
if (oldModel != null && oldModel::class != newModel::class) {
add("ObjectType", oldModel::class, newModel::class)
}
},
)
fun compare(
oldSchema: GeneratorContext,
newSchema: GeneratorContext,
) = buildCompareCollectionDiff(
first = oldSchema.models,
second = newSchema.models,
keySelector = { name },
createModel = ::compareModel
)
}

View File

@ -0,0 +1,68 @@
package org.jellyfin.openapi.compare
import org.jellyfin.openapi.compare.model.CompareOperation
import org.jellyfin.openapi.compare.model.CompareOperationParameter
import org.jellyfin.openapi.compare.model.buildCompareCollectionDiff
import org.jellyfin.openapi.compare.model.buildCompareValueDiffCollection
import org.jellyfin.openapi.model.ApiServiceOperation
import org.jellyfin.openapi.model.ApiServiceOperationParameter
import org.jellyfin.openapi.model.GeneratorContext
class OperationComparator {
private fun compareParameter(
oldParameter: ApiServiceOperationParameter?,
newParameter: ApiServiceOperationParameter,
): CompareOperationParameter = CompareOperationParameter(
name = newParameter.name,
changes = buildCompareValueDiffCollection(oldParameter, newParameter) {
detect({ name }, "Name")
detect({ originalName }, "Original name")
detect({ description }, "Description")
detect({ deprecated }, "Deprecated")
detect({ defaultValue }, "Default value")
// Check type and nullability separately
detect({ type.copy(nullable = false) }, "Type")
detect({ type.isNullable }, "Nullable")
detect({ validation }, "Validation")
},
)
private fun compareParameters(
oldParameters: Collection<ApiServiceOperationParameter>?,
newParameters: Collection<ApiServiceOperationParameter>,
) = buildCompareCollectionDiff(
first = oldParameters ?: newParameters,
second = newParameters,
keySelector = { name },
createModel = ::compareParameter,
)
private fun compareOperation(
oldOperation: ApiServiceOperation?,
newOperation: ApiServiceOperation,
) = CompareOperation(
name = newOperation.name,
pathParameters = compareParameters(oldOperation?.pathParameters, newOperation.pathParameters),
queryParameters = compareParameters(oldOperation?.queryParameters, newOperation.queryParameters),
changes = buildCompareValueDiffCollection(oldOperation, newOperation) {
detect({ name }, "Name")
detect({ description }, "Description")
detect({ deprecated }, "Deprecated")
detect({ pathTemplate }, "URL template")
detect({ method }, "Method")
detect({ requireAuthentication }, "Authentication")
detect({ returnType }, "Return type")
detect({ bodyType }, "Body type")
},
)
fun compare(
oldSchema: GeneratorContext,
newSchema: GeneratorContext,
) = buildCompareCollectionDiff(
first = oldSchema.apiServices.flatMap { it.operations },
second = newSchema.apiServices.flatMap { it.operations },
keySelector = { method.toString() + pathTemplate },
createModel = ::compareOperation,
)
}

View File

@ -0,0 +1,34 @@
package org.jellyfin.openapi.compare
fun <T, K> Iterable<T>.compare(
other: Iterable<T>,
keySelector: (T) -> K,
comparator: (T, T) -> Boolean = { a, b -> a == b },
): Iterable<CompareEntry<T>> {
val references = map(keySelector).toSet()
val otherReferences = other.associateBy(keySelector)
val entries = mutableListOf<CompareEntry<T>>()
other.forEach { otherItem ->
val otherKey = keySelector(otherItem)
if (otherKey !in references) entries.add(CompareEntry.Added(otherItem))
}
forEach { item ->
val otherItem = otherReferences[keySelector(item)]
if (otherItem == null) entries.add(CompareEntry.Removed(item))
else if (!comparator(item, otherItem)) entries.add(CompareEntry.Modified(item, otherItem))
else entries.add(CompareEntry.Unchanged(item))
}
return entries
}
sealed interface CompareEntry<T> {
data class Added<T>(val item: T) : CompareEntry<T>
data class Modified<T>(val before: T, val after: T) : CompareEntry<T>
data class Unchanged<T>(val item: T) : CompareEntry<T>
data class Removed<T>(val item: T) : CompareEntry<T>
}

View File

@ -0,0 +1,39 @@
package org.jellyfin.openapi.compare.model
import kotlinx.serialization.Serializable
import org.jellyfin.openapi.compare.CompareEntry
import org.jellyfin.openapi.compare.compare
@Serializable
data class CompareCollectionDiff<T>(
val added: Collection<T> = emptyList(),
val removed: Collection<T> = emptyList(),
val modified: Collection<T> = emptyList(),
val unchanged: Collection<T> = emptyList(),
) {
fun isEmpty() = added.isEmpty() && removed.isEmpty() && modified.isEmpty() && unchanged.isEmpty()
fun isNotEmpty() = added.isNotEmpty() || removed.isNotEmpty() || modified.isNotEmpty() || unchanged.isNotEmpty()
fun isChanged() = added.isNotEmpty() || removed.isNotEmpty() || modified.isNotEmpty()
fun isNotChanged() = added.isEmpty() && removed.isEmpty() && modified.isEmpty()
}
fun <T> emptyCompareCollectionDiff() = CompareCollectionDiff<T>()
@Suppress("LongParameterList")
fun <T, TN, K> buildCompareCollectionDiff(
first: Iterable<T>,
second: Iterable<T>,
keySelector: T.() -> K,
comparator: (T, T) -> Boolean = { a, b -> a == b },
createModel: (T?, T) -> TN,
): CompareCollectionDiff<TN> {
val result = first.compare(second, keySelector, comparator)
return CompareCollectionDiff(
added = result.filterIsInstance<CompareEntry.Added<T>>().map { createModel(null, it.item) },
removed = result.filterIsInstance<CompareEntry.Removed<T>>().map { createModel(null, it.item) },
modified = result.filterIsInstance<CompareEntry.Modified<T>>().map { createModel(it.before, it.after) },
unchanged = result.filterIsInstance<CompareEntry.Unchanged<T>>().map { createModel(null, it.item) },
)
}

View File

@ -0,0 +1,13 @@
package org.jellyfin.openapi.compare.model
import kotlinx.serialization.Serializable
@Serializable
data class CompareModel(
val name: String,
val description: String?,
val properties: CompareCollectionDiff<CompareModelProperty>,
val constants: CompareCollectionDiff<CompareModelConstant>,
val changes: Collection<CompareValueDiff>,
)

View File

@ -0,0 +1,10 @@
package org.jellyfin.openapi.compare.model
import kotlinx.serialization.Serializable
@Serializable
data class CompareModelConstant(
val name: String,
val changes: Collection<CompareValueDiff> = emptyList(),
)

View File

@ -0,0 +1,10 @@
package org.jellyfin.openapi.compare.model
import kotlinx.serialization.Serializable
@Serializable
data class CompareModelProperty(
val name: String,
val changes: Collection<CompareValueDiff> = emptyList(),
)

View File

@ -0,0 +1,12 @@
package org.jellyfin.openapi.compare.model
import kotlinx.serialization.Serializable
@Serializable
data class CompareOperation(
val name: String,
val pathParameters: CompareCollectionDiff<CompareOperationParameter>,
val queryParameters: CompareCollectionDiff<CompareOperationParameter>,
val changes: Collection<CompareValueDiff>,
)

View File

@ -0,0 +1,10 @@
package org.jellyfin.openapi.compare.model
import kotlinx.serialization.Serializable
@Serializable
data class CompareOperationParameter(
val name: String,
val changes: Collection<CompareValueDiff> = emptyList(),
)

View File

@ -0,0 +1,11 @@
package org.jellyfin.openapi.compare.model
import kotlinx.serialization.Serializable
@Serializable
data class CompareResult(
val binaryDifference: Boolean,
val info: Collection<CompareValueDiff>,
val api: CompareCollectionDiff<CompareOperation>,
val model: CompareCollectionDiff<CompareModel>,
)

View File

@ -0,0 +1,37 @@
package org.jellyfin.openapi.compare.model
import kotlinx.serialization.Serializable
@Serializable
data class CompareValueDiff(val name: String, val from: String, val to: String)
fun <T> buildCompareValueDiffCollection(
a: T?,
b: T,
body: BuildCompareValueDiffCollectionContext<T>.() -> Unit,
): Collection<CompareValueDiff> {
if (a == null) return emptyList()
val context = BuildCompareValueDiffCollectionContext(a, b)
context.body()
return context.values
}
class BuildCompareValueDiffCollectionContext<T>(
private val a: T,
private val b: T,
) {
private val _values = mutableListOf<CompareValueDiff>()
val values get() = _values.toList()
fun <K> detect(selector: T.() -> K, name: String) {
val valueA = a.selector()
val valueB = b.selector()
if (valueA != valueB) add(name, valueA, valueB)
}
fun <K> add(name: String, from: K, to: K) {
_values.add(CompareValueDiff(name, from.toString(), to.toString()))
}
}

View File

@ -0,0 +1,9 @@
package org.jellyfin.openapi.compare.reporter
import org.jellyfin.openapi.compare.model.CompareResult
interface CompareReporter {
val name: String
fun format(result: CompareResult): String
}

View File

@ -0,0 +1,11 @@
package org.jellyfin.openapi.compare.reporter
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.jellyfin.openapi.compare.model.CompareResult
class JsonCompareReporter : CompareReporter {
override val name = "json"
override fun format(result: CompareResult): String = Json.encodeToString(result)
}