fix(idempotency): replace ContentCachingRequestWrapper with re-readable CachedBodyHttpServletRequest

ContentCachingRequestWrapper только cache'ит body ПОСЛЕ того как handler
прочитает getInputStream(). Если filter читает body первым (для hash),
handler потом получает 'Required request body is missing' — буфер пустой.

CachedBodyHttpServletRequest при construction читает body в byte[] и
getInputStream() возвращает свежий ByteArrayInputStream при каждом вызове.
Filter и handler читают одни и те же байты независимо.
This commit is contained in:
Александр Зимин
2026-05-04 00:15:11 +03:00
parent 45e211f0ae
commit dfb694a42f
2 changed files with 61 additions and 6 deletions
@@ -0,0 +1,59 @@
package cloud.nstart.terravault.ordinis.restapi.idempotency;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import org.springframework.util.StreamUtils;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
/**
* Кэширует тело запроса в памяти, чтобы его можно было прочитать несколько раз
* (для idempotency hash + handler-аргумента). В отличие от
* {@link org.springframework.web.util.ContentCachingRequestWrapper}, отдает
* свежий InputStream при каждом вызове {@link #getInputStream()}.
*/
public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
private final byte[] cachedBody;
public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
InputStream is = request.getInputStream();
this.cachedBody = StreamUtils.copyToByteArray(is);
}
public byte[] getCachedBody() {
return cachedBody;
}
@Override
public ServletInputStream getInputStream() {
return new CachedServletInputStream(cachedBody);
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
}
private static class CachedServletInputStream extends ServletInputStream {
private final ByteArrayInputStream buf;
CachedServletInputStream(byte[] data) {
this.buf = new ByteArrayInputStream(data);
}
@Override public boolean isFinished() { return buf.available() == 0; }
@Override public boolean isReady() { return true; }
@Override public void setReadListener(ReadListener listener) { /* no-op */ }
@Override public int read() { return buf.read(); }
@Override public int read(byte[] b, int off, int len) { return buf.read(b, off, len); }
}
}
@@ -12,7 +12,6 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import java.io.IOException;
@@ -71,11 +70,8 @@ public class IdempotencyFilter extends OncePerRequestFilter {
return;
}
ContentCachingRequestWrapper wrappedReq = new ContentCachingRequestWrapper(request);
// Need to read body to compute hash; do it after first content read. Spring caches via
// ContentCachingRequestWrapper after handler reads, so we trigger the read manually.
wrappedReq.getInputStream().readAllBytes(); // populate cache
byte[] body = wrappedReq.getContentAsByteArray();
CachedBodyHttpServletRequest wrappedReq = new CachedBodyHttpServletRequest(request);
byte[] body = wrappedReq.getCachedBody();
String hash = service.hashRequest(body);
try {