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 {
+}