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