diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt index c7fadc5bd..50f271621 100644 --- a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt +++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt @@ -8,12 +8,19 @@ import io.gitlab.arturbosch.detekt.api.Severity import io.gitlab.arturbosch.detekt.api.config import io.gitlab.arturbosch.detekt.api.internal.RequiresTypeResolution import io.gitlab.arturbosch.detekt.rules.fqNameOrNull +import java.io.File +import java.io.FileOutputStream +import java.nio.file.Path +import java.time.OffsetDateTime +import kotlin.io.path.createDirectories import org.jetbrains.kotlin.cfg.getDeclarationDescriptorIncludingConstructors import org.jetbrains.kotlin.com.intellij.openapi.util.TextRange import org.jetbrains.kotlin.com.intellij.psi.PsiComment import org.jetbrains.kotlin.com.intellij.psi.PsiElement import org.jetbrains.kotlin.descriptors.ClassDescriptor +import org.jetbrains.kotlin.descriptors.ClassKind import org.jetbrains.kotlin.descriptors.impl.referencedProperty +import org.jetbrains.kotlin.descriptors.isFinalOrEnum import org.jetbrains.kotlin.diagnostics.DiagnosticUtils.getLineAndColumnInPsiFile import org.jetbrains.kotlin.diagnostics.PsiDiagnosticUtils.LineAndColumn import org.jetbrains.kotlin.name.FqName @@ -22,15 +29,18 @@ import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtDeclaration import org.jetbrains.kotlin.psi.KtElement +import org.jetbrains.kotlin.psi.KtEnumEntry import org.jetbrains.kotlin.psi.KtExpression import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtFunction import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtSuperTypeCallEntry import org.jetbrains.kotlin.psi.KtTypeAlias import org.jetbrains.kotlin.psi.KtTypeParameterList import org.jetbrains.kotlin.psi.KtTypeReference import org.jetbrains.kotlin.psi.KtValueArgument +import org.jetbrains.kotlin.psi.ValueArgument import org.jetbrains.kotlin.psi.psiUtil.children import org.jetbrains.kotlin.psi.psiUtil.getValueParameters import org.jetbrains.kotlin.psi.psiUtil.startOffset @@ -58,11 +68,8 @@ import xyz.block.ftl.Json import xyz.block.ftl.Method import xyz.block.ftl.v1.schema.* import xyz.block.ftl.v1.schema.Array -import java.io.File -import java.io.FileOutputStream -import java.nio.file.Path -import java.time.OffsetDateTime -import kotlin.io.path.createDirectories +import xyz.block.ftl.v1.schema.Enum +import xyz.block.ftl.v1.schema.Verb data class ModuleData(val comments: List = emptyList(), val decls: MutableSet = mutableSetOf()) @@ -132,7 +139,11 @@ class ExtractSchemaRule(config: Config) : Rule(config) { private fun Map.toModules(): List { return this.map { - xyz.block.ftl.v1.schema.Module(name = it.key, decls = it.value.decls.sortedBy { it.data_ == null }, comments = it.value.comments) + xyz.block.ftl.v1.schema.Module( + name = it.key, + decls = it.value.decls.sortedBy { it.data_ == null }, + comments = it.value.comments + ) } } @@ -157,6 +168,7 @@ class SchemaExtractor( *extractVerbs().toTypedArray(), *extractDataDeclarations().toTypedArray(), *extractDatabases().toTypedArray(), + *extractEnums().toTypedArray(), ), comments = moduleComments ) @@ -286,6 +298,15 @@ class SchemaExtractor( .toSet() } + private fun extractEnums(): Set { + return file.children + .filter { it is KtClass && it.isEnum() } + .mapNotNull { + (it as? KtClass)?.toSchemaEnum()?.let { enum -> Decl(enum_ = enum) } + } + .toSet() + } + private fun extractIngress(verb: KtNamedFunction, requestType: Type, responseType: Type): MetadataIngress? { return verb.annotationEntries.firstOrNull { bindingContext.get(BindingContext.ANNOTATION, it)?.fqName?.asString() == HttpIngress::class.qualifiedName @@ -442,10 +463,10 @@ class SchemaExtractor( fields = this.getValueParameters().map { param -> // Metadata containing JSON alias if present. val metadata = param.annotationEntries.firstOrNull { - bindingContext.get(BindingContext.ANNOTATION, it)?.fqName?.asString() == Json::class.qualifiedName - }?.valueArguments?.single()?.let { - listOf(Metadata(alias = MetadataAlias(alias = (it as KtValueArgument).text.trim('"', ' ')))) - } ?: listOf() + bindingContext.get(BindingContext.ANNOTATION, it)?.fqName?.asString() == Json::class.qualifiedName + }?.valueArguments?.single()?.let { + listOf(Metadata(alias = MetadataAlias(alias = (it as KtValueArgument).text.trim('"', ' ')))) + } ?: listOf() Field( name = param.name!!, type = param.typeReference?.let { @@ -467,6 +488,59 @@ class SchemaExtractor( ) } + private fun KtClass.toSchemaEnum(): Enum { + val variants: List + require(this.getValueParameters().isEmpty() || this.getValueParameters().size == 1) { + "${this.getLineAndColumn().toPosition()}: Enums can have at most one value parameter, of type string or number" + } + + if (this.getValueParameters().isEmpty()) { + var ordinal = 0L + variants = this.declarations.filterIsInstance().map { + val variant = EnumVariant( + name = it.name!!, + value_ = Value(intValue = IntValue(value_ = ordinal)) + ) + ordinal = ordinal.inc() + return@map variant + } + } else { + variants = this.declarations.filterIsInstance().map { entry -> + val pos: Position = entry.getLineAndColumn().toPosition() + val name: String = entry.name!! + val arg: ValueArgument = entry.initializerList?.initializers?.single().let { + (it as KtSuperTypeCallEntry).valueArguments.single() + } + + val value: Value + try { + value = arg.getArgumentExpression()?.text?.let { + if (it.startsWith('"')) { + return@let Value(stringValue = StringValue(value_ = it.trim('"'))) + } else { + return@let Value(intValue = IntValue(value_ = it.toLong())) + } + } ?: throw IllegalArgumentException("${pos}: Could not extract enum variant value") + } catch (e: NumberFormatException) { + throw IllegalArgumentException("${pos}: Enum variant value must be a string or number") + } + + EnumVariant( + name = name, + value_ = value, + pos = pos, + ) + } + } + + return Enum( + name = this.name!!, + variants = variants, + comments = this.comments(), + pos = getLineAndColumnInPsiFile(this.containingFile, this.textRange).toPosition(), + ) + } + private fun KotlinType.toSchemaType(position: Position): Type { if (this.unwrap().constructor.isTypeParameterTypeConstructor()) { return Type( @@ -511,7 +585,11 @@ class SchemaExtractor( ?.asString() == OffsetDateTime::class.qualifiedName -> Type(time = xyz.block.ftl.v1.schema.Time()) else -> { - require(this.toClassDescriptor().isData || this.isEmptyBuiltin()) { + require( + this.toClassDescriptor().isData + || this.toClassDescriptor().kind == ClassKind.ENUM_CLASS + || this.isEmptyBuiltin() + ) { "(${position.line},${position.column}) Expected type to be a data class or builtin.Empty, but was ${ this.fqNameOrNull()?.asString() }" diff --git a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRuleTest.kt b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRuleTest.kt index cc5d632a7..7e842f84f 100644 --- a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRuleTest.kt +++ b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRuleTest.kt @@ -3,6 +3,9 @@ package xyz.block.ftl.schemaextractor import io.gitlab.arturbosch.detekt.api.Config import io.gitlab.arturbosch.detekt.rules.KotlinCoreEnvironmentTest import io.gitlab.arturbosch.detekt.test.compileAndLintWithContext +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.assertContains import org.assertj.core.api.Assertions.assertThat import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment import org.jetbrains.kotlin.cli.jvm.config.addJvmClasspathRoots @@ -10,12 +13,29 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import xyz.block.ftl.schemaextractor.ExtractSchemaRule.Companion.OUTPUT_FILENAME -import xyz.block.ftl.v1.schema.* import xyz.block.ftl.v1.schema.Array +import xyz.block.ftl.v1.schema.Data +import xyz.block.ftl.v1.schema.Decl +import xyz.block.ftl.v1.schema.Enum +import xyz.block.ftl.v1.schema.EnumVariant +import xyz.block.ftl.v1.schema.Field +import xyz.block.ftl.v1.schema.IngressPathComponent +import xyz.block.ftl.v1.schema.IngressPathLiteral +import xyz.block.ftl.v1.schema.IntValue import xyz.block.ftl.v1.schema.Map -import java.io.File -import kotlin.test.AfterTest -import kotlin.test.assertContains +import xyz.block.ftl.v1.schema.Metadata +import xyz.block.ftl.v1.schema.MetadataAlias +import xyz.block.ftl.v1.schema.MetadataCalls +import xyz.block.ftl.v1.schema.MetadataIngress +import xyz.block.ftl.v1.schema.Module +import xyz.block.ftl.v1.schema.Optional +import xyz.block.ftl.v1.schema.Position +import xyz.block.ftl.v1.schema.Ref +import xyz.block.ftl.v1.schema.StringValue +import xyz.block.ftl.v1.schema.Type +import xyz.block.ftl.v1.schema.TypeParameter +import xyz.block.ftl.v1.schema.Value +import xyz.block.ftl.v1.schema.Verb @KotlinCoreEnvironmentTest internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { @@ -371,4 +391,141 @@ fun echo(context: Context, req: EchoRequest): EchoResponse { val file = File(OUTPUT_FILENAME) file.delete() } + + @Test + fun `extracts enums`() { + val code = """ + package ftl.things + + import ftl.time.Color + import xyz.block.ftl.Json + import xyz.block.ftl.Context + import xyz.block.ftl.Method + import xyz.block.ftl.Verb + + class InvalidInput(val field: String) : Exception() + + enum class Thing { + A, + B, + C, + } + + /** + * Comments. + */ + enum class StringThing(val value: String) { + A("A"), + B("B"), + C("C"), + } + + enum class IntThing(val value: Int) { + A(1), + B(2), + C(3), + } + + data class Request( + val color: Color, + val thing: Thing, + val stringThing: StringThing, + val intThing: IntThing + ) + + data class Response(val message: String) + + @Verb + fun something(context: Context, req: Request): Response { + return Response(message = "response") + } + """ + ExtractSchemaRule(Config.empty).compileAndLintWithContext(env, code) + val file = File(OUTPUT_FILENAME) + val module = Module.ADAPTER.decode(file.inputStream()) + + val expected = Module( + name = "things", + decls = listOf( + Decl( + data_ = Data( + name = "Request", + fields = listOf( + Field( + name = "color", + type = Type(ref = Ref(name = "Color", module = "time")) + ), + Field( + name = "thing", + type = Type(ref = Ref(name = "Thing", module = "things")) + ), + Field( + name = "stringThing", + type = Type(ref = Ref(name = "StringThing", module = "things")) + ), + Field( + name = "intThing", + type = Type(ref = Ref(name = "IntThing", module = "things")) + ), + ), + ), + ), + Decl( + data_ = Data( + name = "Response", + fields = listOf( + Field( + name = "message", + type = Type(string = xyz.block.ftl.v1.schema.String()) + ) + ), + ), + ), + Decl( + verb = Verb( + name = "something", + request = Type(ref = Ref(name = "Request", module = "things")), + response = Type(ref = Ref(name = "Response", module = "things")), + ), + ), + Decl( + enum_ = Enum( + name = "Thing", + variants = listOf( + EnumVariant(name = "A", value_ = Value(intValue = IntValue(value_ = 0))), + EnumVariant(name = "B", value_ = Value(intValue = IntValue(value_ = 1))), + EnumVariant(name = "C", value_ = Value(intValue = IntValue(value_ = 2))), + ), + ), + ), + Decl( + enum_ = Enum( + name = "StringThing", + comments = listOf("Comments."), + variants = listOf( + EnumVariant(name = "A", value_ = Value(stringValue = StringValue(value_ = "A"))), + EnumVariant(name = "B", value_ = Value(stringValue = StringValue(value_ = "B"))), + EnumVariant(name = "C", value_ = Value(stringValue = StringValue(value_ = "C"))), + ), + ), + ), + Decl( + enum_ = Enum( + name = "IntThing", + variants = listOf( + EnumVariant(name = "A", value_ = Value(intValue = IntValue(value_ = 1))), + EnumVariant(name = "B", value_ = Value(intValue = IntValue(value_ = 2))), + EnumVariant(name = "C", value_ = Value(intValue = IntValue(value_ = 3))), + ), + ), + ), + ) + ) + + assertThat(module) + .usingRecursiveComparison() + .withEqualsForType({ _, _ -> true }, Position::class.java) + .ignoringFieldsMatchingRegexes(".*hashCode\$") + .isEqualTo(expected) + } } diff --git a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/testdata/dependencies/TimeModuleClient.kt b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/testdata/dependencies/TimeModuleClient.kt index d4e64cb5a..cabba3c57 100644 --- a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/testdata/dependencies/TimeModuleClient.kt +++ b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/testdata/dependencies/TimeModuleClient.kt @@ -1,5 +1,3 @@ -// Code generated by FTL-Generator, do not edit. -// package ftl.time import ftl.builtin.Empty @@ -9,10 +7,16 @@ import xyz.block.ftl.Method.GET import xyz.block.ftl.Verb import java.time.OffsetDateTime -public data class TimeResponse( - public val time: OffsetDateTime, +data class TimeResponse( + val time: OffsetDateTime, ) +enum class Color { + RED, + GREEN, + BLUE, +} + /** * Time returns the current time. */ @@ -21,9 +25,9 @@ public data class TimeResponse( GET, "/time", ) -public fun time(context: Context, req: Empty): TimeResponse = +fun time(context: Context, req: Empty): TimeResponse = throw NotImplementedError("Verb stubs should not be called directly, instead use context.call(TimeModuleClient::time, ...)") @Verb -public fun other(context: Context, req: Empty): TimeResponse = +fun other(context: Context, req: Empty): TimeResponse = throw NotImplementedError("Verb stubs should not be called directly, instead use context.call(TimeModuleClient::time, ...)")