Generate request models

This commit is contained in:
Niels van Velzen 2022-08-06 21:03:14 +02:00 committed by Max Rumpf
parent f8db631fee
commit 2b938a0055
16 changed files with 256 additions and 32 deletions

View File

@ -57,6 +57,10 @@ Hooks are classes that will modify the output of the generator. They should be r
add a special function called `[operation]Url` (like "GetImageUrl") that will return a string
containing the request url.
- **OperationRequestModelHook**
A hook that can request a request model function to be added for an API operation. Compared to the
default function it used a data class to contain all parameters.
- **ServiceNameHook**
A hook that can modify the name(s) of an operation. It can add, rename and delete the services
for an operation. When all services are removed the operation will never be generated. Can be

View File

@ -5,6 +5,7 @@ import org.jellyfin.openapi.builder.api.ApiClientExtensionsBuilder
import org.jellyfin.openapi.builder.api.ApiNameBuilder
import org.jellyfin.openapi.builder.api.ApisBuilder
import org.jellyfin.openapi.builder.api.OperationBuilder
import org.jellyfin.openapi.builder.api.OperationParameterModelBuilder
import org.jellyfin.openapi.builder.api.OperationUrlBuilder
import org.jellyfin.openapi.builder.extra.DeprecatedAnnotationSpecBuilder
import org.jellyfin.openapi.builder.extra.DescriptionBuilder
@ -15,6 +16,7 @@ import org.jellyfin.openapi.builder.model.EnumModelBuilder
import org.jellyfin.openapi.builder.model.ModelBuilder
import org.jellyfin.openapi.builder.model.ModelsBuilder
import org.jellyfin.openapi.builder.model.ObjectModelBuilder
import org.jellyfin.openapi.builder.model.RequestModelBuilder
import org.jellyfin.openapi.builder.openapi.OpenApiApiServicesBuilder
import org.jellyfin.openapi.builder.openapi.OpenApiConstantsBuilder
import org.jellyfin.openapi.builder.openapi.OpenApiDefaultValueBuilder
@ -40,8 +42,9 @@ val mainModule = module {
single { ApiNameBuilder() }
single { OperationBuilder(get(), get()) }
single { OperationUrlBuilder(get(), get()) }
single { ApiBuilder(get(), get(), getAll()) }
single { ApisBuilder(get(), get()) }
single { OperationParameterModelBuilder(get(), get()) }
single { ApiBuilder(get(), get(), get(), get(), getAll(), getAll(), get()) }
single { ApisBuilder(get()) }
single { ApiClientExtensionsBuilder(get()) }
// Models
@ -50,6 +53,7 @@ val mainModule = module {
single { EmptyModelBuilder(get(), get()) }
single { EnumModelBuilder(get(), get()) }
single { ObjectModelBuilder(get(), get(), get()) }
single { RequestModelBuilder(get()) }
// Files
single { FileSpecBuilder() }

View File

@ -5,32 +5,31 @@ import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.KModifier
import com.squareup.kotlinpoet.PropertySpec
import com.squareup.kotlinpoet.TypeSpec
import org.jellyfin.openapi.builder.Builder
import org.jellyfin.openapi.builder.ContextBuilder
import org.jellyfin.openapi.builder.extra.FileSpecBuilder
import org.jellyfin.openapi.builder.model.RequestModelBuilder
import org.jellyfin.openapi.constants.Classes
import org.jellyfin.openapi.constants.Packages
import org.jellyfin.openapi.constants.Strings
import org.jellyfin.openapi.hooks.OperationRequestModelHook
import org.jellyfin.openapi.hooks.OperationUrlHook
import org.jellyfin.openapi.model.ApiService
import org.jellyfin.openapi.model.ApiServiceOperation
import org.jellyfin.openapi.model.GeneratorContext
import org.jellyfin.openapi.model.JellyFile
@Suppress("LongParameterList")
class ApiBuilder(
private val operationBuilder: OperationBuilder,
private val operationUrlBuilder: OperationUrlBuilder,
private val operationParameterModelBuilder: OperationParameterModelBuilder,
private val requestModelBuilder: RequestModelBuilder,
private val operationUrlHooks: Collection<OperationUrlHook>,
) : Builder<ApiService, JellyFile> {
override fun build(data: ApiService): JellyFile = TypeSpec.classBuilder(data.name).apply {
// Add "Api" interface as super
addSuperinterface(ClassName(Packages.API, Classes.API_INTERFACE))
// Add "api" value to constructor
val apiClientType = ClassName(Packages.API_CLIENT, Classes.API_CLIENT)
addProperty(PropertySpec.builder(Strings.API_CLIENT_PARAMETER_NAME, apiClientType, KModifier.PRIVATE)
.initializer(Strings.API_CLIENT_PARAMETER_NAME).build())
primaryConstructor(FunSpec.constructorBuilder().addParameter(Strings.API_CLIENT_PARAMETER_NAME, apiClientType)
.build())
// Handle deprecated members
val operations = data.operations.map { namedOperation ->
private val operationRequestModelHooks: Collection<OperationRequestModelHook>,
private val fileSpecBuilder: FileSpecBuilder,
) : ContextBuilder<ApiService, Unit> {
private fun buildAdditionalOperations(operations: Collection<ApiServiceOperation>) =
operations.map { namedOperation ->
// Check if any member is deprecated
if (namedOperation.queryParameters.any { it.deprecated }) {
// Return 2 operations, one with and one without deprecated members
@ -49,14 +48,55 @@ class ApiBuilder(
} else listOf(namedOperation)
}.flatten()
private fun buildTypeSpec(context: GeneratorContext, data: ApiService) = TypeSpec.classBuilder(data.name).apply {
// Add "Api" interface as super
addSuperinterface(ClassName(Packages.API, Classes.API_INTERFACE))
// Add "api" value to constructor
val apiClientType = ClassName(Packages.API_CLIENT, Classes.API_CLIENT)
addProperty(
PropertySpec.builder(Strings.API_CLIENT_PARAMETER_NAME, apiClientType, KModifier.PRIVATE)
.initializer(Strings.API_CLIENT_PARAMETER_NAME).build()
)
primaryConstructor(
FunSpec.constructorBuilder().addParameter(Strings.API_CLIENT_PARAMETER_NAME, apiClientType)
.build()
)
// Create additional operations like the deprecated version
val operations = buildAdditionalOperations(data.operations)
// Add operations
operations
.sortedBy { it.name }
.forEach { namedOperation ->
val createRequestModelVariant = operationRequestModelHooks.any {
it.shouldOperationBuildRequestModelFun(data, namedOperation)
}
val createUrlVariant = operationUrlHooks.any {
it.shouldOperationBuildUrlFun(data, namedOperation)
}
// Default variant
addFunction(operationBuilder.build(namedOperation))
if (operationUrlHooks.any { it.shouldOperationBuildUrlFun(data, namedOperation) })
addFunction(operationUrlBuilder.build(namedOperation))
// Request model variant
if (createRequestModelVariant) {
context += fileSpecBuilder.build(requestModelBuilder.build(namedOperation))
addFunction(operationParameterModelBuilder.build(namedOperation))
}
// URL variant
if (createUrlVariant) addFunction(operationUrlBuilder.build(namedOperation))
}
}.build().let { JellyFile(Packages.API, emptySet(), it) }
}
override fun build(context: GeneratorContext, data: ApiService) {
val api = buildTypeSpec(context, data)
.build()
.let { JellyFile(Packages.API, emptySet(), it) }
.let { fileSpecBuilder.build(it) }
context += api
}
}

View File

@ -1,18 +1,13 @@
package org.jellyfin.openapi.builder.api
import org.jellyfin.openapi.builder.ContextBuilder
import org.jellyfin.openapi.builder.extra.FileSpecBuilder
import org.jellyfin.openapi.model.ApiService
import org.jellyfin.openapi.model.GeneratorContext
class ApisBuilder(
private val apiBuilder: ApiBuilder,
private val fileSpecBuilder: FileSpecBuilder,
) : ContextBuilder<Collection<ApiService>, Unit> {
override fun build(context: GeneratorContext, data: Collection<ApiService>) {
for (apiService in data) {
val file = apiBuilder.build(apiService)
context += fileSpecBuilder.build(file)
}
for (apiService in data) apiBuilder.build(context, apiService)
}
}

View File

@ -0,0 +1,74 @@
package org.jellyfin.openapi.builder.api
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.ParameterSpec
import com.squareup.kotlinpoet.buildCodeBlock
import com.squareup.kotlinpoet.withIndent
import net.pearx.kasechange.toPascalCase
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.model.ApiServiceOperation
import org.jellyfin.openapi.model.ApiServiceOperationParameter
import org.jellyfin.openapi.model.DefaultValue
class OperationParameterModelBuilder(
descriptionBuilder: DescriptionBuilder,
deprecatedAnnotationSpecBuilder: DeprecatedAnnotationSpecBuilder,
) : OperationBuilder(descriptionBuilder, deprecatedAnnotationSpecBuilder) {
private fun FunSpec.Builder.addOperationCall(
operationName: String,
requestParameterName: String,
parameters: Collection<String>,
includeData: Boolean,
) = buildCodeBlock {
require(parameters.isNotEmpty()) { "At least 1 parameter expected to generate a valid function call" }
add("return·%N(\n", operationName)
withIndent {
parameters.forEach { parameter ->
addStatement("%1N·=·%2N.%1N,", parameter, requestParameterName)
}
if (includeData) addStatement("%1N·=·%1N,", "data")
}
add(")\n")
}.let(::addCode)
override fun build(data: ApiServiceOperation): FunSpec = buildFunctionShell(data).apply {
val requestParameterType = ClassName(
Packages.MODEL_REQUEST,
data.name.toPascalCase() + Strings.MODEL_REQUEST_SUFFIX
)
val parameters = data.pathParameters + data.queryParameters
ParameterSpec.builder(Strings.MODEL_REQUEST_PARAMETER_NAME, requestParameterType).apply {
addKdoc("%L", Strings.MODEL_REQUEST_PARAMETER_DESCRIPTION)
// Add default value is all parameters have a default
if (parameters.all { it.containsDefault() }) defaultValue(
"%T()",
requestParameterType
)
}.build().let(::addParameter)
val includeData = data.bodyType != null
if (includeData) {
addParameter(ParameterSpec.builder("data", data.bodyType!!).apply {
// Set default value to null if parameter is nullable
if (data.bodyType.isNullable) defaultValue("%L", "null")
}.build())
}
addOperationCall(data.name, Strings.MODEL_REQUEST_PARAMETER_NAME, parameters.map { it.name }, includeData)
}.build()
private fun ApiServiceOperationParameter.containsDefault(): Boolean {
if (type.isNullable) return true
if (defaultValue is DefaultValue.Conditional) return defaultValue.modelValue != null
if (defaultValue != null) return true
return false
}
}

View File

@ -13,7 +13,7 @@ class OperationUrlBuilder(
private val deprecatedAnnotationSpecBuilder: DeprecatedAnnotationSpecBuilder,
) : OperationBuilder(descriptionBuilder, deprecatedAnnotationSpecBuilder) {
override fun buildFunctionShell(data: ApiServiceOperation) = FunSpec.builder(
data.name + Strings.URL_OPERATION_SUFFIX
buildFunctionName(data.name)
).apply {
// Add description
descriptionBuilder.build(data.description)?.let {
@ -27,6 +27,8 @@ class OperationUrlBuilder(
returns(Types.STRING)
}
fun buildFunctionName(name: String) = name + Strings.URL_OPERATION_SUFFIX
override fun build(data: ApiServiceOperation): FunSpec = buildFunctionShell(data).apply {
// Add parameters receivers
data.pathParameters

View File

@ -30,6 +30,7 @@ fun ParameterSpec.Builder.defaultValue(
is DefaultValue.Boolean -> defaultValue("%L", defaultValue.value)
is DefaultValue.EnumMember -> defaultValue("%T.%L", defaultValue.enumType, defaultValue.memberName)
is DefaultValue.CodeBlock -> defaultValue(defaultValue.build())
is DefaultValue.Conditional -> defaultValue(type, defaultValue.parameterValue, allowEmptyCollection)
// Set value to null by default for nullable values
null -> when {
typeClassName == Types.COLLECTION && allowEmptyCollection ->

View File

@ -0,0 +1,38 @@
package org.jellyfin.openapi.builder.model
import net.pearx.kasechange.toPascalCase
import org.jellyfin.openapi.builder.Builder
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.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(
ObjectApiModel(
data.name.toPascalCase() + Strings.MODEL_REQUEST_SUFFIX,
data.description,
false,
(data.pathParameters + data.queryParameters).map { parameter ->
ObjectApiModelProperty(
name = parameter.name,
originalName = parameter.originalName,
type = parameter.type,
defaultValue = when (parameter.defaultValue) {
is DefaultValue.Conditional -> parameter.defaultValue.modelValue
else -> parameter.defaultValue
},
description = parameter.description,
deprecated = parameter.deprecated,
)
}.toSet()
)
).copy(
namespace = Packages.MODEL_REQUEST,
)
}

View File

@ -65,9 +65,21 @@ class OpenApiApiServicesBuilder(
path: ApiTypePath,
type: TypeName,
parameterSpec: Parameter,
): DefaultValue? =
defaultValueHooks.firstNotNullOfOrNull { hook -> hook.onBuildDefaultValue(path, type, parameterSpec) }
?: openApiDefaultValueBuilder.build(context, parameterSpec.schema)
): DefaultValue? {
val hookDefault = defaultValueHooks.firstNotNullOfOrNull { hook ->
hook.onBuildDefaultValue(path, type, parameterSpec)
}
val generatorDefault = openApiDefaultValueBuilder.build(context, parameterSpec.schema)
if (hookDefault is DefaultValue.Conditional) {
return hookDefault.copy(
modelValue = hookDefault.modelValue ?: generatorDefault,
parameterValue = hookDefault.parameterValue ?: generatorDefault,
)
}
return generatorDefault
}
private fun buildValidation(schema: Schema<*>): ParameterValidation? = when {
schema is IntegerSchema && schema.minimum != null && schema.maximum != null -> IntRangeValidation(

View File

@ -36,6 +36,11 @@ object Packages {
*/
const val MODEL = "org.jellyfin.sdk.model.api"
/**
* Package for the generated request models
*/
const val MODEL_REQUEST = "org.jellyfin.sdk.model.api.request"
/**
* Package containing all kotlinx.serialization serializers
*/

View File

@ -51,4 +51,19 @@ object Strings {
* Name of the parameter used to reference the HttpClient instance in generated api classes.
*/
const val API_CLIENT_PARAMETER_NAME = "api"
/**
* The suffix added to generated models containing operation parameters.
*/
const val MODEL_REQUEST_SUFFIX = "Request"
/**
* Name of the parameter used for the generated request model containing operation parameters.
*/
const val MODEL_REQUEST_PARAMETER_NAME = "request"
/**
* Description of the parameter used for the generated request model containing operation parameters.
*/
const val MODEL_REQUEST_PARAMETER_DESCRIPTION = "The request paramaters"
}

View File

@ -2,6 +2,7 @@ package org.jellyfin.openapi.hooks
import org.jellyfin.openapi.hooks.api.BinaryOperationUrlHook
import org.jellyfin.openapi.hooks.api.ClientLogOperationUrlHook
import org.jellyfin.openapi.hooks.api.LargeOperationRequestModelHook
import org.jellyfin.openapi.hooks.api.PlayStateServiceNameHook
import org.jellyfin.openapi.hooks.model.DefaultUserIdHook
import org.jellyfin.openapi.hooks.model.ImageMapsHook
@ -16,6 +17,8 @@ val hooksModule = module {
single { BinaryOperationUrlHook() } bind OperationUrlHook::class
single { ClientLogOperationUrlHook() } bind OperationUrlHook::class
single { LargeOperationRequestModelHook() } bind OperationRequestModelHook::class
single { PlayStateServiceNameHook() } bind ServiceNameHook::class
single { DefaultUserIdHook() } bind DefaultValueHook::class

View File

@ -0,0 +1,11 @@
package org.jellyfin.openapi.hooks
import org.jellyfin.openapi.model.ApiService
import org.jellyfin.openapi.model.ApiServiceOperation
interface OperationRequestModelHook {
/**
* Determine if a function should be added to request an endpoint with a model instead of parameters.
*/
fun shouldOperationBuildRequestModelFun(api: ApiService, operation: ApiServiceOperation): Boolean
}

View File

@ -0,0 +1,14 @@
package org.jellyfin.openapi.hooks.api
import org.jellyfin.openapi.hooks.OperationRequestModelHook
import org.jellyfin.openapi.model.ApiService
import org.jellyfin.openapi.model.ApiServiceOperation
class LargeOperationRequestModelHook : OperationRequestModelHook {
companion object {
const val PARAMETER_TRESHOLD = 5
}
override fun shouldOperationBuildRequestModelFun(api: ApiService, operation: ApiServiceOperation): Boolean =
(operation.queryParameters.size + operation.pathParameters.size) >= PARAMETER_TRESHOLD
}

View File

@ -6,6 +6,7 @@ import org.jellyfin.openapi.constants.Types
import org.jellyfin.openapi.hooks.ApiTypePath
import org.jellyfin.openapi.hooks.DefaultValueHook
import org.jellyfin.openapi.model.CurrentUserIdDefaultValue
import org.jellyfin.openapi.model.DefaultValue
class DefaultUserIdHook : DefaultValueHook {
companion object {
@ -20,8 +21,8 @@ class DefaultUserIdHook : DefaultValueHook {
// Check for name and nullability
path.parameter in VALID_NAMES && !type.isNullable -> when (type) {
// Check for supported types
Types.UUID -> CurrentUserIdDefaultValue(false)
Types.STRING -> CurrentUserIdDefaultValue(true)
Types.UUID -> DefaultValue.Conditional(parameterValue = CurrentUserIdDefaultValue(false))
Types.STRING -> DefaultValue.Conditional(parameterValue = CurrentUserIdDefaultValue(true))
else -> null
}
else -> null

View File

@ -29,4 +29,9 @@ sealed interface DefaultValue {
interface CodeBlock : DefaultValue {
fun build(): com.squareup.kotlinpoet.CodeBlock
}
data class Conditional(
val modelValue: DefaultValue? = null,
val parameterValue: DefaultValue? = null,
) : DefaultValue
}