Coverage Summary for Class: GhostReactiveDecoder (com.ghost.serialization.spring)

Class Method, % Branch, % Line, % Instruction, %
GhostReactiveDecoder 100% (15/15) 84.6% (22/26) 100% (46/46) 99.4% (327/329)
GhostReactiveDecoder$Companion
Total 100% (15/15) 84.6% (22/26) 100% (46/46) 99.4% (327/329)


 package com.ghost.serialization.spring
 
 import com.ghost.serialization.Ghost
 import com.ghost.serialization.annotations.GhostSerialization
 import com.ghost.serialization.exception.GhostJsonException
 import org.reactivestreams.Publisher
 import org.springframework.core.ResolvableType
 import org.springframework.core.codec.AbstractDecoder
 import org.springframework.core.io.buffer.DataBuffer
 import org.springframework.core.io.buffer.DataBufferUtils
 import org.springframework.util.MimeType
 import org.springframework.util.MimeTypeUtils
 import reactor.core.publisher.Flux
 import reactor.core.publisher.Mono
 import kotlin.reflect.KClass
 
 private const val NDJSON_NEWLINE: Byte = '\n'.code.toByte()
 
 /**
  * Reactive Decoder for Ghost Serialization.
  *
  * Extracts the raw [ByteArray] from each [DataBuffer] and feeds it directly
  * to the pooled [com.ghost.serialization.parser.GhostJsonReader], avoiding
  * intermediate Okio/InputStream wrappers entirely.
  */
 class GhostReactiveDecoder : AbstractDecoder<Any>(
     MimeTypeUtils.APPLICATION_JSON,
     MimeType("application", "x-ndjson")
 ) {
     override fun canDecode(elementType: ResolvableType, mimeType: MimeType?): Boolean {
         val clazz = elementType.toClass()
         return super.canDecode(elementType, mimeType) &&
                 (clazz.isAnnotationPresent(GhostSerialization::class.java) ||
                         Ghost.getSerializer(clazz.kotlin) != null)
     }
 
     override fun decode(
         inputStream: Publisher<DataBuffer>,
         elementType: ResolvableType,
         mimeType: MimeType?,
         hints: MutableMap<String, Any>?
     ): Flux<Any> {
         val clazz = elementType.toClass()
         val isNdJson = isNdJson(mimeType)
 
         return if (isNdJson) {
             decodeStreaming(inputStream, clazz)
         } else {
             decodeJoined(inputStream, clazz)
         }
     }
 
     override fun decodeToMono(
         inputStream: Publisher<DataBuffer>,
         elementType: ResolvableType,
         mimeType: MimeType?,
         hints: MutableMap<String, Any>?
     ): Mono<Any> {
         val clazz = elementType.toClass()
         return decodeJoined(inputStream, clazz).next()
     }
 
     /**
      * NDJSON records aren't guaranteed to arrive one-per-[DataBuffer] — a small multi-line
      * request body will typically arrive as a single network buffer, and a single record can
      * just as easily be split across two. This re-frames the raw buffer stream on `\n` before
      * decoding each line, carrying any trailing partial line over to the next buffer and
      * flushing a final unterminated line at stream completion.
      */
     private fun decodeStreaming(
         inputStream: Publisher<DataBuffer>,
         clazz: Class<*>
     ): Flux<Any> = Flux.defer {
         var carry = ByteArray(0)
 
         Flux.from(inputStream)
             .concatMap { buffer ->
                 val bytes: ByteArray
                 try {
                     bytes = ByteArray(buffer.readableByteCount())
                     buffer.read(bytes)
                 } finally {
                     DataBufferUtils.release(buffer)
                 }
 
                 val combined = if (carry.isEmpty()) bytes else carry + bytes
                 val lines = mutableListOf<ByteArray>()
                 var lineStart = 0
                 for (index in combined.indices) {
                     if (combined[index] == NDJSON_NEWLINE) {
                         if (index > lineStart) lines += combined.copyOfRange(lineStart, index)
                         lineStart = index + 1
                     }
                 }
                 carry = combined.copyOfRange(lineStart, combined.size)
                 Flux.fromIterable(lines)
             }
             .concatWith(Flux.defer { if (carry.isEmpty()) Flux.empty() else Flux.just(carry) })
             .map { line -> deserializeBytes(line, clazz) }
     }
 
     private fun decodeJoined(
         inputStream: Publisher<DataBuffer>,
         clazz: Class<*>
     ): Flux<Any> = DataBufferUtils
         .join(inputStream).flatMapMany { buffer ->
             try {
                 Flux.just(deserializeBuffer(buffer, clazz))
             } finally {
                 DataBufferUtils.release(buffer)
             }
         }
 
     private fun deserializeBuffer(
         buffer: DataBuffer,
         clazz: Class<*>
     ): Any {
         val bytes = ByteArray(buffer.readableByteCount())
         buffer.read(bytes)
         return deserializeBytes(bytes, clazz)
     }
 
     private fun deserializeBytes(
         bytes: ByteArray,
         clazz: Class<*>
     ): Any {
         return try {
             val serializer = Ghost.getSerializer(clazz.kotlin as KClass<Any>)
                 ?: throw IllegalArgumentException(
                     "${Ghost.NOT_FOUND} ${clazz.simpleName}. ${Ghost.MISSING_ANN}"
                 )
             Ghost.deserialize(serializer, bytes)
         } catch (e: Exception) {
             throw GhostJsonException(
                 "$DECODE_ERROR ${clazz.simpleName}: ${e.message}"
             )
         }
     }
 
     private fun isNdJson(mimeType: MimeType?): Boolean {
         return mimeType?.subtype?.contains("ndjson") == true
     }
 
     companion object {
         private const val DECODE_ERROR = "Failed to decode reactive buffer for"
     }
 }