From 7a58d45b5f2ea69d95c877801dbb2182901bd7fe Mon Sep 17 00:00:00 2001 From: "d.sudomoin" Date: Mon, 6 Jul 2026 19:17:14 +0300 Subject: [PATCH] feat(json): allow @JsonWriter on enum instance method and property getter Previously an enum's @JsonWriter value extractor had to be a static method (Kotlin companion object / Java public static) taking the enum and returning the JSON value. This extends the supported forms: an instance method `@JsonWriter fun jsonValue(): V` (0 params, including `@JsonWriter override fun toString()`), and a Kotlin property getter `@get:JsonWriter val value: V` (KSP only - Java has no property concept). The static (companion) form is unchanged and still validated; the two method forms are disambiguated by parameter count (static: 1 param of the enum type; instance: 0 params). Generated code references an instance member as Enum::member (a (Enum) -> V function), the static one via a { e -> Enum.m(e) } lambda - no runtime EnumJsonWriter change. KSP: EnumJsonWriterGenerator.detectWriterMethod now accepts body methods and getter-annotated properties; JsonSymbolProcessor triggers writer generation for a @get:JsonWriter property (handling both KSPropertyDeclaration and KSPropertyGetter, since the reported symbol varies by KSP version). APT: EnumWriterGenerator.detectWriterMethod accepts instance methods; the Enum::method codegen already resolves to Function for both static and instance forms. --- .../processor/writer/EnumWriterGenerator.java | 48 ++++--- .../json/annotation/processor/EnumTest.java | 82 ++++++++++-- .../kora/json/ksp/JsonSymbolProcessor.kt | 25 ++++ .../ksp/writer/EnumJsonWriterGenerator.kt | 76 +++++++---- .../ru/tinkoff/kora/json/ksp/EnumTest.kt | 124 ++++++++++++++++-- 5 files changed, 291 insertions(+), 64 deletions(-) diff --git a/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/writer/EnumWriterGenerator.java b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/writer/EnumWriterGenerator.java index 3c496f993..bea6dfa5f 100644 --- a/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/writer/EnumWriterGenerator.java +++ b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/writer/EnumWriterGenerator.java @@ -47,36 +47,48 @@ public TypeSpec generateEnumWriter(TypeElement typeElement) { @Nullable public EnumValue detectWriterMethod(TypeElement typeElement) { var methods = typeElement.getEnclosedElements().stream() - .filter(e -> e.getKind() == ElementKind.METHOD) - .map(ExecutableElement.class::cast) - .filter(e -> AnnotationUtils.isAnnotationPresent(e, JsonTypes.jsonWriterAnnotation)) - .toList(); + .filter(e -> e.getKind() == ElementKind.METHOD) + .map(ExecutableElement.class::cast) + .filter(e -> AnnotationUtils.isAnnotationPresent(e, JsonTypes.jsonWriterAnnotation)) + .toList(); if (methods.isEmpty()) { return null; } if (methods.size() > 1) { throw new ProcessingErrorException( - "Enum " + typeElement.getSimpleName() + " has multiple @JsonWriter methods, only one is allowed", - methods.get(1) + "Enum " + typeElement.getSimpleName() + " has multiple @JsonWriter methods, only one is allowed", + methods.get(1) ); } var method = methods.get(0); - if (!method.getModifiers().contains(Modifier.PUBLIC) || !method.getModifiers().contains(Modifier.STATIC)) { - throw new ProcessingErrorException("@JsonWriter enum method must be public static", method); - } - if (method.getParameters().size() != 1) { - throw new ProcessingErrorException( - "@JsonWriter method must have exactly one parameter, got " + method.getParameters().size(), - method - ); - } - var enumTypeName = ClassName.get(typeElement); - if (!TypeName.get(method.getParameters().get(0).asType()).equals(enumTypeName)) { - throw new ProcessingErrorException("@JsonWriter method parameter must be of type " + enumTypeName, method); + if (!method.getModifiers().contains(Modifier.PUBLIC)) { + throw new ProcessingErrorException("@JsonWriter method must be public", method); } if (method.getReturnType().getKind() == TypeKind.VOID) { throw new ProcessingErrorException("@JsonWriter method must return a value", method); } + // A static method receives the enum as its single argument; an instance method already has the enum as + // its receiver and therefore must take none. Either way the generated code references it as Enum::method, + // which resolves to Function for both forms, so only the shape is validated here. + var enumTypeName = ClassName.get(typeElement); + if (method.getModifiers().contains(Modifier.STATIC)) { + if (method.getParameters().size() != 1) { + throw new ProcessingErrorException( + "@JsonWriter static method must have exactly one parameter of type " + enumTypeName + ", got " + method.getParameters().size(), + method + ); + } + if (!TypeName.get(method.getParameters().get(0).asType()).equals(enumTypeName)) { + throw new ProcessingErrorException("@JsonWriter static method parameter must be of type " + enumTypeName, method); + } + } else { + if (!method.getParameters().isEmpty()) { + throw new ProcessingErrorException( + "@JsonWriter instance method must have no parameters, got " + method.getParameters().size(), + method + ); + } + } var valueType = TypeName.get(method.getReturnType()); return new EnumValue(valueType, method.getSimpleName().toString()); } diff --git a/json/json-annotation-processor/src/test/java/ru/tinkoff/kora/json/annotation/processor/EnumTest.java b/json/json-annotation-processor/src/test/java/ru/tinkoff/kora/json/annotation/processor/EnumTest.java index 7a8977e54..0cae784ee 100644 --- a/json/json-annotation-processor/src/test/java/ru/tinkoff/kora/json/annotation/processor/EnumTest.java +++ b/json/json-annotation-processor/src/test/java/ru/tinkoff/kora/json/annotation/processor/EnumTest.java @@ -382,7 +382,63 @@ enum TestEnum { assertThat(result.errors()).anyMatch(d -> d.getMessage(null).contains("public static")); } - private void assertWrite(JsonWriter writer, Object value, String expectedJson) { + @Test + public void testEnumWriterFromInstanceMethod() { + compile(""" + @Json + public enum TestEnum { + VALUE1("value1"), VALUE2("value2"); + private final String value; + TestEnum(String value) { this.value = value; } + @JsonWriter public String toValue() { return value; } + } + """); + compileResult.assertSuccess(); + + var w = writer("TestEnum", stringWriter); + assertWrite(w, enumConstant("TestEnum", "VALUE1"), "\"value1\""); + assertWrite(w, enumConstant("TestEnum", "VALUE2"), "\"value2\""); + } + + @Test + public void testEnumWriterInstanceMethodTriggersWithoutJsonAnnotation() { + compile(""" + public enum TestEnum { + VALUE1("value1"), VALUE2("value2"); + private final String value; + TestEnum(String value) { this.value = value; } + @JsonWriter public String toValue() { return value; } + } + """); + compileResult.assertSuccess(); + + var w = writer("TestEnum", stringWriter); + assertWrite(w, enumConstant("TestEnum", "VALUE1"), "\"value1\""); + } + + @Test + public void testEnumWriterInstanceMethodFromExtension() { + compile(List.of(new KoraAppProcessor(), new JsonAnnotationProcessor()), """ + @ru.tinkoff.kora.common.KoraApp + public interface TestApp { + enum TestEnum { + VALUE1("value1"), VALUE2("value2"); + private final String value; + TestEnum(String value) { this.value = value; } + @JsonWriter public String toValue() { return value; } + } + + default ru.tinkoff.kora.json.common.JsonWriter stringWriter() { return com.fasterxml.jackson.core.JsonGenerator::writeString; } + + @Root + default String root(ru.tinkoff.kora.json.common.JsonWriter w) { return ""; } + } + """); + compileResult.assertSuccess(); + assertThat(writer("TestApp_TestEnum", stringWriter)).isNotNull(); + } + + private void assertWrite(JsonWriter writer, Object value, String expectedJson) { try { assertThat(writer.toByteArray(value)).asString(StandardCharsets.UTF_8).isEqualTo(expectedJson); } catch (IOException e) { @@ -519,16 +575,16 @@ public enum TestEnum { } @Test - public void testEnumWriterMethodNotStaticFails() { + public void testEnumWriterInstanceMethodWithParamsFails() { var result = compile(List.of(new JsonAnnotationProcessor()), """ - @Json - public enum TestEnum { - VALUE1, VALUE2; - @JsonWriter public String toValue(TestEnum e) { return e.name(); } - } - """); + @Json + public enum TestEnum { + VALUE1, VALUE2; + @JsonWriter public String toValue(TestEnum e) { return e.name(); } + } + """); assertThat(result.isFailed()).isTrue(); - assertThat(result.errors()).anyMatch(d -> d.getMessage(null).contains("public static")); + assertThat(result.errors()).anyMatch(d -> d.getMessage(null).contains("no parameters")); } @Test @@ -541,7 +597,7 @@ public enum TestEnum { } """); assertThat(result.isFailed()).isTrue(); - assertThat(result.errors()).anyMatch(d -> d.getMessage(null).contains("public static")); + assertThat(result.errors()).anyMatch(d -> d.getMessage(null).contains("must be public")); } @Test @@ -614,14 +670,14 @@ enum TestEnum { VALUE1, VALUE2; @JsonWriter static String toValue(TestEnum e) { return e.name(); } } - + default ru.tinkoff.kora.json.common.JsonWriter stringWriter() { return com.fasterxml.jackson.core.JsonGenerator::writeString; } - + @Root default String root(ru.tinkoff.kora.json.common.JsonWriter w) { return ""; } } """); assertThat(result.isFailed()).isTrue(); - assertThat(result.errors()).anyMatch(d -> d.getMessage(null).contains("public static")); + assertThat(result.errors()).anyMatch(d -> d.getMessage(null).contains("must be public")); } } diff --git a/json/json-symbol-processor/src/main/kotlin/ru/tinkoff/kora/json/ksp/JsonSymbolProcessor.kt b/json/json-symbol-processor/src/main/kotlin/ru/tinkoff/kora/json/ksp/JsonSymbolProcessor.kt index f6f07eeed..a98c12ee5 100644 --- a/json/json-symbol-processor/src/main/kotlin/ru/tinkoff/kora/json/ksp/JsonSymbolProcessor.kt +++ b/json/json-symbol-processor/src/main/kotlin/ru/tinkoff/kora/json/ksp/JsonSymbolProcessor.kt @@ -8,6 +8,8 @@ import com.google.devtools.ksp.processing.SymbolProcessorProvider import com.google.devtools.ksp.symbol.KSAnnotated import com.google.devtools.ksp.symbol.KSClassDeclaration import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSPropertyDeclaration +import com.google.devtools.ksp.symbol.KSPropertyGetter import com.google.devtools.ksp.validate import ru.tinkoff.kora.ksp.common.AnnotationUtils.isAnnotationPresent import ru.tinkoff.kora.ksp.common.BaseSymbolProcessor @@ -42,6 +44,29 @@ class JsonSymbolProcessor( } try { when (it) { + is KSPropertyDeclaration -> { + // @get:JsonWriter on an enum property: @JsonWriter targets METHOD, so on a Kotlin + // property it can only sit on the getter accessor. getSymbolsWithAnnotation may report + // either the property or its getter depending on the KSP version, so both are handled. + if (it.getter?.isAnnotationPresent(JsonTypes.jsonWriterAnnotation) == true) { + val enclosing = it.parentDeclaration as? KSClassDeclaration + if (enclosing == null) { + kspLogger.error("@JsonWriter on a property getter is supported only for an enum property", it) + } else if (processedWriters.add(enclosing.qualifiedName!!.asString())) { + jsonProcessor.generateWriter(enclosing) + } + } + } + + is KSPropertyGetter -> { + val enclosing = it.receiver.parentDeclaration as? KSClassDeclaration + if (enclosing == null) { + kspLogger.error("@JsonWriter on a property getter is supported only for an enum property", it) + } else if (processedWriters.add(enclosing.qualifiedName!!.asString())) { + jsonProcessor.generateWriter(enclosing) + } + } + is KSClassDeclaration -> { if (it.isAnnotationPresent(JsonTypes.json) || (it.isAnnotationPresent(JsonTypes.jsonReaderAnnotation) && it.isAnnotationPresent(JsonTypes.jsonWriterAnnotation))) { if (processedReaders.add(it.qualifiedName!!.asString())) { diff --git a/json/json-symbol-processor/src/main/kotlin/ru/tinkoff/kora/json/ksp/writer/EnumJsonWriterGenerator.kt b/json/json-symbol-processor/src/main/kotlin/ru/tinkoff/kora/json/ksp/writer/EnumJsonWriterGenerator.kt index 4b80da7c7..1155aefb0 100644 --- a/json/json-symbol-processor/src/main/kotlin/ru/tinkoff/kora/json/ksp/writer/EnumJsonWriterGenerator.kt +++ b/json/json-symbol-processor/src/main/kotlin/ru/tinkoff/kora/json/ksp/writer/EnumJsonWriterGenerator.kt @@ -1,7 +1,10 @@ package ru.tinkoff.kora.json.ksp.writer +import com.google.devtools.ksp.getDeclaredProperties import com.google.devtools.ksp.isPublic import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration import com.squareup.kotlinpoet.* import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.ksp.toClassName @@ -68,43 +71,66 @@ class EnumJsonWriterGenerator { val bodyMethods = enumDeclaration.getAllFunctions() .filter { it.isAnnotationPresent(JsonTypes.jsonWriterAnnotation) } .toList() - val methods = companionMethods + bodyMethods - if (methods.isEmpty()) { + // A property annotated at its getter (@get:JsonWriter val value) is also a valid instance value source: + // @JsonWriter targets METHOD, so on a Kotlin property it can only sit on the getter accessor. + val getterProperties = enumDeclaration.getDeclaredProperties() + .filter { it.getter?.isAnnotationPresent(JsonTypes.jsonWriterAnnotation) == true } + .toList() + val targets: List = companionMethods + bodyMethods + getterProperties + if (targets.isEmpty()) { return null } - if (methods.size > 1) { + if (targets.size > 1) { throw ProcessingErrorException( - "Enum ${enumDeclaration.simpleName.asString()} has multiple @JsonWriter methods, only one is allowed", - methods[1] + "Enum ${enumDeclaration.simpleName.asString()} has multiple @JsonWriter members, only one is allowed", + targets[1] ) } - val method = methods[0] - if (method !in companionMethods) { - throw ProcessingErrorException( - "@JsonWriter enum method must be static (declared in the enum's companion object)", - method - ) + val property = getterProperties.firstOrNull() + if (property != null) { + if (!property.isPublic()) { + throw ProcessingErrorException("@JsonWriter property must be public", property) + } + if (property.type.resolve().declaration.qualifiedName?.asString() == "kotlin.Unit") { + throw ProcessingErrorException("@JsonWriter property must have a value type", property) + } + // Referenced in generated code as Enum::property, i.e. a KProperty1 that acts as (Enum) -> V. + return EnumValue(property.type.toTypeName(), property.simpleName.asString(), isStatic = false) } + // A companion-object (static) method receives the enum as its single argument; an instance method + // already has the enum as its receiver and therefore must take none. The parameter count disambiguates + // the two forms, so both are honoured and only their shape is validated. + val method = targets[0] as KSFunctionDeclaration if (!method.isPublic()) { throw ProcessingErrorException("@JsonWriter method must be public", method) } - if (method.parameters.size != 1) { - throw ProcessingErrorException( - "@JsonWriter method must have exactly one parameter, got ${method.parameters.size}", - method - ) - } - val paramDeclaration = method.parameters[0].type.resolve().declaration - if (paramDeclaration != enumDeclaration) { - throw ProcessingErrorException( - "@JsonWriter method parameter must be of type ${enumDeclaration.simpleName.asString()}", - method - ) - } val returnDeclaration = method.returnType?.resolve()?.declaration if (returnDeclaration == null || returnDeclaration.qualifiedName?.asString() == "kotlin.Unit") { throw ProcessingErrorException("@JsonWriter method must return a value", method) } - return EnumValue(method.returnType!!.toTypeName(), method.simpleName.asString(), isStatic = true) + val isStatic = method in companionMethods + if (isStatic) { + if (method.parameters.size != 1) { + throw ProcessingErrorException( + "@JsonWriter static (companion object) method must have exactly one parameter of type ${enumDeclaration.simpleName.asString()}, got ${method.parameters.size}", + method + ) + } + val paramDeclaration = method.parameters[0].type.resolve().declaration + if (paramDeclaration != enumDeclaration) { + throw ProcessingErrorException( + "@JsonWriter static (companion object) method parameter must be of type ${enumDeclaration.simpleName.asString()}", + method + ) + } + } else { + if (method.parameters.isNotEmpty()) { + throw ProcessingErrorException( + "@JsonWriter instance method must have no parameters, got ${method.parameters.size}", + method + ) + } + } + return EnumValue(method.returnType!!.toTypeName(), method.simpleName.asString(), isStatic = isStatic) } } diff --git a/json/json-symbol-processor/src/test/kotlin/ru/tinkoff/kora/json/ksp/EnumTest.kt b/json/json-symbol-processor/src/test/kotlin/ru/tinkoff/kora/json/ksp/EnumTest.kt index 7867cadf0..8f970d743 100644 --- a/json/json-symbol-processor/src/test/kotlin/ru/tinkoff/kora/json/ksp/EnumTest.kt +++ b/json/json-symbol-processor/src/test/kotlin/ru/tinkoff/kora/json/ksp/EnumTest.kt @@ -337,6 +337,114 @@ class EnumTest : AbstractJsonSymbolProcessorTest() { r.assertRead("\"unknown\"", enumConstant("TestEnum", "OTHER")) } + @Test + fun testEnumWriterFromInstanceMethod() { + compile( + """ + @Json + enum class TestEnum(val value: String) { + VALUE1("value1"), VALUE2("value2"); + @JsonWriter fun toValue(): String = value + } + """.trimIndent() + ) + compileResult.assertSuccess() + + val w = writer("TestEnum", stringWriter) + w.assertWrite(enumConstant("TestEnum", "VALUE1"), "\"value1\"") + w.assertWrite(enumConstant("TestEnum", "VALUE2"), "\"value2\"") + } + + @Test + fun testEnumWriterInstanceToStringOverride() { + compile( + """ + @Json + enum class TestEnum(val value: String) { + VALUE1("value1"), VALUE2("value2"); + @JsonWriter override fun toString(): String = value + } + """.trimIndent() + ) + compileResult.assertSuccess() + + val w = writer("TestEnum", stringWriter) + w.assertWrite(enumConstant("TestEnum", "VALUE1"), "\"value1\"") + w.assertWrite(enumConstant("TestEnum", "VALUE2"), "\"value2\"") + } + + @Test + fun testEnumWriterInstanceMethodTriggersWithoutJsonAnnotation() { + compile( + """ + enum class TestEnum(val value: String) { + VALUE1("value1"), VALUE2("value2"); + @JsonWriter fun toValue(): String = value + } + """.trimIndent() + ) + compileResult.assertSuccess() + + val w = writer("TestEnum", stringWriter) + w.assertWrite(enumConstant("TestEnum", "VALUE1"), "\"value1\"") + w.assertWrite(enumConstant("TestEnum", "VALUE2"), "\"value2\"") + } + + @Test + fun testEnumWriterInstanceMethodFromExtension() { + compile0( + listOf(ru.tinkoff.kora.kora.app.ksp.KoraAppProcessorProvider(), JsonSymbolProcessorProvider()), """ + enum class TestEnum(val value: String) { + VALUE1("value1"), VALUE2("value2"); + @JsonWriter fun toValue(): String = value + } + """.trimIndent(), """ + @ru.tinkoff.kora.common.KoraApp + interface TestApp { + fun stringWriter(): ru.tinkoff.kora.json.common.JsonWriter = ru.tinkoff.kora.json.common.JsonWriter { obj, text -> obj.writeString(text) } + + @Root + fun root(w: ru.tinkoff.kora.json.common.JsonWriter) = "" + } + """.trimIndent() + ) + compileResult.assertSuccess() + Assertions.assertThat(writer("TestEnum", stringWriter)).isNotNull() + } + + @Test + fun testEnumWriterFromGetterProperty() { + compile( + """ + @Json + enum class TestEnum(@get:JsonWriter val value: String) { + VALUE1("value1"), VALUE2("value2") + } + """.trimIndent() + ) + compileResult.assertSuccess() + + val w = writer("TestEnum", stringWriter) + w.assertWrite(enumConstant("TestEnum", "VALUE1"), "\"value1\"") + w.assertWrite(enumConstant("TestEnum", "VALUE2"), "\"value2\"") + } + + @Test + fun testEnumWriterGetterPropertyTriggersWithoutJsonAnnotation() { + compile( + """ + enum class TestEnum(@get:JsonWriter val value: String) { + VALUE1("value1"), VALUE2("value2") + } + """.trimIndent() + ) + compileResult.assertSuccess() + + val w = writer("TestEnum", stringWriter) + w.assertWrite(enumConstant("TestEnum", "VALUE1"), "\"value1\"") + w.assertWrite(enumConstant("TestEnum", "VALUE2"), "\"value2\"") + } + private fun enumConstant(className: String, name: String): Any { val clazz = this.compileResult.loadClass(className); require(clazz.isEnum) @@ -508,18 +616,18 @@ class EnumTest : AbstractJsonSymbolProcessorTest() { } @Test - fun testEnumWriterMethodNotStaticFails() { + fun testEnumWriterInstanceMethodWithParamsFails() { compile0( listOf(JsonSymbolProcessorProvider()), """ - @Json - enum class TestEnum(val value: String) { - VALUE1("value1"), VALUE2("value2"); - @JsonWriter fun toValue(e: TestEnum): String = e.value - } - """.trimIndent() + @Json + enum class TestEnum(val value: String) { + VALUE1("value1"), VALUE2("value2"); + @JsonWriter fun toValue(e: TestEnum): String = e.value + } + """.trimIndent() ) Assertions.assertThat(compileResult.isFailed()).isTrue() - Assertions.assertThat(compileResult.messages).anyMatch { it.contains("must be static") } + Assertions.assertThat(compileResult.messages).anyMatch { it.contains("no parameters") } } @Test