CapturingRequestWrapper.kt

package eu.inqudium.limesium.servlet.logging

import jakarta.servlet.ReadListener
import jakarta.servlet.ServletInputStream
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletRequestWrapper
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.UnsupportedEncodingException
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets

/**
 * Tees the request body into [capture] as the APPLICATION reads it - a passive copy, never a pre-read.
 *
 * A replaying up-front buffer was deliberately rejected; this wrapper instead observes the
 * live stream, which means: no extra memory beyond the capture limit, no interference with streaming or
 * async processing, and the log shows exactly the bytes the application actually consumed (an unread body
 * is logged as absent, which is the truthful answer).
 *
 * [getReader] is overridden alongside [getInputStream]: the wrapper base class would otherwise hand out
 * the ORIGINAL request's reader and silently bypass the tee. The reader preserves the SERVLET decoding
 * contract exactly - the declared request encoding, the spec default ISO-8859-1 when none is declared,
 * and `UnsupportedEncodingException` for an unsupported one; the UTF-8 fallback of [bodyCharset] applies
 * ONLY to how the log line renders the captured bytes, never to what the application reads (and is
 * bound late - see [bodyCharset]). The wrapper also reproduces the delegate's stream/reader either-or
 * contract itself, because the tee satisfies both APIs from ONE delegate stream and the delegate can
 * therefore no longer see which public API the application chose.
 *
 * ASYNC boundary: the tee lives on THIS wrapper. Spring MVC's async support starts async with
 * `startAsync(currentRequest, currentResponse)` and keeps the wrappers; the Servlet-specified
 * zero-argument `startAsync()` initializes its context with the ORIGINAL request/response, so bytes a
 * raw async cycle reads/writes flow beside the tee and are logged as absent - a documented contract
 * boundary, pinned by `RequestLoggingFilterTomcatIntegrationTest` and `RequestLoggingFilterJettyIntegrationTest`.
 */
internal class CapturingRequestWrapper(
    request: HttpServletRequest,
    private val capture: BoundedBodyCapture,
) : HttpServletRequestWrapper(request) {
    private var teeStream: ServletInputStream? = null
    private var teeReader: BufferedReader? = null
    private var streamSelected = false

    private var boundBodyCharset: Charset? = null

    /**
     * The charset the captured bytes are decoded with for the LOG LINE only - never for the reader. Bound
     * LATE, when the application first selects the stream or the reader, not at construction: the servlet
     * contract lets downstream code call `setCharacterEncoding` until the body is consumed, and a charset
     * frozen at filter entry would decode the captured bytes with an encoding the application never used.
     * For a body that was never read, resolved from the encoding current at the time of the query.
     */
    val bodyCharset: Charset
        get() = boundBodyCharset ?: charsetOrDefault(characterEncoding)

    override fun getInputStream(): ServletInputStream {
        check(teeReader == null) { "getReader() has already been called on this request" }
        streamSelected = true
        return teeOverDelegate()
    }

    override fun getReader(): BufferedReader {
        teeReader?.let { return it }
        check(!streamSelected) { "getInputStream() has already been called on this request" }
        return BufferedReader(InputStreamReader(teeOverDelegate(), readerCharset())).also { teeReader = it }
    }

    /**
     * The tee stream. Besides copying, it records the READ STATE on the capture: selecting the stream
     * (or the reader over it) marks consumption as started; observing the end of the stream - an EOF
     * return from either `read`, or `isFinished` answering true for the non-blocking read loop - marks
     * it complete. Both are observations of what the application did, never an extra read: the tee
     * does not probe for EOF itself, so a body the application stopped reading stays PARTIAL.
     */
    private fun teeOverDelegate(): ServletInputStream {
        teeStream?.let { return it }
        boundBodyCharset = charsetOrDefault(characterEncoding)
        val real = super.getInputStream()
        capture.markStarted()
        return object : ServletInputStream() {
            override fun read(): Int {
                val b = real.read()
                if (b != -1) {
                    capture.capture(b)
                } else {
                    capture.markCompleted()
                }
                return b
            }

            override fun read(
                bytes: ByteArray,
                offset: Int,
                length: Int,
            ): Int {
                val n = real.read(bytes, offset, length)
                if (n > 0) {
                    capture.capture(bytes, offset, n)
                } else if (n == -1) {
                    capture.markCompleted()
                }
                return n
            }

            override fun isFinished(): Boolean {
                val finished = real.isFinished
                if (finished) {
                    capture.markCompleted()
                }
                return finished
            }

            override fun isReady(): Boolean = real.isReady

            // Delegated, not inherited: InputStream's default answers a constant 0, which would make a
            // parser probing the stream behave differently the moment body capture is switched on -
            // the one thing a passive tee must never do.
            override fun available(): Int = real.available()

            override fun setReadListener(listener: ReadListener?) = real.setReadListener(listener)

            override fun close() = real.close()
        }.also { teeStream = it }
    }

    /**
     * The charset the APPLICATION's reader decodes with - the servlet contract, unchanged by this
     * wrapper: the effective request encoding (which already includes container/context defaults the
     * delegate applies), the spec default ISO-8859-1 when none is declared, and
     * [UnsupportedEncodingException] for a declared-but-unsupported one, exactly as `getReader()` on an
     * unwrapped request would throw it.
     */
    private fun readerCharset(): Charset {
        val name = characterEncoding ?: return StandardCharsets.ISO_8859_1
        try {
            return Charset.forName(name)
        } catch (e: Exception) {
            throw UnsupportedEncodingException(name).apply { initCause(e) }
        }
    }

    companion object {
        /**
         * UTF-8 fallback instead of the servlet spec's ISO-8859-1 default: this charset only affects how
         * the LOG LINE renders the captured bytes, and modern payloads without a declared encoding are
         * far more likely UTF-8. The bytes and characters handed to the application are untouched - the
         * reader uses [CapturingRequestWrapper.readerCharset], which preserves the servlet contract.
         */
        fun charsetOrDefault(name: String?): Charset =
            try {
                name?.let { Charset.forName(it) }
            } catch (_: IllegalArgumentException) {
                // IllegalCharsetNameException / UnsupportedCharsetException: an undeclared-or-broken
                // encoding only affects the log rendering, never the application's reader.
                null
            } ?: StandardCharsets.UTF_8
    }
}