I'm persisting data to Redis, for my Spring Boot application. This requires me to create a custom Jackson Object Mapper for the Redis Serialiser

Error

I keep getting this error though

GET "/oauth2/authorization/in-house-auth-server?post_login_success_uri=http%3A%2F%2Flocalhost%3A7080%2Fangular-ui%2Fhome&post_login_failure_uri=http%3A%2F%2Flocalhost%3A7080%2Fangular-ui%2Flogin-error"

java.lang.IllegalArgumentException: Unexpected token (START_OBJECT), expected START_ARRAY: need Array value to contain `As.WRAPPER_ARRAY` type information for class java.util.Map
 at [Source: UNKNOWN; byte offset: #UNKNOWN]

It's driving me bonkers, and I don't know what is wrong with my mapper. Is someone able to please, please help?

The function that calls the serialiser is the SAVE method in this class

RedisAuthorizationRequestRepository

@Repository
internal class RedisAuthorizationRequestRepository(
    private val redisTemplate: ReactiveRedisTemplate<String, Any>,
    springSessionProperties: SpringSessionProperties,
    private val redisSerialiserConfig: RedisSerialiserConfig
) : ServerAuthorizationRequestRepository<OAuth2AuthorizationRequest> {

    private val redisKeyPrefix = springSessionProperties.redis?.oauth2RequestNamespace

    override fun saveAuthorizationRequest(
        authorizationRequest: OAuth2AuthorizationRequest?,
        exchange: ServerWebExchange
    ): Mono<Void> {
        return constructRedisKey(exchange).flatMap { redisKey ->
            println("SAVING AUTHORIZATION REQUEST")
            println("Redis Key: $redisKey")

            if (authorizationRequest != null) {
                val hashOperations = redisTemplate.opsForHash<String, Any>()
                val fieldsMap = redisSerialiserConfig.redisObjectMapper().convertValue(
                    authorizationRequest,
                    object : TypeReference<Map<String, Any?>>() {}
                )

                println("Authorization Request: $authorizationRequest")
                println("Fields Map: $fieldsMap")

                hashOperations.putAll(redisKey, fieldsMap).doOnSuccess {
                    println("Successfully saved authorization request to Redis")
                }.doOnError { e ->
                    println("Error saving authorization request to Redis: ${e.message}")
                }.then()
            } else {
                println("Authorization request is null, deleting Redis key: $redisKey")
                redisTemplate.opsForHash<String, Any>().delete(redisKey)
                    .doOnSuccess {
                        println("Successfully deleted authorization request from Redis")
                    }.doOnError { e ->
                        println("Error deleting authorization request from Redis: ${e.message}")
                    }.then()
            }
        }
    }

    override fun loadAuthorizationRequest(exchange: ServerWebExchange): Mono<OAuth2AuthorizationRequest?> {
        return constructRedisKey(exchange).flatMap { redisKey ->
            println("LOADING AUTHORIZATION REQUEST")
            println("Redis Key: $redisKey")

            redisTemplate.opsForHash<String, Any>().entries(redisKey)
                .doOnNext { entries ->
                    println("Redis Entries: $entries")
                }
                .collectMap({ it.key as String }, { it.value })
                .doOnSuccess { map ->
                    println("Loaded Map from Redis: $map")
                }
                .mapNotNull { map ->
                    if (map.isEmpty()) {
                        println("Loaded map is empty, returning null")
                        null
                    } else {
                        try {
                            val authorizationRequest = redisSerialiserConfig
                                .redisObjectMapper()
                                .convertValue(map, OAuth2AuthorizationRequest::class.java)
                            println("Deserialized OAuth2AuthorizationRequest: $authorizationRequest")
                            authorizationRequest
                        } catch (e: Exception) {
                            println("Error deserializing OAuth2AuthorizationRequest: ${e.message}")
                            null
                        }
                    }
                }
                .doOnError { e ->
                    println("Error loading authorization request: ${e.message}")
                }
        }
    }

    override fun removeAuthorizationRequest(exchange: ServerWebExchange): Mono<OAuth2AuthorizationRequest?> {
        println("REMOVING AUTHORIZATION REQUEST")
        return constructRedisKey(exchange).flatMap { redisKey ->
            println("Attempting to remove Authorization Request with key: $redisKey")
            redisTemplate.opsForHash<String, Any>().entries(redisKey)
                .doOnNext { entries ->
                    println("Current Redis Entries: $entries")
                }
                .collectMap({ it.key as String }, { it.value })
                .doOnNext { map ->
                    println("Removing Authorization Request with data: $map")
                }
                .flatMap { map ->
                    redisTemplate.opsForHash<String, Any>().delete(redisKey)
                        .then(Mono.fromCallable {
                            try {
                                val authorizationRequest = redisSerialiserConfig
                                    .redisObjectMapper()
                                    .convertValue(map, OAuth2AuthorizationRequest::class.java)
                                println("Successfully removed Authorization Request from Redis. Data: $authorizationRequest")
                                authorizationRequest
                            } catch (e: Exception) {
                                println("Error deserializing Authorization Request after removal: ${e.message}")
                                null
                            }
                        })
                }
                .onErrorResume { e ->
                    println("Error occurred while removing Authorization Request: ${e.message}")
                    Mono.empty()
                }
        }
    }

    // Helper method to construct the Redis key using a unique identifier from the exchange
    private fun constructRedisKey(exchange: ServerWebExchange): Mono<String> {
        return exchange.session
            .map { it.id }
            .map { "$redisKeyPrefix:$it" }
    }
}

RedisSerialiserConfig

And here is the serialiser / de-serialiser

/**
     * Customized {@link ObjectMapper} to add mix-in for class that doesn't have default
     * constructors.
     * @return the {@link ObjectMapper} to use
     */
    fun redisObjectMapper(): ObjectMapper {
        val mapper = ObjectMapper()

        // Register custom serializers and deserializers
        val module = SimpleModule().apply {
            addSerializer(
                OAuth2AuthorizationRequest::class.java,
                OAuth2AuthorizationRequestSerializer()
            )
            addSerializer(
                OAuth2AuthorizationResponseType::class.java,
                OAuth2AuthorizationResponseTypeSerializer()
            )
            addDeserializer(
                OAuth2AuthorizationRequest::class.java,
                OAuth2AuthorizationRequestDeserializer()
            )
            addDeserializer(
                OAuth2AuthorizationResponseType::class.java,
                OAuth2AuthorizationResponseTypeDeserializer()
            )
        }
        mapper.registerModule(module)

        // register security-related modules
        mapper.registerModule(CoreJackson2Module())
        mapper.registerModules(SecurityJackson2Modules.getModules(this::class.java.classLoader))

        // create a PolymorphicTypeValidator that allows java.lang.Long
        val ptv = BasicPolymorphicTypeValidator.builder()
            .allowIfBaseType(Any::class.java)
            .allowIfSubType(Long::class.java)
            .build()
        mapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL)

        // register subtypes explicitly
        mapper.registerSubtypes(
            OAuth2AuthorizationRequest::class.java,
            OAuth2AuthorizationResponseType::class.java
        )

        // additional configurations
        mapper.registerModule(JavaTimeModule())
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
        mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
        mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL)

        return mapper
    }

The mapper gets embedded directly in a generic serialiser / de-serialiser

@Bean
    // setting a custom session serialiser for Redis
    fun springSessionDefaultRedisSerializer(): RedisSerializer<Any> {
        return object : GenericJackson2JsonRedisSerializer(redisObjectMapper()) {
            override fun serialize(value: Any?): ByteArray {
                value.let{
                    println("Serializing: $value of type: ${value!!::class.java}")
                }
                println("Serializing: $value")
                return super.serialize(value)
            }

            override fun deserialize(bytes: ByteArray?): Any {
                if (bytes == null || bytes.isEmpty()) {
                    println("Deserialization: Received null or empty byte array")
                    return Any()
                }
                val result = super.deserialize(bytes)
                return result
            }
        }
    }

Custom Mappers

This is the de-serialiser (though this doesn't get called by the save method)

/**
     * Define custom de-serialiser for OAuth2AuthorizationRequest
     */
    private class OAuth2AuthorizationRequestDeserializer : JsonDeserializer<OAuth2AuthorizationRequest>() {

        override fun deserialize(jp: JsonParser, ctxt: DeserializationContext): OAuth2AuthorizationRequest {
            println("Starting deserialization of OAuth2AuthorizationRequest")

            val node = jp.codec.readTree<JsonNode>(jp)

            // Check type id and create an instance of OAuth2AuthorizationRequest
            val typeId = node.get("@class")?.asText()
            if (typeId != OAuth2AuthorizationRequest::class.java.name) {
                throw IllegalArgumentException("Unexpected type id: $typeId")
            }

            // Extract values from JSON
            val authorizationUri = node.get("authorizationUri")?.asText() ?: throw IllegalArgumentException("authorizationUri is required")
            val clientId = node.get("clientId")?.asText() ?: throw IllegalArgumentException("clientId is required")
            val redirectUri = node.get("redirectUri")?.asText()
            val state = node.get("state")?.asText()
            val scopes = node.get("scopes")?.elements()?.asSequence()?.map { it.asText() }?.toSet() ?: emptySet()
            val additionalParameters = node.get("additionalParameters")?.fields()?.asSequence()?.associate { it.key to it.value.asText() } ?: emptyMap()
            val attributes = node.get("attributes")?.fields()?.asSequence()?.associate { it.key to it.value.asText() } ?: emptyMap()
            val authorizationRequestUri = node.get("authorizationRequestUri")?.asText()

            // Log extracted values
            println("Extracted values from JSON: authorizationUri=$authorizationUri, clientId=$clientId, redirectUri=$redirectUri, state=$state, scopes=$scopes, additionalParameters=$additionalParameters, attributes=$attributes, authorizationRequestUri=$authorizationRequestUri")

            // Initialize Builder with extracted values
            val builder = OAuth2AuthorizationRequest
                .authorizationCode() // Assuming the default grant type for the builder
                .authorizationUri(authorizationUri)
                .clientId(clientId)
                .redirectUri(redirectUri)
                .scopes(scopes)
                .state(state)
                .additionalParameters(additionalParameters)
                .attributes(attributes)
                .authorizationRequestUri(authorizationRequestUri ?: "")

            val authorizationRequest = builder.build()

            // Log final deserialized object
            println("Successfully deserialized OAuth2AuthorizationRequest: $authorizationRequest")

            return authorizationRequest
        }

    }

This is the serialiser, which I believe is the one getting called on the save method. Please note this accepts a value that is value: OAuth2AuthorizationRequest, (rather than a list of OAuth2AuthorizationRequest), so based on the error message, not sure how I can create an Json Array, rather than Json Object (since there is nothing to iterate on)

private class OAuth2AuthorizationRequestSerializer : JsonSerializer<OAuth2AuthorizationRequest>() {

        override fun serialize(
            value: OAuth2AuthorizationRequest,
            gen: JsonGenerator,
            serializers: SerializerProvider
        ) {
            // Start writing the JSON object
            gen.writeStartObject()

            // Write the class type information
            gen.writeStringField("@class", OAuth2AuthorizationRequest::class.java.name)
            gen.writeStringField("authorizationUri", value.authorizationUri ?: "")
            gen.writeStringField("clientId", value.clientId ?: "")
            gen.writeStringField("redirectUri", value.redirectUri ?: "")
            gen.writeStringField("state", value.state ?: "")

            // Write scopes as an array
            gen.writeArrayFieldStart("scopes")
            value.scopes.forEach { scope: String ->
                gen.writeString(scope)
            }
            gen.writeEndArray()

            // Write additionalParameters and attributes as objects
            gen.writeObjectField("additionalParameters", value.additionalParameters ?: emptyMap<String, Any>())
            gen.writeObjectField("attributes", value.attributes ?: emptyMap<String, Any>())

            // Write remaining fields
            gen.writeStringField("authorizationRequestUri", value.authorizationRequestUri ?: "")
            gen.writeStringField("grantType", value.grantType?.value ?: "")
            gen.writeObjectField("responseType", mapOf("value" to value.responseType.value))

            // End the JSON object
            gen.writeEndObject()
        }


        override fun serializeWithType(
            value: OAuth2AuthorizationRequest,
            gen: JsonGenerator,
            serializers: SerializerProvider,
            typeSer: TypeSerializer
        ) {
            typeSer.writeTypePrefix(gen, typeSer.typeId(value, JsonToken.START_OBJECT))
            serialize(value, gen, serializers)
            typeSer.writeTypeSuffix(gen, typeSer.typeId(value, JsonToken.END_OBJECT))
        }
    }

Describe the bug A clear and concise description of what the bug is.

To Reproduce Steps to reproduce the behavior.

Expected behavior A clear and concise description of what you expected to happen.

Sample

A link to a GitHub repository with a minimal, reproducible sample.

Reports that include a sample will take priority over reports that do not. At times, we may require a sample, so it is good to try and include a sample up front.

Comment From: dreamstar-enterprises

I could not solve this implementing an ObjectMapper with a custom serialiser. But, if I removed the custom serialiser registrations from here:

     addSerializer(
                OAuth2AuthorizationRequest::class.java,
                OAuth2AuthorizationRequestSerializer()
            )
            addSerializer(
                OAuth2AuthorizationResponseType::class.java,
                OAuth2AuthorizationResponseTypeSerializer()
            )

Then it worked. So I gave up trying to implement a custom serialiser - instead relying on whatever the default was.

A lot of pain to discover this...but there you go.

Closing issue