Add interfaces and polymorphic serialization to OpenAPI generator

This commit is contained in:
Niels van Velzen 2023-05-14 12:18:30 +02:00 committed by Max Rumpf
parent 88a3e592e5
commit 3c610643ce
21 changed files with 331 additions and 56 deletions

View File

@ -14,6 +14,7 @@ import org.jellyfin.openapi.builder.extra.FileSpecBuilder
import org.jellyfin.openapi.builder.extra.TypeSerializerBuilder
import org.jellyfin.openapi.builder.model.EmptyModelBuilder
import org.jellyfin.openapi.builder.model.EnumModelBuilder
import org.jellyfin.openapi.builder.model.InterfaceModelBuilder
import org.jellyfin.openapi.builder.model.ModelBuilder
import org.jellyfin.openapi.builder.model.ModelsBuilder
import org.jellyfin.openapi.builder.model.ObjectModelBuilder
@ -65,11 +66,12 @@ val mainModule = module {
single { ApiClientExtensionsBuilder(get()) }
// Models
single { ModelBuilder(get(), get(), get()) }
single { ModelBuilder(get(), get(), get(), get()) }
single { ModelsBuilder(get(), get()) }
single { EmptyModelBuilder(get(), get()) }
single { EnumModelBuilder(get(), get()) }
single { ObjectModelBuilder(get(), get(), get()) }
single { InterfaceModelBuilder(get(), get()) }
single { RequestModelBuilder(get()) }
// Files

View File

@ -82,7 +82,7 @@ class ApiBuilder(
// Request model variant
if (createRequestModelVariant) {
context += fileSpecBuilder.build(requestModelBuilder.build(namedOperation))
context += fileSpecBuilder.build(requestModelBuilder.build(context, namedOperation))
addFunction(operationParameterModelBuilder.build(namedOperation))
}

View File

@ -1,5 +1,6 @@
package org.jellyfin.openapi.builder.model
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.TypeSpec
import net.pearx.kasechange.CaseFormat
import net.pearx.kasechange.toPascalCase
@ -20,6 +21,15 @@ class EmptyModelBuilder(
override fun build(data: EmptyApiModel): JellyFile {
return TypeSpec.classBuilder(data.name.toPascalCase(from = CaseFormat.CAPITALIZED_CAMEL))
.apply {
data.interfaces.forEach { interfaceName ->
addSuperinterface(
ClassName(
Packages.MODEL,
interfaceName.toPascalCase(from = CaseFormat.CAPITALIZED_CAMEL)
)
)
}
descriptionBuilder.build(DescriptionType.MODEL, data.description)?.let {
addKdoc("%L", it)
}

View File

@ -28,6 +28,16 @@ class EnumModelBuilder(
override fun build(data: EnumApiModel): JellyFile {
return TypeSpec.enumBuilder(data.name.toPascalCase(from = CaseFormat.CAPITALIZED_CAMEL))
.apply {
// Super
data.interfaces.forEach { interfaceName ->
addSuperinterface(
ClassName(
Packages.MODEL,
interfaceName.toPascalCase(from = CaseFormat.CAPITALIZED_CAMEL)
)
)
}
// Constructor
primaryConstructor(FunSpec.constructorBuilder().apply {
addParameter("serialName", Types.STRING)

View File

@ -0,0 +1,76 @@
package org.jellyfin.openapi.builder.model
import com.squareup.kotlinpoet.AnnotationSpec
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.KModifier
import com.squareup.kotlinpoet.PropertySpec
import com.squareup.kotlinpoet.TypeSpec
import net.pearx.kasechange.CaseFormat
import net.pearx.kasechange.toPascalCase
import org.jellyfin.openapi.builder.ContextBuilder
import org.jellyfin.openapi.builder.extra.DeprecatedAnnotationSpecBuilder
import org.jellyfin.openapi.builder.extra.DescriptionBuilder
import org.jellyfin.openapi.constants.Packages
import org.jellyfin.openapi.constants.Strings
import org.jellyfin.openapi.constants.Types
import org.jellyfin.openapi.model.DescriptionType
import org.jellyfin.openapi.model.GeneratorContext
import org.jellyfin.openapi.model.InterfaceApiModel
import org.jellyfin.openapi.model.JellyFile
class InterfaceModelBuilder(
private val descriptionBuilder: DescriptionBuilder,
private val deprecatedAnnotationSpecBuilder: DeprecatedAnnotationSpecBuilder,
) : ContextBuilder<InterfaceApiModel, JellyFile> {
@Suppress("ComplexMethod")
override fun build(context: GeneratorContext, data: InterfaceApiModel): JellyFile {
val interfacePropertyNames = data.interfaces
.flatMap { (context.getOrGenerateModel(it) as? InterfaceApiModel)?.properties.orEmpty() }
.map { it.name }
// Create class properties
val properties = data.properties.map { property ->
PropertySpec
.builder(property.name, property.type)
.apply {
descriptionBuilder.build(DescriptionType.MODEL_PROPERTY, property.description)?.let {
addKdoc("%L", it)
}
// Add override modifier if in interface
if (property.name in interfacePropertyNames) modifiers += KModifier.OVERRIDE
if (property.deprecated) addAnnotation(deprecatedAnnotationSpecBuilder.build(Strings.DEPRECATED_MEMBER))
}
.build()
}
// Create class
return TypeSpec.interfaceBuilder(data.name.toPascalCase(from = CaseFormat.CAPITALIZED_CAMEL))
.apply {
data.interfaces.forEach { interfaceName ->
addSuperinterface(
ClassName(
Packages.MODEL,
interfaceName.toPascalCase(from = CaseFormat.CAPITALIZED_CAMEL)
)
)
}
modifiers += KModifier.SEALED
descriptionBuilder.build(DescriptionType.MODEL, data.description)?.let {
addKdoc("%L", it)
}
if (data.deprecated) addAnnotation(deprecatedAnnotationSpecBuilder.build(Strings.DEPRECATED_CLASS))
// Only allow serialization when a discriminator is defined
data.polymorphicDiscriminator?.let {
addAnnotation(AnnotationSpec.builder(Types.SERIALIZABLE).build())
addAnnotation(AnnotationSpec.builder(Types.JSON_DISCRIMINATOR).addMember("%S", it).build())
}
}
.addProperties(properties)
.build()
.let { JellyFile(Packages.MODEL, emptySet(), it) }
}
}

View File

@ -1,22 +1,26 @@
package org.jellyfin.openapi.builder.model
import org.jellyfin.openapi.OpenApiGeneratorError
import org.jellyfin.openapi.builder.Builder
import org.jellyfin.openapi.builder.ContextBuilder
import org.jellyfin.openapi.model.ApiModel
import org.jellyfin.openapi.model.EmptyApiModel
import org.jellyfin.openapi.model.EnumApiModel
import org.jellyfin.openapi.model.GeneratorContext
import org.jellyfin.openapi.model.InterfaceApiModel
import org.jellyfin.openapi.model.JellyFile
import org.jellyfin.openapi.model.ObjectApiModel
class ModelBuilder(
private val emptyModelBuilder: EmptyModelBuilder,
private val enumModelBuilder: EnumModelBuilder,
private val objectModelBuilder: ObjectModelBuilder
) : Builder<ApiModel, JellyFile> {
override fun build(data: ApiModel) = when (data) {
private val objectModelBuilder: ObjectModelBuilder,
private val interfaceModelBuilder: InterfaceModelBuilder,
) : ContextBuilder<ApiModel, JellyFile> {
override fun build(context: GeneratorContext, data: ApiModel) = when (data) {
is EmptyApiModel -> emptyModelBuilder.build(data)
is EnumApiModel -> enumModelBuilder.build(data)
is ObjectApiModel -> objectModelBuilder.build(data)
is ObjectApiModel -> objectModelBuilder.build(context, data)
is InterfaceApiModel -> interfaceModelBuilder.build(context, data)
else -> throw OpenApiGeneratorError("Unknown model class ${data::class.qualifiedName}")
}
}

View File

@ -11,7 +11,7 @@ class ModelsBuilder(
) : ContextBuilder<Collection<ApiModel>, Unit> {
override fun build(context: GeneratorContext, data: Collection<ApiModel>) {
for (model in data) {
val file = modelBuilder.build(model)
val file = modelBuilder.build(context, model)
context += fileSpecBuilder.build(file)
}
}

View File

@ -1,6 +1,7 @@
package org.jellyfin.openapi.builder.model
import com.squareup.kotlinpoet.AnnotationSpec
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.KModifier
import com.squareup.kotlinpoet.ParameterSpec
@ -9,7 +10,7 @@ import com.squareup.kotlinpoet.TypeName
import com.squareup.kotlinpoet.TypeSpec
import net.pearx.kasechange.CaseFormat
import net.pearx.kasechange.toPascalCase
import org.jellyfin.openapi.builder.Builder
import org.jellyfin.openapi.builder.ContextBuilder
import org.jellyfin.openapi.builder.extra.DeprecatedAnnotationSpecBuilder
import org.jellyfin.openapi.builder.extra.DescriptionBuilder
import org.jellyfin.openapi.builder.extra.TypeSerializerBuilder
@ -17,7 +18,10 @@ import org.jellyfin.openapi.builder.extra.defaultValue
import org.jellyfin.openapi.constants.Packages
import org.jellyfin.openapi.constants.Strings
import org.jellyfin.openapi.constants.Types
import org.jellyfin.openapi.model.DefaultValue
import org.jellyfin.openapi.model.DescriptionType
import org.jellyfin.openapi.model.GeneratorContext
import org.jellyfin.openapi.model.InterfaceApiModel
import org.jellyfin.openapi.model.JellyFile
import org.jellyfin.openapi.model.ObjectApiModel
@ -25,11 +29,32 @@ class ObjectModelBuilder(
private val descriptionBuilder: DescriptionBuilder,
private val deprecatedAnnotationSpecBuilder: DeprecatedAnnotationSpecBuilder,
private val typeSerializerBuilder: TypeSerializerBuilder
) : Builder<ObjectApiModel, JellyFile> {
@Suppress("ComplexMethod")
override fun build(data: ObjectApiModel): JellyFile {
) : ContextBuilder<ObjectApiModel, JellyFile> {
@Suppress("ComplexMethod", "LoopWithTooManyJumpStatements", "LongMethod", "NestedBlockDepth")
override fun build(context: GeneratorContext, data: ObjectApiModel): JellyFile {
val properties = mutableListOf<PropertySpec>()
val serializers = mutableSetOf<TypeName>()
var polymorphicProperty: String? = null
var polymorphicPropertyValue: String? = null
val superPropertyNames = mutableSetOf<String>()
for (interfaceName in data.interfaces) {
// Add shared properties
val interfaceModel = context.getOrGenerateModel(interfaceName) as? InterfaceApiModel ?: continue
interfaceModel.properties.forEach { superPropertyNames.add(it.name) }
if (interfaceModel.polymorphicDiscriminator == null) continue
// Determine discriminator property name
val discriminator = interfaceModel.polymorphicDiscriminator
require(polymorphicProperty == null || polymorphicProperty == discriminator) {
"Multiple polymorphic interfaces with different discriminator are not supported." +
" (a=$polymorphicProperty, b=$discriminator)."
}
polymorphicProperty = discriminator
}
val constructor = FunSpec.constructorBuilder().apply {
data.properties.forEach { property ->
// Create constructor parameter
@ -46,8 +71,13 @@ class ObjectModelBuilder(
addKdoc("%L", it)
}
// Add override modifier if in interface
if (property.name in superPropertyNames) modifiers += KModifier.OVERRIDE
if (property.deprecated) addAnnotation(deprecatedAnnotationSpecBuilder.build(Strings.DEPRECATED_MEMBER))
addAnnotation(AnnotationSpec.builder(Types.SERIAL_NAME).addMember("%S", property.originalName).build())
addAnnotation(
AnnotationSpec.builder(Types.SERIAL_NAME).addMember("%S", property.originalName).build()
)
}
.build()
)
@ -55,6 +85,19 @@ class ObjectModelBuilder(
// Check if serializer is required
val serializer = typeSerializerBuilder.build(property.type)
if (serializer != null && serializer !in serializers) serializers += serializer
// Save polymorphic value
if (property.originalName == polymorphicProperty) {
require(polymorphicPropertyValue == null) { "Polymorphic property value already set" }
requireNotNull(property.defaultValue) { "Default value must be set for polymorphic property" }
polymorphicPropertyValue = when (property.defaultValue) {
is DefaultValue.String -> property.defaultValue.value
is DefaultValue.EnumMember -> property.defaultValue.serialName
else -> error("Polymorphic property value is of invalid type ${property.defaultValue}")
}
}
}
}.build()
@ -76,12 +119,30 @@ class ObjectModelBuilder(
// Create class
return TypeSpec.classBuilder(data.name.toPascalCase(from = CaseFormat.CAPITALIZED_CAMEL))
.apply {
data.interfaces.forEach { interfaceName ->
addSuperinterface(
ClassName(
Packages.MODEL,
interfaceName.toPascalCase(from = CaseFormat.CAPITALIZED_CAMEL)
)
)
}
modifiers += KModifier.DATA
descriptionBuilder.build(DescriptionType.MODEL, data.description)?.let {
addKdoc("%L", it)
}
if (data.deprecated) addAnnotation(deprecatedAnnotationSpecBuilder.build(Strings.DEPRECATED_CLASS))
addAnnotation(AnnotationSpec.builder(Types.SERIALIZABLE).build())
if (polymorphicProperty != null) {
addAnnotation(
AnnotationSpec.builder(Types.SERIAL_NAME).addMember(
"%S",
requireNotNull(polymorphicPropertyValue) { "Polymorphic property value is missing" }
).build()
)
}
}
.primaryConstructor(constructor)
.addProperties(properties)

View File

@ -1,23 +1,26 @@
package org.jellyfin.openapi.builder.model
import net.pearx.kasechange.toPascalCase
import org.jellyfin.openapi.builder.Builder
import org.jellyfin.openapi.builder.ContextBuilder
import org.jellyfin.openapi.constants.Packages
import org.jellyfin.openapi.constants.Strings
import org.jellyfin.openapi.model.ApiServiceOperation
import org.jellyfin.openapi.model.DefaultValue
import org.jellyfin.openapi.model.GeneratorContext
import org.jellyfin.openapi.model.JellyFile
import org.jellyfin.openapi.model.ObjectApiModel
import org.jellyfin.openapi.model.ObjectApiModelProperty
class RequestModelBuilder(
private val objectModelBuilder: ObjectModelBuilder,
) : Builder<ApiServiceOperation, JellyFile> {
override fun build(data: ApiServiceOperation): JellyFile = objectModelBuilder.build(
) : ContextBuilder<ApiServiceOperation, JellyFile> {
override fun build(context: GeneratorContext, data: ApiServiceOperation): JellyFile = objectModelBuilder.build(
context,
ObjectApiModel(
data.name.toPascalCase() + Strings.MODEL_REQUEST_SUFFIX,
data.description,
false,
emptySet(),
(data.pathParameters + data.queryParameters).map { parameter ->
ObjectApiModelProperty(
name = parameter.name,

View File

@ -34,7 +34,8 @@ class OpenApiDefaultValueBuilder : ContextBuilder<Schema<Any>, DefaultValue?> {
return DefaultValue.EnumMember(
enumType = ClassName(Packages.MODEL, model.name),
memberName = schemaDefault.toScreamingSnakeCase(from = CaseFormat.CAPITALIZED_CAMEL)
memberName = schemaDefault.toScreamingSnakeCase(from = CaseFormat.CAPITALIZED_CAMEL),
serialName = schemaDefault,
)
}

View File

@ -9,6 +9,8 @@ import org.jellyfin.openapi.model.ApiModel
import org.jellyfin.openapi.model.EmptyApiModel
import org.jellyfin.openapi.model.EnumApiModel
import org.jellyfin.openapi.model.GeneratorContext
import org.jellyfin.openapi.model.InterfaceApiModel
import org.jellyfin.openapi.model.InterfaceApiModelProperty
import org.jellyfin.openapi.model.ObjectApiModel
import org.jellyfin.openapi.model.ObjectApiModelProperty
@ -16,42 +18,102 @@ class OpenApiModelBuilder(
private val openApiTypeBuilder: OpenApiTypeBuilder,
private val openApiDefaultValueBuilder: OpenApiDefaultValueBuilder,
) : ContextBuilder<Schema<Any>, ApiModel> {
override fun build(context: GeneratorContext, data: Schema<Any>): ApiModel = when {
// Object
data.type == "object" -> when (data.properties.isNullOrEmpty()) {
// No properties use the empty model
true -> EmptyApiModel(
data.name,
data.description,
data.deprecated == true
)
// Otherwise use the object model
false -> ObjectApiModel(
data.name,
data.description,
data.deprecated == true,
data.properties.map { (originalName, property) ->
val name = originalName.toCamelCase(from = CaseFormat.CAPITALIZED_CAMEL)
ObjectApiModelProperty(
name = name,
originalName = originalName,
defaultValue = openApiDefaultValueBuilder.build(context, property),
type = openApiTypeBuilder.build(ModelTypePath(data.name, name), property),
description = property.description,
deprecated = property.deprecated == true
override fun build(context: GeneratorContext, data: Schema<Any>): ApiModel {
return when {
// Object
data.type == "object" -> when {
// When properties set, use the object model
!data.properties.isNullOrEmpty() -> buildObjectModel(context, data)
// When oneOf set, use the interface model
!data.oneOf.isNullOrEmpty() -> buildInterfaceModel(context, data)
// Otherwise use the empty model
else -> buildEmptyModel(data)
}
// Enum
data.enum.isNotEmpty() -> buildEnumModel(data)
// Unknown type
else -> throw NotImplementedError("Unknown top-level type: ${data.type} for ${data.name}")
}
}
private fun buildInterfaceModel(context: GeneratorContext, data: Schema<Any>): InterfaceApiModel {
val referencedModels = data.oneOf.mapNotNull { it.`$ref`?.let(context::getOrGenerateModel) }
var sharedProperties: Set<InterfaceApiModelProperty>? = null
for ((index, model) in referencedModels.withIndex()) {
// Add interface to referenced model
context.addModelInterface(model, data.name)
// Only search for shared properties if the lookup didn't fail before this iteration
if (sharedProperties == null && index != 0) continue
// Find properties of current model
val modelProperties = when (model) {
is ObjectApiModel -> model.properties.map {
InterfaceApiModelProperty(
name = it.name,
originalName = it.originalName,
type = it.type,
description = it.description,
deprecated = it.deprecated,
)
}.toSet()
)
}
// Enum
data.enum.isNotEmpty() -> EnumApiModel(
data.name,
data.description,
data.deprecated == true,
data.enum.orEmpty().map { it.toString() }.toSet()
)
is InterfaceApiModel -> model.properties
else -> null
}
// Unknown type
else -> throw NotImplementedError("Unknown top-level type: ${data.type} for ${data.name}")
// Search for shared properties between previous and current iteration
sharedProperties = when {
// Unsupported model type
modelProperties == null -> null
// First iteration
sharedProperties == null -> modelProperties
// Compare with existing properties
else -> sharedProperties.filter { a -> modelProperties.any { b -> a.typeMatches(b) } }.toSet()
}
}
return InterfaceApiModel(
name = data.name,
description = data.description,
deprecated = data.deprecated == true,
interfaces = emptySet(),
polymorphicDiscriminator = data.discriminator?.propertyName,
properties = sharedProperties.orEmpty(),
)
}
private fun buildEmptyModel(data: Schema<Any>) = EmptyApiModel(
name = data.name,
description = data.description,
deprecated = data.deprecated == true,
interfaces = emptySet(),
)
private fun buildObjectModel(context: GeneratorContext, data: Schema<Any>) = ObjectApiModel(
name = data.name,
description = data.description,
deprecated = data.deprecated == true,
interfaces = emptySet(),
properties = data.properties.map { (originalName, property) ->
val name = originalName.toCamelCase(from = CaseFormat.CAPITALIZED_CAMEL)
ObjectApiModelProperty(
name = name,
originalName = originalName,
defaultValue = openApiDefaultValueBuilder.build(context, property),
type = openApiTypeBuilder.build(ModelTypePath(data.name, name), property),
description = property.description,
deprecated = property.deprecated == true,
)
}.toSet()
)
private fun buildEnumModel(data: Schema<Any>) = EnumApiModel(
name = data.name,
description = data.description,
deprecated = data.deprecated == true,
interfaces = emptySet(),
constants = data.enum.orEmpty().map { it.toString() }.toSet(),
)
}

View File

@ -33,4 +33,5 @@ object Types {
val SERIALIZABLE = ClassName("kotlinx.serialization", "Serializable")
val SERIAL_NAME = ClassName("kotlinx.serialization", "SerialName")
val USE_SERIALIZERs = ClassName("kotlinx.serialization", "UseSerializers")
val JSON_DISCRIMINATOR = ClassName("kotlinx.serialization.json", "JsonClassDiscriminator")
}

View File

@ -4,4 +4,5 @@ interface ApiModel {
val name: String
val description: String?
val deprecated: Boolean
val interfaces: Set<String>
}

View File

@ -12,7 +12,11 @@ sealed interface DefaultValue {
@JvmInline
value class Boolean(val value: kotlin.Boolean) : DefaultValue
data class EnumMember(val enumType: TypeName, val memberName: kotlin.String) : DefaultValue
data class EnumMember(
val enumType: TypeName,
val memberName: kotlin.String,
val serialName: kotlin.String,
) : DefaultValue
/**
* Custom value builder used in hooks.

View File

@ -3,5 +3,6 @@ package org.jellyfin.openapi.model
data class EmptyApiModel(
override val name: String,
override val description: String?,
override val deprecated: Boolean
override val deprecated: Boolean,
override val interfaces: Set<String>,
) : ApiModel

View File

@ -4,5 +4,6 @@ data class EnumApiModel(
override val name: String,
override val description: String?,
override val deprecated: Boolean,
val constants: Set<String>
override val interfaces: Set<String>,
val constants: Set<String>,
) : ApiModel

View File

@ -7,6 +7,7 @@ import io.swagger.v3.oas.models.media.Schema
import io.swagger.v3.parser.core.models.SwaggerParseResult
import net.pearx.kasechange.CaseFormat
import net.pearx.kasechange.toPascalCase
import org.jellyfin.openapi.OpenApiGeneratorError
import org.jellyfin.openapi.builder.openapi.OpenApiModelBuilder
class GeneratorContext(
@ -36,6 +37,18 @@ class GeneratorContext(
}
}
fun addModelInterface(model: ApiModel, interfaceName: String) {
val interfaces = model.interfaces + interfaceName
_models[model.name] = when (model) {
is EmptyApiModel -> model.copy(interfaces = interfaces)
is EnumApiModel -> model.copy(interfaces = interfaces)
is InterfaceApiModel -> model.copy(interfaces = interfaces)
is ObjectApiModel -> model.copy(interfaces = interfaces)
else -> throw OpenApiGeneratorError("Unknown model class ${model::class.qualifiedName}")
}
}
operator fun plusAssign(service: ApiService) {
_apiServices.add(service)
}

View File

@ -0,0 +1,10 @@
package org.jellyfin.openapi.model
data class InterfaceApiModel(
override val name: String,
override val description: String?,
override val deprecated: Boolean,
override val interfaces: Set<String>,
val polymorphicDiscriminator: String?,
val properties: Set<InterfaceApiModelProperty>,
) : ApiModel

View File

@ -0,0 +1,14 @@
package org.jellyfin.openapi.model
import com.squareup.kotlinpoet.TypeName
data class InterfaceApiModelProperty(
val name: String,
val originalName: String,
val type: TypeName,
val description: String?,
val deprecated: Boolean,
) {
fun typeMatches(other: InterfaceApiModelProperty): Boolean =
other === this || (name == other.name && originalName == other.originalName && type == other.type)
}

View File

@ -4,5 +4,6 @@ data class ObjectApiModel(
override val name: String,
override val description: String?,
override val deprecated: Boolean,
val properties: Set<ObjectApiModelProperty>
override val interfaces: Set<String>,
val properties: Set<ObjectApiModelProperty>,
) : ApiModel

View File

@ -8,5 +8,5 @@ data class ObjectApiModelProperty(
val defaultValue: DefaultValue?,
val type: TypeName,
val description: String?,
val deprecated: Boolean
val deprecated: Boolean,
)