From bea6e72c36b9e743efd05f817026ed353f4a91b7 Mon Sep 17 00:00:00 2001 From: Roman Mitasov Date: Fri, 26 Jul 2024 13:01:23 +0300 Subject: [PATCH] Draft: @JsonUnboxed annotation --- .../processor/common/CompileResult.java | 4 +- .../annotation/processor/JsonProcessor.java | 65 ++++ .../json/annotation/processor/JsonTypes.java | 1 + .../processor/reader/JsonReaderGenerator.java | 2 +- .../reader/ReaderTypeMetaParser.java | 24 ++ .../reader/UnboxedReaderGenerator.java | 93 +++++ .../processor/reader/UnboxedReaderMeta.java | 10 + .../writer/UnboxedWriterGenerator.java | 68 ++++ .../processor/writer/UnboxedWriterMeta.java | 16 + .../writer/WriterTypeMetaParser.java | 28 ++ .../annotation/processor/UnboxedTest.java | 324 ++++++++++++++++++ .../json/common/annotation/JsonUnboxed.java | 50 +++ 12 files changed, 682 insertions(+), 3 deletions(-) create mode 100644 json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/reader/UnboxedReaderGenerator.java create mode 100644 json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/reader/UnboxedReaderMeta.java create mode 100644 json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/writer/UnboxedWriterGenerator.java create mode 100644 json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/writer/UnboxedWriterMeta.java create mode 100644 json/json-annotation-processor/src/test/java/ru/tinkoff/kora/json/annotation/processor/UnboxedTest.java create mode 100644 json/json-common/src/main/java/ru/tinkoff/kora/json/common/annotation/JsonUnboxed.java diff --git a/annotation-processor-common/src/testFixtures/java/ru/tinkoff/kora/annotation/processor/common/CompileResult.java b/annotation-processor-common/src/testFixtures/java/ru/tinkoff/kora/annotation/processor/common/CompileResult.java index 011410830..70d0faa22 100644 --- a/annotation-processor-common/src/testFixtures/java/ru/tinkoff/kora/annotation/processor/common/CompileResult.java +++ b/annotation-processor-common/src/testFixtures/java/ru/tinkoff/kora/annotation/processor/common/CompileResult.java @@ -71,9 +71,9 @@ public RuntimeException compilationException() { .filter(d -> d.getKind() == Diagnostic.Kind.ERROR) .map(Object::toString) .collect(Collectors.joining("\n")); - throw new RuntimeException("CompilationError: \n" + errors.indent(2) + "\n" + j.toString().indent(2)); + return new RuntimeException("CompilationError: \n" + errors.indent(2) + "\n" + j.toString().indent(2)); } catch (IOException e) { - throw new RuntimeException(e); + return new RuntimeException(e); } } diff --git a/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/JsonProcessor.java b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/JsonProcessor.java index 1c1fe83f3..6429907d5 100644 --- a/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/JsonProcessor.java +++ b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/JsonProcessor.java @@ -1,18 +1,23 @@ package ru.tinkoff.kora.json.annotation.processor; import com.squareup.javapoet.JavaFile; +import javax.lang.model.element.AnnotationMirror; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import ru.tinkoff.kora.annotation.processor.common.AnnotationUtils; import ru.tinkoff.kora.annotation.processor.common.CommonUtils; import ru.tinkoff.kora.annotation.processor.common.ComparableTypeMirror; +import ru.tinkoff.kora.annotation.processor.common.ProcessingErrorException; import ru.tinkoff.kora.annotation.processor.common.SealedTypeUtils; import ru.tinkoff.kora.json.annotation.processor.reader.EnumReaderGenerator; import ru.tinkoff.kora.json.annotation.processor.reader.JsonReaderGenerator; +import ru.tinkoff.kora.json.annotation.processor.reader.UnboxedReaderGenerator; import ru.tinkoff.kora.json.annotation.processor.reader.ReaderTypeMetaParser; import ru.tinkoff.kora.json.annotation.processor.reader.SealedInterfaceReaderGenerator; import ru.tinkoff.kora.json.annotation.processor.writer.EnumWriterGenerator; import ru.tinkoff.kora.json.annotation.processor.writer.JsonWriterGenerator; import ru.tinkoff.kora.json.annotation.processor.writer.SealedInterfaceWriterGenerator; +import ru.tinkoff.kora.json.annotation.processor.writer.UnboxedWriterGenerator; import ru.tinkoff.kora.json.annotation.processor.writer.WriterTypeMetaParser; import javax.annotation.processing.ProcessingEnvironment; @@ -38,6 +43,8 @@ public class JsonProcessor { private final SealedInterfaceWriterGenerator sealedWriterGenerator; private final EnumReaderGenerator enumReaderGenerator; private final EnumWriterGenerator enumWriterGenerator; + private final UnboxedReaderGenerator unboxedReaderGenerator; + private final UnboxedWriterGenerator unboxedWriterGenerator; public JsonProcessor(ProcessingEnvironment processingEnv) { this.processingEnv = processingEnv; @@ -52,6 +59,8 @@ public JsonProcessor(ProcessingEnvironment processingEnv) { this.sealedWriterGenerator = new SealedInterfaceWriterGenerator(this.processingEnv); this.enumReaderGenerator = new EnumReaderGenerator(); this.enumWriterGenerator = new EnumWriterGenerator(); + this.unboxedReaderGenerator = new UnboxedReaderGenerator(); + this.unboxedWriterGenerator = new UnboxedWriterGenerator(); } public void generateReader(TypeElement jsonElement) { @@ -62,6 +71,13 @@ public void generateReader(TypeElement jsonElement) { if (readerElement != null) { return; } + + var unboxedAnnotation = AnnotationUtils.findAnnotation(jsonElement, JsonTypes.jsonUnboxed); + if (unboxedAnnotation != null) { + this.generateUnboxedReader(jsonElement, jsonElementType, unboxedAnnotation); + return; + } + if (jsonElement.getKind() == ElementKind.ENUM) { this.generateEnumReader(jsonElement); return; @@ -98,6 +114,22 @@ private void generateDtoReader(TypeElement typeElement, TypeMirror jsonTypeMirro CommonUtils.safeWriteTo(this.processingEnv, javaFile); } + private void generateUnboxedReader( + TypeElement typeElement, + TypeMirror jsonTypeMirror, + AnnotationMirror unboxedAnnotation + ) { + checkUnboxedTarget(typeElement, unboxedAnnotation); + + var packageElement = JsonUtils.jsonClassPackage(this.elements, typeElement); + var meta = Objects.requireNonNull(this.readerTypeMetaParser.parseUnboxed(typeElement, jsonTypeMirror)); + var readerType = Objects.requireNonNull(this.unboxedReaderGenerator.generateForUnboxed(meta)); + + var javaFile = JavaFile.builder(packageElement, readerType).build(); + + CommonUtils.safeWriteTo(this.processingEnv, javaFile); + } + private void generateEnumWriter(TypeElement jsonElement) { var packageElement = JsonUtils.jsonClassPackage(this.elements, jsonElement); var enumWriterType = this.enumWriterGenerator.generateEnumWriter(jsonElement); @@ -113,6 +145,11 @@ public void generateWriter(TypeElement jsonElement) { if (writerElement != null) { return; } + var unboxedAnnotation = AnnotationUtils.findAnnotation(jsonElement, JsonTypes.jsonUnboxed); + if (unboxedAnnotation != null) { + this.generateUnboxedWriter(jsonElement, jsonElement.asType(), unboxedAnnotation); + return; + } if (jsonElement.getKind() == ElementKind.ENUM) { this.generateEnumWriter(jsonElement); return; @@ -133,6 +170,22 @@ private void generateSealedWriter(TypeElement jsonElement) { CommonUtils.safeWriteTo(this.processingEnv, javaFile); } + private void generateUnboxedWriter( + TypeElement typeElement, + TypeMirror jsonTypeMirror, + AnnotationMirror unboxedAnnotation + ) { + checkUnboxedTarget(typeElement, unboxedAnnotation); + + var meta = Objects.requireNonNull(this.writerTypeMetaParser.parseUnboxed(typeElement, jsonTypeMirror)); + + var writerType = Objects.requireNonNull(this.unboxedWriterGenerator.generate(meta)); + var packageElement = JsonUtils.jsonClassPackage(this.elements, typeElement); + var javaFile = JavaFile.builder(packageElement, writerType).build(); + + CommonUtils.safeWriteTo(this.processingEnv, javaFile); + } + private void tryGenerateWriter(TypeElement jsonElement, TypeMirror jsonTypeMirror) { var meta = Objects.requireNonNull(this.writerTypeMetaParser.parse(jsonElement, jsonTypeMirror)); var packageElement = JsonUtils.jsonClassPackage(this.elements, jsonElement); @@ -141,4 +194,16 @@ private void tryGenerateWriter(TypeElement jsonElement, TypeMirror jsonTypeMirro var javaFile = JavaFile.builder(packageElement, writerType).build(); CommonUtils.safeWriteTo(this.processingEnv, javaFile); } + + private void checkUnboxedTarget(TypeElement typeElement, AnnotationMirror unboxedAnnotation) { + var kind = typeElement.getKind(); + + if (kind != ElementKind.CLASS && kind != ElementKind.RECORD) { + throw new ProcessingErrorException( + "@JsonUnboxed supported only for classes and records", + typeElement, + unboxedAnnotation + ); + } + } } diff --git a/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/JsonTypes.java b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/JsonTypes.java index 3f840d456..107c767b9 100644 --- a/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/JsonTypes.java +++ b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/JsonTypes.java @@ -7,6 +7,7 @@ public class JsonTypes { public static final ClassName jsonInclude = ClassName.get("ru.tinkoff.kora.json.common.annotation", "JsonInclude"); public static final ClassName jsonDiscriminatorField = ClassName.get("ru.tinkoff.kora.json.common.annotation", "JsonDiscriminatorField"); public static final ClassName jsonDiscriminatorValue = ClassName.get("ru.tinkoff.kora.json.common.annotation", "JsonDiscriminatorValue"); + public static final ClassName jsonUnboxed = ClassName.get("ru.tinkoff.kora.json.common.annotation", "JsonUnboxed"); public static final ClassName jsonReaderAnnotation = ClassName.get("ru.tinkoff.kora.json.common.annotation", "JsonReader"); diff --git a/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/reader/JsonReaderGenerator.java b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/reader/JsonReaderGenerator.java index 4c1f5676a..f3cd400f6 100644 --- a/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/reader/JsonReaderGenerator.java +++ b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/reader/JsonReaderGenerator.java @@ -65,7 +65,7 @@ private TypeSpec generateForClass(JsonClassReaderMeta meta) { .addModifiers(Modifier.PUBLIC, Modifier.FINAL) .addException(IOException.class) .addParameter(JsonTypes.jsonParser, "_parser") - .returns(TypeName.get(meta.typeElement().asType())) + .returns(TypeName.get(meta.typeMirror())) .addAnnotation(Override.class) .addAnnotation(Nullable.class); method.addStatement("var _token = _parser.currentToken()"); diff --git a/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/reader/ReaderTypeMetaParser.java b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/reader/ReaderTypeMetaParser.java index 3e024b29d..57fc4233e 100644 --- a/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/reader/ReaderTypeMetaParser.java +++ b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/reader/ReaderTypeMetaParser.java @@ -49,6 +49,30 @@ public JsonClassReaderMeta parse(TypeElement jsonClass, TypeMirror typeMirror) t return new JsonClassReaderMeta(typeMirror, jsonClass, fields); } + public UnboxedReaderMeta parseUnboxed(TypeElement jsonClass, TypeMirror typeMirror) throws ProcessingErrorException { + if (jsonClass.getKind() != ElementKind.CLASS && jsonClass.getKind() != ElementKind.RECORD) { + throw new IllegalArgumentException("Should not be called for non class elements"); + } + if (jsonClass.getModifiers().contains(Modifier.ABSTRACT)) { + throw new IllegalArgumentException("Should not be called for abstract elements"); + } + + var jsonConstructor = Objects.requireNonNull(this.findJsonConstructor(jsonClass)); + + if (jsonConstructor.getParameters().size() != 1) { + throw new ProcessingErrorException( + "@JsonUnboxed JsonReader can be created only for constructors with single parameter", + jsonConstructor + ); + } + + VariableElement parameter = jsonConstructor.getParameters().get(0); + + var fieldMeta = new UnboxedReaderMeta.FieldMeta(parameter, TypeName.get(parameter.asType())); + + return new UnboxedReaderMeta(typeMirror, jsonClass, fieldMeta); + } + @Nullable public ReaderFieldType parseReaderFieldType(TypeMirror jsonClass) { var knownType = this.knownTypes.detect(jsonClass); diff --git a/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/reader/UnboxedReaderGenerator.java b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/reader/UnboxedReaderGenerator.java new file mode 100644 index 000000000..5c6587830 --- /dev/null +++ b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/reader/UnboxedReaderGenerator.java @@ -0,0 +1,93 @@ +package ru.tinkoff.kora.json.annotation.processor.reader; + +import com.squareup.javapoet.AnnotationSpec; +import com.squareup.javapoet.CodeBlock; +import com.squareup.javapoet.FieldSpec; +import com.squareup.javapoet.MethodSpec; +import com.squareup.javapoet.ParameterSpec; +import com.squareup.javapoet.ParameterizedTypeName; +import com.squareup.javapoet.TypeName; +import com.squareup.javapoet.TypeSpec; +import com.squareup.javapoet.TypeVariableName; +import jakarta.annotation.Nullable; +import java.io.IOException; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeParameterElement; +import ru.tinkoff.kora.annotation.processor.common.CommonClassNames; +import ru.tinkoff.kora.annotation.processor.common.CommonUtils; +import ru.tinkoff.kora.json.annotation.processor.JsonTypes; +import ru.tinkoff.kora.json.annotation.processor.JsonUtils; + +public class UnboxedReaderGenerator { + + public TypeSpec generateForUnboxed(UnboxedReaderMeta meta) { + + var typeBuilder = TypeSpec.classBuilder(JsonUtils.jsonReaderName(meta.typeElement())) + .addAnnotation(AnnotationSpec.builder(CommonClassNames.koraGenerated) + .addMember("value", CodeBlock.of("$S", UnboxedReaderGenerator.class.getCanonicalName())) + .build()) + .addSuperinterface(ParameterizedTypeName.get(JsonTypes.jsonReader, TypeName.get(meta.typeMirror()))) + .addModifiers(Modifier.PUBLIC, Modifier.FINAL) + .addOriginatingElement(meta.typeElement()); + + for (TypeParameterElement typeParameter : meta.typeElement().getTypeParameters()) { + typeBuilder.addTypeVariable(TypeVariableName.get(typeParameter)); + } + + var field = meta.field(); + + var fieldName = this.readerFieldName(field); + var fieldType = ParameterizedTypeName.get(JsonTypes.jsonReader, field.typeName()); + var readerField = FieldSpec.builder(fieldType, fieldName, Modifier.PRIVATE, Modifier.FINAL).build(); + var readerParameter = ParameterSpec.builder(fieldType, fieldName).build(); + + var constructor = MethodSpec.constructorBuilder() + .addModifiers(Modifier.PUBLIC) + .addParameter(readerParameter) + .addStatement("this.$N = $N", readerField, readerParameter); + + typeBuilder.addField(readerField); + typeBuilder.addMethod(constructor.build()); + + var method = MethodSpec.methodBuilder("read") + .addModifiers(Modifier.PUBLIC, Modifier.FINAL) + .addException(IOException.class) + .addParameter(JsonTypes.jsonParser, "_parser") + .returns(TypeName.get(meta.typeMirror())) + .addAnnotation(Override.class) + .addAnnotation(Nullable.class); + + method.addStatement("var _token = _parser.currentToken()"); + method.beginControlFlow("if (_token == $T.VALUE_NULL)", JsonTypes.jsonToken); + + if (isNullable(field)) { + method.addStatement("return new $T(null)", meta.typeElement()); + } else { + method.addStatement( + "throw new $T(_parser, $S)", + JsonTypes.jsonParseException, + "Expecting nonnull value, got VALUE_NULL token" + ); + } + + method.endControlFlow(); + + method.addStatement("return new $T($N.read(_parser))", meta.typeElement(), readerField); + + typeBuilder.addMethod(method.build()); + + return typeBuilder.build(); + } + + private String readerFieldName(UnboxedReaderMeta.FieldMeta field) { + return field.parameter().getSimpleName() + "Reader"; + } + + private boolean isNullable(UnboxedReaderMeta.FieldMeta field) { + if (field.parameter().asType().getKind().isPrimitive()) { + return false; + } + + return CommonUtils.isNullable(field.parameter()); + } +} diff --git a/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/reader/UnboxedReaderMeta.java b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/reader/UnboxedReaderMeta.java new file mode 100644 index 000000000..a33d6aa0c --- /dev/null +++ b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/reader/UnboxedReaderMeta.java @@ -0,0 +1,10 @@ +package ru.tinkoff.kora.json.annotation.processor.reader; + +import com.squareup.javapoet.TypeName; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.TypeMirror; + +public record UnboxedReaderMeta(TypeMirror typeMirror, TypeElement typeElement, FieldMeta field) { + public record FieldMeta(VariableElement parameter, TypeName typeName) {} +} diff --git a/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/writer/UnboxedWriterGenerator.java b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/writer/UnboxedWriterGenerator.java new file mode 100644 index 000000000..4d8843dec --- /dev/null +++ b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/writer/UnboxedWriterGenerator.java @@ -0,0 +1,68 @@ +package ru.tinkoff.kora.json.annotation.processor.writer; + +import com.squareup.javapoet.AnnotationSpec; +import com.squareup.javapoet.CodeBlock; +import com.squareup.javapoet.FieldSpec; +import com.squareup.javapoet.MethodSpec; +import com.squareup.javapoet.ParameterSpec; +import com.squareup.javapoet.ParameterizedTypeName; +import com.squareup.javapoet.TypeName; +import com.squareup.javapoet.TypeSpec; +import com.squareup.javapoet.TypeVariableName; +import jakarta.annotation.Nullable; +import java.io.IOException; +import javax.lang.model.element.Modifier; +import ru.tinkoff.kora.annotation.processor.common.CommonClassNames; +import ru.tinkoff.kora.json.annotation.processor.JsonTypes; +import ru.tinkoff.kora.json.annotation.processor.JsonUtils; + +public class UnboxedWriterGenerator { + + @Nullable + public TypeSpec generate(UnboxedWriterMeta meta) { + var typeBuilder = TypeSpec.classBuilder(JsonUtils.jsonWriterName(meta.typeElement())) + .addAnnotation(AnnotationSpec.builder(CommonClassNames.koraGenerated) + .addMember("value", CodeBlock.of("$S", UnboxedWriterGenerator.class.getCanonicalName())) + .build()) + .addSuperinterface(ParameterizedTypeName.get(JsonTypes.jsonWriter, TypeName.get(meta.typeMirror()))) + .addModifiers(Modifier.PUBLIC, Modifier.FINAL) + .addOriginatingElement(meta.typeElement()); + + for (var typeParameter : meta.typeElement().getTypeParameters()) { + typeBuilder.addTypeVariable(TypeVariableName.get(typeParameter)); + } + + var field = meta.field(); + + var fieldName = this.writerFieldName(field); + var fieldType = ParameterizedTypeName.get(JsonTypes.jsonWriter, TypeName.get(field.typeMirror())); + + var writerField = FieldSpec.builder(fieldType, fieldName, Modifier.PRIVATE, Modifier.FINAL).build(); + var writerParameter = ParameterSpec.builder(fieldType, fieldName).build(); + + var constructor = MethodSpec.constructorBuilder() + .addModifiers(Modifier.PUBLIC) + .addParameter(writerParameter) + .addStatement("this.$N = $N", writerField, writerParameter); + + typeBuilder.addField(writerField); + typeBuilder.addMethod(constructor.build()); + + var method = MethodSpec.methodBuilder("write") + .addModifiers(Modifier.PUBLIC, Modifier.FINAL) + .addException(IOException.class) + .addParameter(JsonTypes.jsonGenerator, "_gen") + .addParameter(ParameterSpec.builder(TypeName.get(meta.typeMirror()), "_object").addAnnotation(Nullable.class).build()) + .addAnnotation(Override.class) + .addCode("if (_object == null) {$>\n_gen.writeNull();\nreturn;$<\n}\n"); + + method.addStatement("$N.write(_gen, _object.$L)", writerField, field.accessor()); + + typeBuilder.addMethod(method.build()); + return typeBuilder.build(); + } + + private String writerFieldName(UnboxedWriterMeta.FieldMeta field) { + return field.accessor().getSimpleName() + "Writer"; + } +} diff --git a/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/writer/UnboxedWriterMeta.java b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/writer/UnboxedWriterMeta.java new file mode 100644 index 000000000..0e8ed23bd --- /dev/null +++ b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/writer/UnboxedWriterMeta.java @@ -0,0 +1,16 @@ +package ru.tinkoff.kora.json.annotation.processor.writer; + +import com.squareup.javapoet.TypeName; +import jakarta.annotation.Nullable; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.TypeMirror; + +public record UnboxedWriterMeta(TypeMirror typeMirror, TypeElement typeElement, FieldMeta field) { + public record FieldMeta( + VariableElement field, + TypeMirror typeMirror, + ExecutableElement accessor + ) {} +} diff --git a/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/writer/WriterTypeMetaParser.java b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/writer/WriterTypeMetaParser.java index 9d059b6ed..5bf114b06 100644 --- a/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/writer/WriterTypeMetaParser.java +++ b/json/json-annotation-processor/src/main/java/ru/tinkoff/kora/json/annotation/processor/writer/WriterTypeMetaParser.java @@ -54,6 +54,34 @@ public JsonClassWriterMeta parse(TypeElement jsonClass, TypeMirror typeMirror) { return new JsonClassWriterMeta(typeMirror, jsonClass, fieldMetas); } + public UnboxedWriterMeta parseUnboxed(TypeElement jsonClass, TypeMirror typeMirror) { + if (jsonClass.getKind() != ElementKind.CLASS && jsonClass.getKind() != ElementKind.RECORD) { + throw new IllegalArgumentException("Should not be called for non classes"); + } + if (jsonClass.getModifiers().contains(Modifier.ABSTRACT)) { + throw new IllegalArgumentException("Should not be called for abstract classes"); + } + + var fieldElements = this.parseFields(jsonClass); + + if (fieldElements.size() != 1) { + throw new ProcessingErrorException( + "@JsonUnboxed JsonWriter can be created only for classes with single field", + jsonClass + ); + } + + VariableElement field = fieldElements.get(0); + + var fieldMeta = new UnboxedWriterMeta.FieldMeta( + field, + field.asType(), + this.getAccessorMethod(jsonClass, field) + ); + + return new UnboxedWriterMeta(typeMirror, jsonClass, fieldMeta); + } + private List parseFields(TypeElement typeElement) { return typeElement.getEnclosedElements() .stream() diff --git a/json/json-annotation-processor/src/test/java/ru/tinkoff/kora/json/annotation/processor/UnboxedTest.java b/json/json-annotation-processor/src/test/java/ru/tinkoff/kora/json/annotation/processor/UnboxedTest.java new file mode 100644 index 000000000..52eb9413c --- /dev/null +++ b/json/json-annotation-processor/src/test/java/ru/tinkoff/kora/json/annotation/processor/UnboxedTest.java @@ -0,0 +1,324 @@ +package ru.tinkoff.kora.json.annotation.processor; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonParser; +import java.io.IOException; +import java.lang.reflect.TypeVariable; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import org.junit.jupiter.api.Test; +import ru.tinkoff.kora.json.common.JsonReader; +import ru.tinkoff.kora.json.common.JsonWriter; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class UnboxedTest extends AbstractJsonAnnotationProcessorTest { + + JsonReader stringReader = JsonParser::getValueAsString; + JsonWriter stringWriter = JsonGenerator::writeString; + + + @Test + public void unboxedRecordReader() throws IOException { + compile( + """ + @JsonReader + @JsonUnboxed + public record TestUnboxed(String a) { + } + """ + ); + + var reader = reader("TestUnboxed", stringReader); + + assertThat(reader.read("\"test string\"")) + .isEqualTo(newObject("TestUnboxed", "test string")); + } + + @Test + public void unboxedRecordReaderNullableField() throws IOException { + compile( + """ + @JsonReader + @JsonUnboxed + public record TestUnboxed(@Nullable String a) { + } + """ + ); + + var reader = reader("TestUnboxed", stringReader); + + assertThat(reader.read("null")) + .isEqualTo(newObject("TestUnboxed", (Object) null)); + } + + @Test + public void unboxedRecordReaderNonNullableField() { + compile( + """ + @JsonReader + @JsonUnboxed + public record TestUnboxed(String a) { + } + """ + ); + + var reader = reader("TestUnboxed", stringReader); + + assertThatThrownBy(() -> reader.read("null")) + .isInstanceOf(JsonParseException.class) + .hasMessageStartingWith("Expecting nonnull value, got VALUE_NULL token"); + } + + @Test + public void unboxedClassReader() throws IOException { + compile( + """ + @JsonReader + @JsonUnboxed + public class TestUnboxedClass { + private final String value; + + public TestUnboxedClass(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + """ + ); + + var reader = reader("TestUnboxedClass", stringReader); + + var actual = reader.read("\"test string\""); + + assertThat(invoke(actual, "getValue")) + .isEqualTo("test string"); + } + + @Test + public void genericUnboxedRecordReader() { + compile( + """ + @JsonReader + @JsonUnboxed + public record TestUnboxedGeneric(T value) {} + """ + ); + + var readerClass = compileResult.loadClass("$TestUnboxedGeneric_JsonReader"); + + var readerClassTypeParams = Arrays.stream(readerClass.getTypeParameters()).map(TypeVariable::getName); + + assertThat(readerClassTypeParams) + .containsExactly("T"); + + var parameterTypeName = readerClass.getConstructors()[0].getParameters()[0].getParameterizedType().getTypeName(); + + assertThat(parameterTypeName) + .isEqualTo("ru.tinkoff.kora.json.common.JsonReader"); + } + + @Test + public void errorIfJsonReaderPutOnTypeWithMultipleConstructorParams() { + var compileResult = compile( + List.of(new JsonAnnotationProcessor()), + """ + @JsonReader + @JsonUnboxed + public record TestBadValueRecord(String value1, int value2) {} + """ + ); + + assertThat(compileResult.isFailed()) + .isTrue(); + + var errors = compileResult.errors(); + + assertThat(errors) + .hasSize(1); + + assertThat(errors.get(0).getMessage(Locale.getDefault())) + .isEqualTo("@JsonUnboxed JsonReader can be created only for constructors with single parameter"); + } + + @Test + public void readerAnnotationOnConstructor() throws IOException { + compile( + """ + @JsonUnboxed + public record TestUnboxed(String a, String b) { + @JsonReader + public TestUnboxed(String joined) { + this(joined.split("\\\\.")); + } + + private TestUnboxed(String[] parts) { + this(parts[0], parts[1]); + } + } + """ + ); + + var reader = reader("TestUnboxed", stringReader); + + var actual = reader.read("\"test.string\""); + + var a = invoke(actual, "a"); + var b = invoke(actual, "b"); + + assertThat(a) + .isEqualTo("test"); + + assertThat(b) + .isEqualTo("string"); + } + + @Test + public void unboxedRecordWriter() throws IOException { + compile( + """ + @JsonWriter + @JsonUnboxed + public record TestUnboxed(String a) { + } + """ + ); + + var writer = writer("TestUnboxed", stringWriter); + + assertThat(writer.toString(newObject("TestUnboxed", "test string"))) + .isEqualTo("\"test string\""); + } + + @Test + public void unboxedRecordWriterNullableField() throws IOException { + compile( + """ + @JsonWriter + @JsonUnboxed + public record TestUnboxed(@Nullable String a) { + } + """ + ); + + var writer = writer("TestUnboxed", stringWriter); + + assertThat(writer.toString(newObject("TestUnboxed", (Object) null))) + .isEqualTo("null"); + } + + @Test + public void unboxedClassWriter() throws IOException { + compile( + """ + @JsonWriter + @JsonUnboxed + public class TestUnboxedClass { + private final String value; + + public TestUnboxedClass(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + """ + ); + + var writer = writer("TestUnboxedClass", stringWriter); + + var actual = writer.toString(newObject("TestUnboxedClass", "test string")); + + assertThat(actual) + .isEqualTo("\"test string\""); + } + + @Test + public void genericUnboxedRecordWriter() { + compile( + """ + @JsonWriter + @JsonUnboxed + public record TestUnboxedGeneric(T value) {} + """ + ); + + var readerClass = compileResult.loadClass("$TestUnboxedGeneric_JsonWriter"); + + var readerClassTypeParams = Arrays.stream(readerClass.getTypeParameters()).map(TypeVariable::getName); + + assertThat(readerClassTypeParams) + .containsExactly("T"); + + var parameterTypeName = readerClass.getConstructors()[0].getParameters()[0].getParameterizedType().getTypeName(); + + assertThat(parameterTypeName) + .isEqualTo("ru.tinkoff.kora.json.common.JsonWriter"); + } + + @Test + public void errorIfJsonWriterPutOnTypeWithMultipleFields() { + var compileResult = compile( + List.of(new JsonAnnotationProcessor()), + """ + @JsonWriter + @JsonUnboxed + public record TestBadValueRecord(String value1, int value2) {} + """ + ); + + assertThat(compileResult.isFailed()) + .isTrue(); + + var errors = compileResult.errors(); + + assertThat(errors) + .hasSize(1); + + assertThat(errors.get(0).getMessage(Locale.getDefault())) + .isEqualTo("@JsonUnboxed JsonWriter can be created only for classes with single field"); + } + + @Test + public void noUnboxedErrorWithSkippedFields() throws IOException { + compile( + """ + @Json + @JsonUnboxed + public class TestValueClass { + private final String value1; + @JsonSkip + private final int value2; + + public TestValueClass(String value1) { + this.value1 = value1; + this.value2 = value1.length(); + } + + public String getValue1() { return value1; } + public int getValue2() { return value2; } + } + """ + ); + + var mapper = mapper("TestValueClass", List.of(stringReader), List.of(stringWriter)); + + mapper.verifyWrite(newObject("TestValueClass", "my string"), "\"my string\""); + + var readObject = mapper.read("\"test string\""); + + assertThat(invoke(readObject, "getValue1")) + .isEqualTo("test string"); + + assertThat(invoke(readObject, "getValue2")) + .isEqualTo("test string".length()); + + } +} diff --git a/json/json-common/src/main/java/ru/tinkoff/kora/json/common/annotation/JsonUnboxed.java b/json/json-common/src/main/java/ru/tinkoff/kora/json/common/annotation/JsonUnboxed.java new file mode 100644 index 000000000..b4fca7e4a --- /dev/null +++ b/json/json-common/src/main/java/ru/tinkoff/kora/json/common/annotation/JsonUnboxed.java @@ -0,0 +1,50 @@ +package ru.tinkoff.kora.json.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Use on single-field value-class to indicate that + * it should be (de)serialized as it's field, not as object. + *

+ * Given the following class: + *

{@code
+ *     @Json
+ *     record ValueClass(String value) {}
+ *     }
+ * + * Then {@code new ValueClass("test")} will be (de)serialized as: + *
{@code
+ *     {"value": "test"}
+ *     }
+ * + * But after adding {@code @JsonUnboxed}: + *
{@code
+ *     @Json
+ *     @JsonUnboxed
+ *     record ValueClass(String value) {}
+ *     }
+ * + * {@code new ValueClass("test")} will be (de)serialized as: + *
{@code
+ *     "test"
+ *     }
+ *

+ *

+ * Old-fashioned Java value-classes, Kotlin {@code data} and {@code value} classes are also supported. + *

+ *

+ * If annotation is placed on a class with more than one field, + * error will be raised. + *

+ *

+ * Don't be confused with Jackson's {@code @JsonUnwrapped} annotation which is used + * to move object's fields one level up. + *

+ */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface JsonUnboxed { +}