micronaut-projects/micronaut-serialization

Cannot create default custom Serializer/Deserializer for String

Closed this issue · 14 comments

GeitV commented

Expected Behavior

Create custom Serde<String> definition that is used for all serializations/deserializations. Add @Primary to make it primary.

Actual Behaviour

In reality it's not used unless one marks the fields with explicit annotations as follows

@Serdeable
@MappedEntity
data class Address(
    @field:Id
    @GeneratedValue
    var id: Long? = null,

    @Serdeable.Serializable(using = StringSerde::class)
    @Serdeable.Deserializable(using = StringSerde::class)
    @field:MappedProperty("address_line_1")
    var addressLine1: String?,

    ...
)

Steps To Reproduce

Create custom String serialization in kotlin

@Primary
@Singleton
class StringSerde : Serde<String> {

    override fun serialize(
        encoder: Encoder,
        context: Serializer.EncoderContext,
        type: Argument<out String>,
        value: String
    ) {
        encoder.encodeString(value)
    }

    override fun deserialize(
        decoder: Decoder,
        context: Deserializer.DecoderContext,
        type: Argument<in String>
    ): String? {
        if (decoder.decodeNull()) return null
        val string = decoder.decodeString()
        return string.ifBlank { null }
    }
}

Start using it and see that it's not actually used by default

Environment Information

  • kotlin
  • micronaut serde 1.3.2

Example Application

No response

Version

3.7.3

not sure what the use case is

GeitV commented

Use case is that when incoming string is empty "" then we set it as null. Eg:

{
    "id": 124,
    "address_line_1": ""
}

would result in Kotlin object that has addressLine1 as null.

Our web developers aren't really keen on mapping empty strings to null on their side, so I thought we could fix it on backend side.

you can probably create a factory that replaces these beans:

@Singleton
Serializer<CharSequence> charSequenceSerializer() {
return new Serializer<CharSequence>() {
@Override
public void serialize(Encoder encoder,
EncoderContext context,
Argument<? extends CharSequence> type, CharSequence value) throws IOException {
if (value instanceof String) {
encoder.encodeString((String) value);
} else {
encoder.encodeString(value.toString());
}
}
@Override
public boolean isEmpty(EncoderContext context, CharSequence value) {
return value == null || value.length() == 0;
}
};
}
@Singleton
@Order(1000) // prioritize over character
Serializer<String> stringSerializer() {
return new Serializer<String>() {
@Override
public void serialize(Encoder encoder,
EncoderContext context,
Argument<? extends String> type, String value) throws IOException {
encoder.encodeString(value);
}
@Override
public boolean isEmpty(EncoderContext context, String value) {
return value == null || value.length() == 0;
}
};
}

Using @Replaces https://docs.micronaut.io/latest/api/io/micronaut/context/annotation/Replaces.html#factory--

GeitV commented

I might be stupid but I don't get how can you replace bean with generic qualifier

Tried this but got nothing

@Factory
class CustomDeserializers {

    @Singleton
    @Replaces(
        value = Deserializer::class,
        factory = io.micronaut.serde.support.deserializers.CoreDeserializers::class
    )
    fun stringDeserializer(): Deserializer<String> = object : Deserializer<String> {
        override fun deserialize(
            decoder: Decoder,
            context: Deserializer.DecoderContext,
            type: Argument<in String>
        ): String? {
            if (decoder.decodeNull()) return null
            return decoder.decodeString().ifBlank { null }
        }

        override fun allowNull() = true
    }
}

This issue thread didn't give enough insight either. Maybe you could help us out here?

actually @Replaces is not needed, the following works (using Java as unfamiliar with Kotlin):

import java.io.IOException;

import io.micronaut.context.annotation.Factory;
import io.micronaut.core.annotation.Order;
import io.micronaut.core.type.Argument;
import io.micronaut.core.util.StringUtils;
import io.micronaut.serde.Encoder;
import io.micronaut.serde.Serializer;
import jakarta.inject.Singleton;

@Factory
public class CustomSerializers {

    @Singleton
    @Order(-100) // prioritize over default
    Serializer<String> stringSerializer() {
        return new Serializer<String>() {
            @Override
            public void serialize(Encoder encoder,
                                  EncoderContext context,
                                  Argument<? extends String> type, String value) throws IOException {
                if (StringUtils.isEmpty(value)) {
                    encoder.encodeNull();;
                } else {
                    encoder.encodeString(value);
                }
            }

            @Override
            public boolean isEmpty(EncoderContext context, String value) {
                return value == null || value.length() == 0;
            }
        };
    }
}

oops realised you were after the deserializer, anyway same strategy will work

GeitV commented

Indeed, I was trying the @Order annotation as well, but that did not give me anything

@Factory
class CustomDeserializers {

    @Singleton
    @Order(1000) // tried -100 as well
    fun stringDeserializer(): Deserializer<String> = object : Deserializer<String> {
        override fun deserialize(
            decoder: Decoder,
            context: Deserializer.DecoderContext,
            type: Argument<in String>
        ): String? {
            if (decoder.decodeNull()) return null
            return decoder.decodeString().ifBlank { null }
        }

        override fun allowNull() = true
    }
}

Then, blaming kotlin I tried to do it in Java, but still, nothing

@Factory
public class CustomDeserializer {

    @Singleton
    @Order(-100)
    Deserializer<String> stringDeserializer() {
        return new Deserializer<String>() {
            @Override
            public String deserialize(@NotNull Decoder decoder, @NotNull DecoderContext context,
                                      @NotNull Argument<? super String> type) throws IOException {
                if (decoder.decodeNull()) return null;
                String str = decoder.decodeString();
                if (str.isBlank()) return null;
                return str;
            }

            @Override
            public boolean allowNull() {
                return true;
            }
        };
    }
}

If everything seems to be correct, I'll do test repo next week for this.

Also, would be nice to have some hints in the documentation that core classes cannot be serialized using Serde<Class>

GeitV commented

Created test repo for the issue, made the code in Java so it would be easier for you @graemerocher

https://github.com/GeitV/string-deserializer-not-working

Currently, following test fails as empty request string is treated as empty request string, not as null.
https://github.com/GeitV/string-deserializer-not-working/blob/604d0ffd0835e23b97b26bfd17f4e7715ee7ba42/src/test/kotlin/com/example/PingTest.kt#L31-L33

Maybe default String deserializer should be configurable?
There is already "jackson.trim-strings" in https://docs.micronaut.io/latest/guide/configurationreference.html
Maybe it is good idea to implement "micronaut.serde.deserialization.trim-strings" with same logic: "Whether strings should be trimmed when deserializing (defaults to false). If the resulting string is an empty string, then null will be applied instead."

GeitV commented

I agree with Andrus. When you serialize, then the serializer already makes empty strings into nulls.
Would be great to have the same functionality when deserializing as well.

Is there any ETA for this, @graemerocher ? I just realized I also need this for dates that arrive as "". This throws an error when deserializing a date that is sent to Micronaut as empty string: "Text '' could not be parsed at index 0"

It would be good to have a configuration as well for trimming all incoming strings.

it is not as simple as making it configurable, it is better to replace the entire implementation if you have custom needs. Decoding/encoding strings is a highly performance sensitive code path, so currently the current implementation is the most optimal.

it is not as simple as making it configurable, it is better to replace the entire implementation if you have custom needs. Decoding/encoding strings is a highly performance sensitive code path, so currently the current implementation is the most optimal.

How would one go about implementing a trimming function then? The following code does not work:

@Factory
public class CustomSerializers {

    @Singleton
    @Order(-100) // prioritize over default
    Serializer<String> stringSerializer() {
        return new Serializer<String>() {
            @Override
            public void serialize(Encoder encoder,
                                  EncoderContext context,
                                  Argument<? extends String> type, String value) throws IOException {
                if (StringUtils.isEmpty(value)) {
                    encoder.encodeNull();;
                } else {
                    encoder.encodeString(value);
                }
            }

            @Override
            public boolean isEmpty(EncoderContext context, String value) {
                return value == null || value.length() == 0;
            }
        };
    }
}

you could make the logic part of the getter/setter instead