Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Enum, V> 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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,63 @@ enum TestEnum {
assertThat(result.errors()).anyMatch(d -> d.getMessage(null).contains("public static"));
}

private void assertWrite(JsonWriter<Object> 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<String> stringWriter() { return com.fasterxml.jackson.core.JsonGenerator::writeString; }

@Root
default String root(ru.tinkoff.kora.json.common.JsonWriter<TestEnum> w) { return ""; }
}
""");
compileResult.assertSuccess();
assertThat(writer("TestApp_TestEnum", stringWriter)).isNotNull();
}

private void assertWrite(JsonWriter<Object> writer, Object value, String expectedJson) {
try {
assertThat(writer.toByteArray(value)).asString(StandardCharsets.UTF_8).isEqualTo(expectedJson);
} catch (IOException e) {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -614,14 +670,14 @@ enum TestEnum {
VALUE1, VALUE2;
@JsonWriter static String toValue(TestEnum e) { return e.name(); }
}

default ru.tinkoff.kora.json.common.JsonWriter<String> stringWriter() { return com.fasterxml.jackson.core.JsonGenerator::writeString; }

@Root
default String root(ru.tinkoff.kora.json.common.JsonWriter<TestEnum> 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"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<KSDeclaration> = 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<Enum, V> 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)
}
}
Loading
Loading