This is an automated email from the ASF dual-hosted git repository.

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git


The following commit(s) were added to refs/heads/master by this push:
     new 66d73f5b02 fix(base): defer encoding of rewritten request bodies 
(#7899)
66d73f5b02 is described below

commit 66d73f5b02e548bb9283b9cce46172c2986e3cf6
Author: cloudbase-ai <[email protected]>
AuthorDate: Wed Oct 21 14:05:20 2026 +0800

    fix(base): defer encoding of rewritten request bodies (#7899)
    
    Signed-off-by: cloudbase-ai <[email protected]>
    Co-authored-by: cloudbase-ai 
<[email protected]>
---
 .../plugin/base/utils/ServerWebExchangeUtils.java  |   5 +-
 .../base/utils/ServerWebExchangeUtilsTest.java     | 206 +++++++++++++++++++++
 2 files changed, 210 insertions(+), 1 deletion(-)

diff --git 
a/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/utils/ServerWebExchangeUtils.java
 
b/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/utils/ServerWebExchangeUtils.java
index ab28c00c15..b1bcf87f4e 100644
--- 
a/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/utils/ServerWebExchangeUtils.java
+++ 
b/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/utils/ServerWebExchangeUtils.java
@@ -28,6 +28,7 @@ import org.springframework.web.reactive.function.BodyInserter;
 import org.springframework.web.reactive.function.BodyInserters;
 import org.springframework.web.reactive.function.server.ServerRequest;
 import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
 import java.util.List;
@@ -54,7 +55,9 @@ public class ServerWebExchangeUtils {
                 .switchIfEmpty(Mono.just(""))
                 .flatMap(convert)
                 .flatMap(body -> {
-                    BodyInserter<String, ReactiveHttpOutputMessage> 
bodyInserter = BodyInserters.fromValue(body);
+                    // A single-value inserter encodes eagerly to calculate 
content length. RequestDecorator
+                    // uses chunked headers, so encode only when the 
downstream actually reads the body.
+                    BodyInserter<Flux<String>, ReactiveHttpOutputMessage> 
bodyInserter = BodyInserters.fromPublisher(Flux.just(body), String.class);
                     return bodyInserter.insert(outputMessage, new 
BodyInserterContext());
                 }).then(Mono.defer(() -> {
                     ServerHttpRequestDecorator decorator = new 
RequestDecorator(exchange, outputMessage);
diff --git 
a/shenyu-plugin/shenyu-plugin-base/src/test/java/org/apache/shenyu/plugin/base/utils/ServerWebExchangeUtilsTest.java
 
b/shenyu-plugin/shenyu-plugin-base/src/test/java/org/apache/shenyu/plugin/base/utils/ServerWebExchangeUtilsTest.java
new file mode 100644
index 0000000000..acf4e311d4
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-base/src/test/java/org/apache/shenyu/plugin/base/utils/ServerWebExchangeUtilsTest.java
@@ -0,0 +1,206 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.base.utils;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.EmptyByteBuf;
+import io.netty.buffer.PooledByteBufAllocator;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.core.io.buffer.DataBufferFactory;
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.core.io.buffer.NettyDataBuffer;
+import org.springframework.core.io.buffer.NettyDataBufferFactory;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.codec.ServerCodecConfigurer;
+import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Pooled-buffer ownership of rewritten request bodies.
+ */
+public final class ServerWebExchangeUtilsTest {
+
+    private final List<ByteBuf> allocations = new ArrayList<>();
+
+    private final NettyDataBufferFactory factory = new 
NettyDataBufferFactory(PooledByteBufAllocator.DEFAULT) {
+        @Override
+        public NettyDataBuffer allocateBuffer(final int initialCapacity) {
+            NettyDataBuffer buffer = super.allocateBuffer(initialCapacity);
+            allocations.add(buffer.getNativeBuffer());
+            return buffer;
+        }
+    };
+
+    @AfterEach
+    public void releaseTestBuffers() {
+        allocations.stream().filter(buffer -> buffer.refCnt() > 0 && !(buffer 
instanceof EmptyByteBuf)).forEach(ByteBuf::release);
+    }
+
+    @Test
+    public void testShortCircuitDoesNotAllocateRewrittenBody() {
+        ServerWebExchange rewritten = rewrite(exchange("original", 
StandardCharsets.UTF_8), body -> Mono.just(body + " changed"));
+        rewritten.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
+        rewritten.getResponse().setComplete().block();
+
+        assertTrue(allocations.isEmpty(), "An unread rewritten body must not 
allocate a pooled buffer");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"UTF-8", "UTF-16LE", "ISO-8859-1"})
+    public void testForwardingPreservesBodyCharsetAndHeaders(final String 
charsetName) {
+        Charset charset = Charset.forName(charsetName);
+        ServerWebExchange original = exchange("original", charset);
+        ServerWebExchange rewritten = rewrite(original, body -> Mono.just(body 
+ "-é"));
+
+        assertEquals("original-é", readBody(rewritten, charset));
+        assertEquals(original.getRequest().getHeaders().getContentType(), 
rewritten.getRequest().getHeaders().getContentType());
+        assertEquals("kept", 
rewritten.getRequest().getHeaders().getFirst("X-Test"));
+        assertEquals(-1, 
rewritten.getRequest().getHeaders().getContentLength());
+        assertEquals("chunked", 
rewritten.getRequest().getHeaders().getFirst(HttpHeaders.TRANSFER_ENCODING));
+        assertEquals(1, allocations.size());
+        assertBuffersReleased();
+    }
+
+    @Test
+    public void testRepeatedSubscriptionsEncodeFreshBuffers() {
+        AtomicInteger conversions = new AtomicInteger();
+        ServerWebExchange rewritten = rewrite(exchange("original", 
StandardCharsets.UTF_8), body -> {
+            conversions.incrementAndGet();
+            return Mono.just(body + " changed");
+        });
+
+        assertEquals("original changed", readBody(rewritten, 
StandardCharsets.UTF_8));
+        assertEquals("original changed", readBody(rewritten, 
StandardCharsets.UTF_8));
+        assertEquals(1, conversions.get(), "Re-reading the transformed body 
must not repeat conversion side effects");
+        assertEquals(2, allocations.size());
+        assertBuffersReleased();
+    }
+
+    @Test
+    public void testCancellationBeforeBodyDemandDoesNotAllocate() {
+        ServerWebExchange rewritten = rewrite(exchange("original", 
StandardCharsets.UTF_8), body -> Mono.just(body + " changed"));
+
+        StepVerifier.create(rewritten.getRequest().getBody(), 
0).thenCancel().verify();
+
+        assertTrue(allocations.isEmpty());
+    }
+
+    @Test
+    public void testCancellationAfterConsumptionReleasesBuffer() {
+        ServerWebExchange rewritten = rewrite(exchange("original", 
StandardCharsets.UTF_8), Mono::just);
+
+        StepVerifier.create(rewritten.getRequest().getBody(), 0)
+                .thenRequest(1)
+                .assertNext(buffer -> {
+                    assertEquals("original", 
buffer.toString(StandardCharsets.UTF_8));
+                    DataBufferUtils.release(buffer);
+                }).thenCancel().verify();
+
+        assertEquals(1, allocations.size());
+        assertBuffersReleased();
+    }
+
+    @Test
+    public void testCancellationDuringConversionDoesNotAllocate() {
+        
StepVerifier.create(ServerWebExchangeUtils.rewriteRequestBody(exchange("original",
 StandardCharsets.UTF_8),
+                ServerCodecConfigurer.create().getReaders(), body -> 
Mono.never())).thenCancel().verify();
+
+        assertTrue(allocations.isEmpty());
+    }
+
+    @Test
+    public void testConversionFailureIsPreservedWithoutAllocation() {
+        IllegalStateException failure = new IllegalStateException("conversion 
failed");
+        
StepVerifier.create(ServerWebExchangeUtils.rewriteRequestBody(exchange("original",
 StandardCharsets.UTF_8),
+                ServerCodecConfigurer.create().getReaders(), body -> 
Mono.error(failure)))
+                .expectErrorSatisfies(error -> assertSame(failure, error))
+                .verify();
+
+        assertTrue(allocations.isEmpty());
+    }
+
+    @Test
+    public void testEmptyInputCanBeRewritten() {
+        ServerWebExchange rewritten = rewrite(exchange("", 
StandardCharsets.UTF_8), body -> Mono.just(body + "replacement"));
+
+        assertEquals("replacement", readBody(rewritten, 
StandardCharsets.UTF_8));
+        assertBuffersReleased();
+    }
+
+    @Test
+    public void testEmptyOutputCanBeRead() {
+        ServerWebExchange rewritten = rewrite(exchange("original", 
StandardCharsets.UTF_8), body -> Mono.just(""));
+
+        assertEquals("", readBody(rewritten, StandardCharsets.UTF_8));
+        assertBuffersReleased();
+    }
+
+    private ServerWebExchange exchange(final String body, final Charset 
charset) {
+        byte[] bytes = body.getBytes(charset);
+        MockServerWebExchange original = 
MockServerWebExchange.from(MockServerHttpRequest.post("/test")
+                .contentType(new MediaType("text", "plain", 
charset)).contentLength(bytes.length).header("X-Test", "kept")
+                .body(Flux.just(new 
org.springframework.core.io.buffer.DefaultDataBufferFactory().wrap(bytes))));
+        return original.mutate().response(new 
ServerHttpResponseDecorator(original.getResponse()) {
+            @Override
+            public DataBufferFactory bufferFactory() {
+                return factory;
+            }
+        }).build();
+    }
+
+    private ServerWebExchange rewrite(final ServerWebExchange exchange, final 
Function<String, Mono<String>> convert) {
+        return ServerWebExchangeUtils.rewriteRequestBody(exchange, 
ServerCodecConfigurer.create().getReaders(), 
convert).block(Duration.ofSeconds(5));
+    }
+
+    private String readBody(final ServerWebExchange exchange, final Charset 
charset) {
+        return 
DataBufferUtils.join(exchange.getRequest().getBody()).map(buffer -> {
+            try {
+                return buffer.toString(charset);
+            } finally {
+                DataBufferUtils.release(buffer);
+            }
+        }).defaultIfEmpty("").block(Duration.ofSeconds(5));
+    }
+
+    private void assertBuffersReleased() {
+        // EmptyByteBuf is a shared, unreleasable zero-capacity buffer, not a 
pooled allocation.
+        allocations.stream().filter(buffer -> !(buffer instanceof 
EmptyByteBuf)).forEach(buffer -> assertEquals(0, buffer.refCnt()));
+    }
+}

Reply via email to