From 4512a4d1277b4c5fdb977de21c92e85e498919d2 Mon Sep 17 00:00:00 2001 From: Denis Stepanov Date: Fri, 22 Aug 2025 13:31:59 +0200 Subject: [PATCH 1/5] Improvements to empty body server behavior --- .../binders/NettyBodyAnnotationBinder.java | 12 +- .../binders/NettyInputStreamBodyBinder.java | 70 ----- .../NettyServerRequestBinderRegistry.java | 1 - .../bodyreadwrite/EmptyJsonBodyTest.java | 226 ++++++++++++++ .../bodyreadwrite/EmptyPlainTextBodyTest.java | 149 +++++++++ .../WriteBodyInteractionsTest.java | 283 ++++++++++++++++++ .../http/body/InputStreamBodyReader.java | 43 +++ 7 files changed, 710 insertions(+), 74 deletions(-) delete mode 100644 http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyInputStreamBodyBinder.java create mode 100644 http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/bodyreadwrite/EmptyJsonBodyTest.java create mode 100644 http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/bodyreadwrite/EmptyPlainTextBodyTest.java create mode 100644 http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/bodyreadwrite/WriteBodyInteractionsTest.java create mode 100644 http/src/main/java/io/micronaut/http/body/InputStreamBodyReader.java diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java index df68794bf04..be3cf9c2810 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java @@ -38,11 +38,12 @@ import io.micronaut.http.body.MessageBodyReader; import io.micronaut.http.codec.CodecException; import io.micronaut.http.context.ServerHttpRequestContext; -import io.micronaut.http.netty.body.NettyByteBodyFactory; +import io.micronaut.http.netty.body.AvailableNettyByteBody; import io.micronaut.http.server.netty.FormDataHttpContentProcessor; import io.micronaut.http.server.netty.FormRouteCompleter; import io.micronaut.http.server.netty.MicronautHttpData; import io.micronaut.http.server.netty.NettyHttpRequest; +import io.micronaut.http.server.netty.NettyHttpServer; import io.micronaut.http.server.netty.configuration.NettyHttpServerConfiguration; import io.micronaut.http.server.netty.converters.NettyConverters; import io.micronaut.web.router.RouteAttributes; @@ -53,6 +54,8 @@ import io.netty.buffer.CompositeByteBuf; import io.netty.handler.codec.http.DefaultLastHttpContent; import io.netty.handler.codec.http.multipart.InterfaceHttpData; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; @@ -61,12 +64,15 @@ @Internal final class NettyBodyAnnotationBinder extends DefaultBodyAnnotationBinder { + + private static final Logger LOG = LoggerFactory.getLogger(NettyHttpServer.class); + final NettyHttpServerConfiguration httpServerConfiguration; final MessageBodyHandlerRegistry bodyHandlerRegistry; NettyBodyAnnotationBinder(ConversionService conversionService, NettyHttpServerConfiguration httpServerConfiguration, - MessageBodyHandlerRegistry bodyHandlerRegistry) { + MessageBodyHandlerRegistry bodyHandlerRegistry) { super(conversionService); this.httpServerConfiguration = httpServerConfiguration; this.bodyHandlerRegistry = bodyHandlerRegistry; @@ -103,7 +109,7 @@ public BindingResult bindFullBody(ArgumentConversionContext context, HttpR if (!(source instanceof NettyHttpRequest nhr)) { return super.bindFullBody(context, source); } - if (nhr.byteBody().expectedLength().orElse(-1) == 0) { + if (context.getArgument().isNullable() && nhr.byteBody().expectedLength().orElse(-1) == 0) { return BindingResult.empty(); } diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyInputStreamBodyBinder.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyInputStreamBodyBinder.java deleted file mode 100644 index 023c97d0d8f..00000000000 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyInputStreamBodyBinder.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2017-2020 original authors - * - * Licensed 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 - * - * https://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 io.micronaut.http.server.netty.binders; - -import io.micronaut.core.annotation.Internal; -import io.micronaut.core.convert.ArgumentConversionContext; -import io.micronaut.core.type.Argument; -import io.micronaut.http.HttpRequest; -import io.micronaut.http.bind.binders.NonBlockingBodyArgumentBinder; -import io.micronaut.http.exceptions.ContentLengthExceededException; -import io.micronaut.http.server.netty.NettyHttpRequest; -import io.micronaut.http.server.netty.NettyHttpServer; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.InputStream; -import java.util.Optional; - -/** - * Responsible for binding to a {@link InputStream} argument from the body of the request. - * - * @author James Kleeh - * @since 2.5.0 - */ -@Internal -final class NettyInputStreamBodyBinder implements NonBlockingBodyArgumentBinder { - - public static final Argument TYPE = Argument.of(InputStream.class); - private static final Logger LOG = LoggerFactory.getLogger(NettyHttpServer.class); - - NettyInputStreamBodyBinder() { - } - - @Override - public Argument argumentType() { - return TYPE; - } - - @Override - public BindingResult bind(ArgumentConversionContext context, HttpRequest source) { - if (source instanceof NettyHttpRequest nhr) { - if (nhr.byteBody().expectedLength().orElse(-1) == 0) { - return BindingResult.empty(); - } - try { - InputStream s = nhr.byteBody().toInputStream(); - return () -> Optional.of(s); - } catch (ContentLengthExceededException t) { - if (LOG.isTraceEnabled()) { - LOG.trace("Server received error for argument [{}]: {}", context.getArgument(), t.getMessage(), t); - } - return BindingResult.empty(); - } - } - return BindingResult.empty(); - } -} diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyServerRequestBinderRegistry.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyServerRequestBinderRegistry.java index d1b54c389d8..5cdf31fe7e8 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyServerRequestBinderRegistry.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyServerRequestBinderRegistry.java @@ -68,7 +68,6 @@ public NettyServerRequestBinderRegistry(ConversionService conversionService, internalRequestBinderRegistry.addArgumentBinder(new MultipartBodyArgumentBinder( httpServerConfiguration )); - internalRequestBinderRegistry.addArgumentBinder(new NettyInputStreamBodyBinder()); NettyStreamingFileUpload.Factory fileUploadFactory = new NettyStreamingFileUpload.Factory( httpServerConfiguration.get().getMultipart(), executorService.get() diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/bodyreadwrite/EmptyJsonBodyTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/bodyreadwrite/EmptyJsonBodyTest.java new file mode 100644 index 00000000000..ee502a2d660 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/bodyreadwrite/EmptyJsonBodyTest.java @@ -0,0 +1,226 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed 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 + * + * https://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 io.micronaut.http.server.tck.tests.bodyreadwrite; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Consumes; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.tck.AssertionUtils; +import io.micronaut.http.tck.BodyAssertion; +import io.micronaut.http.tck.HttpResponseAssertion; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static io.micronaut.http.tck.TestScenario.asserts; + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class EmptyJsonBodyTest { + public static final String SPEC_NAME = "EmptyJsonBodyTest"; + + private Map getConfiguration() { + return Map.of( + "micronaut.server.not-found-on-missing-body", "false" + ); + } + + @Test + void stringBody() throws IOException { + asserts(SPEC_NAME, + getConfiguration(), + HttpRequest.POST("/myController/string", "FooBar").accept(MediaType.APPLICATION_JSON).contentType(MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("FooBar") + .build())); + } + + @Test + void bytesBody() throws IOException { + asserts(SPEC_NAME, + getConfiguration(), + HttpRequest.POST("/myController/bytes", "FooBar").accept(MediaType.APPLICATION_JSON).contentType(MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("FooBar") + .build())); + } + + @Test + void ioBody() throws IOException { + asserts(SPEC_NAME, + getConfiguration(), + HttpRequest.POST("/myController/io", "FooBar").accept(MediaType.APPLICATION_JSON).contentType(MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("FooBar") + .build())); + } + + @Test + void beanEmptyBody() throws IOException { + asserts(SPEC_NAME, + getConfiguration(), + HttpRequest.POST("/myController/bean", "").accept(MediaType.APPLICATION_JSON).contentType(MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.NO_CONTENT) + .body(BodyAssertion.IS_MISSING) + .build())); + } + + @Test + void stringEmptyBody() throws IOException { + asserts(SPEC_NAME, + getConfiguration(), + HttpRequest.POST("/myController/string", "").accept(MediaType.APPLICATION_JSON).contentType(MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body(BodyAssertion.IS_MISSING) + .build())); + } + + @Test + void bytesEmptyBody() throws IOException { + asserts(SPEC_NAME, + getConfiguration(), + HttpRequest.POST("/myController/bytes", "").accept(MediaType.APPLICATION_JSON).contentType(MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body(BodyAssertion.IS_MISSING) + .build())); + } + + @Test + void ioEmptyBody() throws IOException { + asserts(SPEC_NAME, + getConfiguration(), + HttpRequest.POST("/myController/io", "").accept(MediaType.APPLICATION_JSON).contentType(MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body(BodyAssertion.IS_MISSING) + .build())); + } + + @Test + void stringNullableBody() throws IOException { + asserts(SPEC_NAME, + getConfiguration(), + HttpRequest.POST("/myController/stringNullable", "").accept(MediaType.APPLICATION_JSON).contentType(MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("") + .build())); + } + + @Test + void bytesNullableBody() throws IOException { + asserts(SPEC_NAME, + getConfiguration(), + HttpRequest.POST("/myController/bytesNullable", "").accept(MediaType.APPLICATION_JSON).contentType(MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("") + .build())); + } + + @Test + void ioNullableEmptyBody() throws IOException { + asserts(SPEC_NAME, + getConfiguration(), + HttpRequest.POST("/myController/ioNullable", "").accept(MediaType.APPLICATION_JSON).contentType(MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("") + .build())); + } + + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Controller("/myController") + @Requires(property = "spec.name", value = SPEC_NAME) + static class MyController { + + @Post("/bean") + MyBean bean(@Body @Nullable MyBean bean) { + return bean; + } + + @Post("/string") + String string(@Body String foobar) { + return foobar; + } + + @Post("/stringNullable") + String stringNullable(@Body @Nullable String foobar) { + if (foobar == null) { + return nullBodyValue(); + } + return foobar; + } + + @Post("/bytes") + byte[] bytes(@Body byte[] foobar) { + return foobar; + } + + @Post("/bytesNullable") + byte[] bytesNullable(@Nullable @Body byte[] foobar) { + if (foobar == null) { + return nullBodyValue().getBytes(StandardCharsets.UTF_8); + } + return foobar; + } + + @Post("/io") + InputStream inputStream(@Body InputStream is) { + return is; + } + + @Post("/ioNullable") + InputStream inputNullableStream(@Body @Nullable InputStream is) { + if (is == null) { + return new ByteArrayInputStream(nullBodyValue().getBytes(StandardCharsets.UTF_8)); + } + return is; + } + + private String nullBodyValue() { + return ""; + } + + } + + @Introspected + record MyBean() { + } + +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/bodyreadwrite/EmptyPlainTextBodyTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/bodyreadwrite/EmptyPlainTextBodyTest.java new file mode 100644 index 00000000000..13b5d2d9ca2 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/bodyreadwrite/EmptyPlainTextBodyTest.java @@ -0,0 +1,149 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed 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 + * + * https://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 io.micronaut.http.server.tck.tests.bodyreadwrite; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Consumes; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.tck.AssertionUtils; +import io.micronaut.http.tck.BodyAssertion; +import io.micronaut.http.tck.HttpResponseAssertion; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +import static io.micronaut.http.tck.TestScenario.asserts; + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class EmptyPlainTextBodyTest { + public static final String SPEC_NAME = "EmptyPlainTextBodyTest"; + + @Test + void stringBody() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/myController/string", "FooBar").accept(MediaType.TEXT_PLAIN).contentType(MediaType.TEXT_PLAIN), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("FooBar") + .build())); + } + + @Test + void bytesBody() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/myController/bytes", "FooBar").accept(MediaType.TEXT_PLAIN).contentType(MediaType.TEXT_PLAIN), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("FooBar") + .build())); + } + + @Test + void ioBody() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/myController/io", "FooBar").accept(MediaType.TEXT_PLAIN).contentType(MediaType.TEXT_PLAIN), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("FooBar") + .build())); + } + + @Test + void stringEmptyBody() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/myController/string", "").accept(MediaType.TEXT_PLAIN).contentType(MediaType.TEXT_PLAIN), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body(BodyAssertion.IS_MISSING) + .build())); + } + + @Test + void bytesEmptyBody() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/myController/bytes", "").accept(MediaType.TEXT_PLAIN).contentType(MediaType.TEXT_PLAIN), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body(BodyAssertion.IS_MISSING) + .build())); + } + + @Test + void ioEmptyBody() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/myController/io", "").accept(MediaType.TEXT_PLAIN).contentType(MediaType.TEXT_PLAIN), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body(BodyAssertion.IS_MISSING) + .build())); + } + + @Test + void ioNullableEmptyBody() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/myController/ioNullable", "").accept(MediaType.TEXT_PLAIN).contentType(MediaType.TEXT_PLAIN), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body(BodyAssertion.IS_MISSING) + .build())); + } + + @Consumes(MediaType.TEXT_PLAIN) + @Produces(MediaType.TEXT_PLAIN) + @Controller("/myController") + @Requires(property = "spec.name", value = SPEC_NAME) + static class MyController { + + @Post("/string") + String string(@Body String foobar) { + return foobar; + } + + @Post("/bytes") + byte[] bytes(@Body byte[] foobar) { + return foobar; + } + + @Post("/io") + InputStream inputStream(@Body InputStream is) { + return is; + } + + @Post("/ioNullable") + InputStream inputNullableStream(@Body @Nullable InputStream is) { + if (is == null) { + return new ByteArrayInputStream(new byte[0]); + } + return is; + } + + } + + +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/bodyreadwrite/WriteBodyInteractionsTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/bodyreadwrite/WriteBodyInteractionsTest.java new file mode 100644 index 00000000000..58d490b107e --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/bodyreadwrite/WriteBodyInteractionsTest.java @@ -0,0 +1,283 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed 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 + * + * https://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 io.micronaut.http.server.tck.tests.bodyreadwrite; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.AnnotationValue; +import io.micronaut.core.annotation.Order; +import io.micronaut.core.convert.exceptions.ConversionErrorException; +import io.micronaut.core.order.Ordered; +import io.micronaut.core.type.Argument; +import io.micronaut.core.type.Headers; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Error; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.body.MessageBodyReader; +import io.micronaut.http.codec.CodecException; +import io.micronaut.http.exceptions.HttpStatusException; +import io.micronaut.http.tck.AssertionUtils; +import io.micronaut.http.tck.HttpResponseAssertion; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.nio.charset.StandardCharsets; + +import static io.micronaut.http.tck.TestScenario.asserts; + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class WriteBodyInteractionsTest { + public static final String SPEC_NAME = "WriteBodyInteractionsTest"; + + @Test + void stringHeader() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/myController/stringHEADER", "").header("foobar", "abc123"), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("abc123") + .build())); + } + + @Test + void stringHttpException() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/myController/stringHTTP_EXCEPTION", "").header("foobar", "abc123"), + (server, request) -> AssertionUtils.assertThrows(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.I_AM_A_TEAPOT) + .body("A http exception") + .build())); + } + + @Test + void stringIOException() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/myController/stringIO_EXCEPTION", "").header("foobar", "abc123"), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.ACCEPTED) + .body("IO EXCEPTION") + .build())); + } + + @Test + void bodyHeader() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/myController/byteArrayHEADER", "").header("foobar", "abc123"), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("abc123") + .build())); + } + + @Test + void bodyHttpException() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/myController/byteArrayHTTP_EXCEPTION", "").header("foobar", "abc123"), + (server, request) -> AssertionUtils.assertThrows(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.I_AM_A_TEAPOT) + .body("A http exception") + .build())); + } + + @Test + void bodyIOException() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/myController/byteArrayIO_EXCEPTION", "").header("foobar", "abc123"), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.ACCEPTED) + .body("IO EXCEPTION") + .build())); + } + + @Test + void inputStreamHeader() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/myController/inputStreamHEADER", "").header("foobar", "abc123"), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("abc123") + .build())); + } + + @Test + void inputStreamHttpException() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/myController/inputStreamHTTP_EXCEPTION", "").header("foobar", "abc123"), + (server, request) -> AssertionUtils.assertThrows(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.I_AM_A_TEAPOT) + .body("A http exception") + .build())); + } + + @Test + void inputStreamIOException() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/myController/inputStreamIO_EXCEPTION", "").header("foobar", "abc123"), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.ACCEPTED) + .body("IO EXCEPTION") + .build())); + } + + @Controller("/myController") + @Requires(property = "spec.name", value = SPEC_NAME) + static class MyController { + + @Post("/stringHEADER") + @Produces(MediaType.TEXT_PLAIN) + String stringHeader(@Command("HEADER") @Body String foobar) { + return foobar; + } + + @Post("/stringHTTP_EXCEPTION") + @Produces(MediaType.TEXT_PLAIN) + String stringHttpException(@Command("HTTP_EXCEPTION") @Body String foobar) { + return foobar; + } + + @Post("/stringIO_EXCEPTION") + @Produces(MediaType.TEXT_PLAIN) + String stringIO(@Command("IO_EXCEPTION") @Body String foobar) { + return foobar; + } + + @Post("/byteArrayHEADER") + @Produces(MediaType.TEXT_PLAIN) + byte[] byteArrayHeader(@Command("HEADER") @Body byte[] foobar) { + return foobar; + } + + @Post("/byteArrayHTTP_EXCEPTION") + @Produces(MediaType.TEXT_PLAIN) + byte[] byteArrayHttpException(@Command("HTTP_EXCEPTION") @Body byte[] foobar) { + return foobar; + } + + @Post("/byteArrayIO_EXCEPTION") + @Produces(MediaType.TEXT_PLAIN) + byte[] byteArrayIO(@Command("IO_EXCEPTION") @Body byte[] foobar) { + return foobar; + } + + @Post("/inputStreamHEADER") + @Produces(MediaType.TEXT_PLAIN) + InputStream inputStreamHeader(@Command("HEADER") @Body InputStream foobar) { + return foobar; + } + + @Post("/inputStreamHTTP_EXCEPTION") + @Produces(MediaType.TEXT_PLAIN) + InputStream inputStreamHttpException(@Command("HTTP_EXCEPTION") @Body InputStream foobar) { + return foobar; + } + + @Post("/inputStreamIO_EXCEPTION") + @Produces(MediaType.TEXT_PLAIN) + InputStream inputStreamIO(@Command("IO_EXCEPTION") @Body InputStream foobar) { + return foobar; + } + + @Error + HttpResponse onError(ConversionErrorException throwable) { + return HttpResponse.accepted().body(throwable.getConversionError().getCause().getMessage()); + } + } + + private static String getCommandValue(Headers httpHeaders, AnnotationValue annotation) { + return switch (annotation.stringValue().orElseThrow()) { + case "HEADER" -> httpHeaders.get("foobar"); + case "HTTP_EXCEPTION" -> + throw new HttpStatusException(HttpStatus.I_AM_A_TEAPOT, "A http exception"); + case "IO_EXCEPTION" -> throw new CodecException("IO EXCEPTION"); + default -> throw new AssertionError("Unknown command: " + annotation.stringValue()); + }; + } + + @Requires(property = "spec.name", value = SPEC_NAME) + @Order(Ordered.HIGHEST_PRECEDENCE) + @Singleton + static final class StringBodyCommandBodyReader implements MessageBodyReader { + + @Override + public boolean isReadable(Argument type, MediaType mediaType) { + AnnotationValue annotation = type.getAnnotation(Command.class); + return annotation != null; + } + + @Override + public String read(Argument type, MediaType mediaType, Headers httpHeaders, InputStream inputStream) throws CodecException { + AnnotationValue annotation = type.getAnnotation(Command.class); + return getCommandValue(httpHeaders, annotation); + } + + } + + @Requires(property = "spec.name", value = SPEC_NAME) + @Order(Ordered.HIGHEST_PRECEDENCE) + @Singleton + static final class ByteArrayCommandMessageBodyReader implements MessageBodyReader { + + @Override + public boolean isReadable(Argument type, MediaType mediaType) { + AnnotationValue annotation = type.getAnnotation(Command.class); + return annotation != null; + } + + @Override + public byte[] read(Argument type, MediaType mediaType, Headers httpHeaders, InputStream inputStream) throws CodecException { + AnnotationValue annotation = type.getAnnotation(Command.class); + return getCommandValue(httpHeaders, annotation).getBytes(StandardCharsets.UTF_8); + } + } + + @Requires(property = "spec.name", value = SPEC_NAME) + @Order(Ordered.HIGHEST_PRECEDENCE) + @Singleton + static final class InputStreamCommandMessageBodyReader implements MessageBodyReader { + + @Override + public boolean isReadable(Argument type, MediaType mediaType) { + AnnotationValue annotation = type.getAnnotation(Command.class); + return annotation != null; + } + + @Override + public InputStream read(Argument type, MediaType mediaType, Headers httpHeaders, InputStream inputStream) throws CodecException { + AnnotationValue annotation = type.getAnnotation(Command.class); + return new ByteArrayInputStream(getCommandValue(httpHeaders, annotation).getBytes(StandardCharsets.UTF_8)); + } + } + + @Retention(RetentionPolicy.RUNTIME) + @interface Command { + String value(); + } + +} diff --git a/http/src/main/java/io/micronaut/http/body/InputStreamBodyReader.java b/http/src/main/java/io/micronaut/http/body/InputStreamBodyReader.java new file mode 100644 index 00000000000..ecd9afadcf1 --- /dev/null +++ b/http/src/main/java/io/micronaut/http/body/InputStreamBodyReader.java @@ -0,0 +1,43 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed 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 + * + * https://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 io.micronaut.http.body; + +import io.micronaut.context.annotation.BootstrapContextCompatible; +import io.micronaut.context.annotation.Prototype; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.type.Argument; +import io.micronaut.core.type.Headers; +import io.micronaut.http.MediaType; +import io.micronaut.http.codec.CodecException; + +import java.io.InputStream; + +/** + * The body handler for input stream. + * + * @author Denis Stepanov + * @since 4.10 + */ +@Prototype +@BootstrapContextCompatible +@Internal +final class InputStreamBodyReader implements MessageBodyReader { + + @Override + public InputStream read(Argument type, MediaType mediaType, Headers httpHeaders, InputStream inputStream) throws CodecException { + return inputStream; + } +} From 6d0bd2de5184ef3dcfa8749e12ca494fa3e1d194 Mon Sep 17 00:00:00 2001 From: Denis Stepanov Date: Wed, 3 Sep 2025 12:47:00 +0200 Subject: [PATCH 2/5] Correct --- .../context/visitor/ExecutableVisitor.java | 4 ++- .../bind/DefaultRequestBinderRegistry.java | 27 +++++++++++++++---- .../http/body/InputStreamBodyReader.java | 7 ++++- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/core-processor/src/main/java/io/micronaut/context/visitor/ExecutableVisitor.java b/core-processor/src/main/java/io/micronaut/context/visitor/ExecutableVisitor.java index 5893c0da2a7..aef0af0d7fa 100644 --- a/core-processor/src/main/java/io/micronaut/context/visitor/ExecutableVisitor.java +++ b/core-processor/src/main/java/io/micronaut/context/visitor/ExecutableVisitor.java @@ -18,6 +18,7 @@ import io.micronaut.context.annotation.Executable; import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; +import io.micronaut.inject.ast.ClassElement; import io.micronaut.inject.ast.KotlinParameterElement; import io.micronaut.inject.ast.MethodElement; import io.micronaut.inject.ast.ParameterElement; @@ -48,7 +49,8 @@ public TypeElementQuery query() { @Override public void visitMethod(MethodElement element, VisitorContext context) { for (ParameterElement parameter : element.getParameters()) { - if (parameter.getType().isPrimitive() && parameter.isNullable() + ClassElement type = parameter.getType(); + if (type.isPrimitive() && !type.isArray() && parameter.isNullable() && !(parameter instanceof KotlinParameterElement kotlinParameterElement && kotlinParameterElement.hasDefault())) { context.warn("@Nullable on primitive types will allow the method to be executed at runtime with null values, causing an exception", parameter); } diff --git a/http/src/main/java/io/micronaut/http/bind/DefaultRequestBinderRegistry.java b/http/src/main/java/io/micronaut/http/bind/DefaultRequestBinderRegistry.java index 239bd3894c4..44dd7674537 100644 --- a/http/src/main/java/io/micronaut/http/bind/DefaultRequestBinderRegistry.java +++ b/http/src/main/java/io/micronaut/http/bind/DefaultRequestBinderRegistry.java @@ -15,6 +15,8 @@ */ package io.micronaut.http.bind; +import io.micronaut.core.annotation.AnnotationMetadata; +import io.micronaut.core.annotation.AnnotationUtil; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.bind.ArgumentBinder; import io.micronaut.core.bind.annotation.Bindable; @@ -33,8 +35,8 @@ import io.micronaut.http.annotation.Body; import io.micronaut.http.bind.binders.AnnotatedRequestArgumentBinder; import io.micronaut.http.bind.binders.ContinuationArgumentBinder; -import io.micronaut.http.bind.binders.CookieObjectArgumentBinder; import io.micronaut.http.bind.binders.CookieAnnotationBinder; +import io.micronaut.http.bind.binders.CookieObjectArgumentBinder; import io.micronaut.http.bind.binders.DefaultBodyAnnotationBinder; import io.micronaut.http.bind.binders.DefaultUnmatchedRequestArgumentBinder; import io.micronaut.http.bind.binders.HeaderAnnotationBinder; @@ -48,6 +50,8 @@ import io.micronaut.http.bind.binders.TypedRequestArgumentBinder; import io.micronaut.http.cookie.Cookie; import io.micronaut.http.cookie.Cookies; +import io.micronaut.inject.annotation.AnnotationMetadataHierarchy; +import io.micronaut.inject.annotation.MutableAnnotationMetadata; import jakarta.inject.Inject; import jakarta.inject.Singleton; @@ -71,7 +75,7 @@ public class DefaultRequestBinderRegistry implements RequestBinderRegistry { private static final long CACHE_MAX_SIZE = 30; - + private static final AnnotationMetadata NULLABLE_ANNOTATION_METADATA; private final Map, RequestArgumentBinder> byAnnotation = new LinkedHashMap<>(); private final Map byTypeAndAnnotation = new LinkedHashMap<>(); private final Map byType = new LinkedHashMap<>(); @@ -81,6 +85,12 @@ public class DefaultRequestBinderRegistry implements RequestBinderRegistry { private final List> unmatchedBinders = new ArrayList<>(); private final DefaultUnmatchedRequestArgumentBinder defaultUnmatchedRequestArgumentBinder; + static { + MutableAnnotationMetadata nullable = new MutableAnnotationMetadata(); + nullable.addAnnotation(AnnotationUtil.NULLABLE, Map.of()); + NULLABLE_ANNOTATION_METADATA = nullable; + } + /** * @param conversionService The conversion service * @param binders The request argument binders @@ -268,7 +278,14 @@ private static ArgumentBinder.BindingResult> convertBod .filter(arg -> arg.getType() != Void.class); if (typeVariable.isPresent()) { @SuppressWarnings("unchecked") - ArgumentConversionContext unwrappedConversionContext = ConversionContext.of((Argument) typeVariable.get()); + Argument argument = (Argument) typeVariable.get(); + argument = argument.withAnnotationMetadata( + new AnnotationMetadataHierarchy( + argument.getAnnotationMetadata(), + NULLABLE_ANNOTATION_METADATA // HttpRequest's body can be null + ) + ); + ArgumentConversionContext unwrappedConversionContext = ConversionContext.of(argument); ArgumentBinder.BindingResult bodyBound = bodyAnnotationBinder.bindFullBody(unwrappedConversionContext, source); // can't use flatMap here because we return a present optional even when the body conversion failed return new PendingRequestBindingResult<>() { @@ -286,14 +303,14 @@ public List getConversionErrors() { public Optional> getValue() { Optional body = bodyBound.getValue(); if (pushCapable) { - return Optional.of(new PushCapableRequestWrapper((HttpRequest) source, (PushCapableHttpRequest) source) { + return Optional.of(new PushCapableRequestWrapper<>((HttpRequest) source, (PushCapableHttpRequest) source) { @Override public Optional getBody() { return body; } }); } else { - return Optional.of(new HttpRequestWrapper((HttpRequest) source) { + return Optional.of(new HttpRequestWrapper<>((HttpRequest) source) { @Override public Optional getBody() { return body; diff --git a/http/src/main/java/io/micronaut/http/body/InputStreamBodyReader.java b/http/src/main/java/io/micronaut/http/body/InputStreamBodyReader.java index ecd9afadcf1..d5cd8f0e268 100644 --- a/http/src/main/java/io/micronaut/http/body/InputStreamBodyReader.java +++ b/http/src/main/java/io/micronaut/http/body/InputStreamBodyReader.java @@ -34,10 +34,15 @@ @Prototype @BootstrapContextCompatible @Internal -final class InputStreamBodyReader implements MessageBodyReader { +final class InputStreamBodyReader implements TypedMessageBodyReader { @Override public InputStream read(Argument type, MediaType mediaType, Headers httpHeaders, InputStream inputStream) throws CodecException { return inputStream; } + + @Override + public Argument getType() { + return Argument.of(InputStream.class); + } } From f68e8fbee0240b96c52cd787c087182cf968c798 Mon Sep 17 00:00:00 2001 From: Denis Stepanov Date: Thu, 4 Sep 2025 13:23:09 +0200 Subject: [PATCH 3/5] Correct --- .../binders/NettyBodyAnnotationBinder.java | 12 +++---- .../bind/DefaultRequestBinderRegistry.java | 22 +------------ .../binders/DefaultBodyAnnotationBinder.java | 32 +++++++++++++++++++ 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java index be3cf9c2810..0d42c5d55a1 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java @@ -17,7 +17,6 @@ import io.micronaut.core.annotation.Internal; import io.micronaut.core.convert.ArgumentConversionContext; -import io.micronaut.core.convert.ConversionContext; import io.micronaut.core.convert.ConversionError; import io.micronaut.core.convert.ConversionService; import io.micronaut.core.convert.value.ConvertibleValues; @@ -25,6 +24,7 @@ import io.micronaut.core.io.buffer.ByteBuffer; import io.micronaut.core.io.buffer.ReferenceCounted; import io.micronaut.core.propagation.PropagatedContext; +import io.micronaut.core.type.Argument; import io.micronaut.http.HttpHeaders; import io.micronaut.http.HttpRequest; import io.micronaut.http.MediaType; @@ -97,10 +97,10 @@ protected BindingResult> bindFullBodyConvertibleValues(Http if (existing != null) { return existing; } else { - //noinspection unchecked - BindingResult> result = (BindingResult>) bindFullBody((ArgumentConversionContext) ConversionContext.of(ConvertibleValues.class), nhr); - nhr.convertibleBody = result; - return result; + Argument objectArgument = (Argument) Argument.of(ConvertibleValues.class); + BindingResult result = bindFullBodyNullable(objectArgument, nhr); + nhr.convertibleBody = (BindingResult>) result; + return (BindingResult>) result; } } @@ -109,7 +109,7 @@ public BindingResult bindFullBody(ArgumentConversionContext context, HttpR if (!(source instanceof NettyHttpRequest nhr)) { return super.bindFullBody(context, source); } - if (context.getArgument().isNullable() && nhr.byteBody().expectedLength().orElse(-1) == 0) { + if (nhr.byteBody().expectedLength().orElse(-1) == 0) { return BindingResult.empty(); } diff --git a/http/src/main/java/io/micronaut/http/bind/DefaultRequestBinderRegistry.java b/http/src/main/java/io/micronaut/http/bind/DefaultRequestBinderRegistry.java index 44dd7674537..761afc66396 100644 --- a/http/src/main/java/io/micronaut/http/bind/DefaultRequestBinderRegistry.java +++ b/http/src/main/java/io/micronaut/http/bind/DefaultRequestBinderRegistry.java @@ -15,13 +15,10 @@ */ package io.micronaut.http.bind; -import io.micronaut.core.annotation.AnnotationMetadata; -import io.micronaut.core.annotation.AnnotationUtil; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.bind.ArgumentBinder; import io.micronaut.core.bind.annotation.Bindable; import io.micronaut.core.convert.ArgumentConversionContext; -import io.micronaut.core.convert.ConversionContext; import io.micronaut.core.convert.ConversionError; import io.micronaut.core.convert.ConversionService; import io.micronaut.core.type.Argument; @@ -50,8 +47,6 @@ import io.micronaut.http.bind.binders.TypedRequestArgumentBinder; import io.micronaut.http.cookie.Cookie; import io.micronaut.http.cookie.Cookies; -import io.micronaut.inject.annotation.AnnotationMetadataHierarchy; -import io.micronaut.inject.annotation.MutableAnnotationMetadata; import jakarta.inject.Inject; import jakarta.inject.Singleton; @@ -75,7 +70,6 @@ public class DefaultRequestBinderRegistry implements RequestBinderRegistry { private static final long CACHE_MAX_SIZE = 30; - private static final AnnotationMetadata NULLABLE_ANNOTATION_METADATA; private final Map, RequestArgumentBinder> byAnnotation = new LinkedHashMap<>(); private final Map byTypeAndAnnotation = new LinkedHashMap<>(); private final Map byType = new LinkedHashMap<>(); @@ -85,11 +79,6 @@ public class DefaultRequestBinderRegistry implements RequestBinderRegistry { private final List> unmatchedBinders = new ArrayList<>(); private final DefaultUnmatchedRequestArgumentBinder defaultUnmatchedRequestArgumentBinder; - static { - MutableAnnotationMetadata nullable = new MutableAnnotationMetadata(); - nullable.addAnnotation(AnnotationUtil.NULLABLE, Map.of()); - NULLABLE_ANNOTATION_METADATA = nullable; - } /** * @param conversionService The conversion service @@ -277,16 +266,7 @@ private static ArgumentBinder.BindingResult> convertBod .filter(arg -> arg.getType() != Object.class) .filter(arg -> arg.getType() != Void.class); if (typeVariable.isPresent()) { - @SuppressWarnings("unchecked") - Argument argument = (Argument) typeVariable.get(); - argument = argument.withAnnotationMetadata( - new AnnotationMetadataHierarchy( - argument.getAnnotationMetadata(), - NULLABLE_ANNOTATION_METADATA // HttpRequest's body can be null - ) - ); - ArgumentConversionContext unwrappedConversionContext = ConversionContext.of(argument); - ArgumentBinder.BindingResult bodyBound = bodyAnnotationBinder.bindFullBody(unwrappedConversionContext, source); + ArgumentBinder.BindingResult bodyBound = bodyAnnotationBinder.bindFullBodyNullable((Argument) typeVariable.get(), source); // can't use flatMap here because we return a present optional even when the body conversion failed return new PendingRequestBindingResult<>() { @Override diff --git a/http/src/main/java/io/micronaut/http/bind/binders/DefaultBodyAnnotationBinder.java b/http/src/main/java/io/micronaut/http/bind/binders/DefaultBodyAnnotationBinder.java index 8d407aea14b..b10d5bf6ebd 100644 --- a/http/src/main/java/io/micronaut/http/bind/binders/DefaultBodyAnnotationBinder.java +++ b/http/src/main/java/io/micronaut/http/bind/binders/DefaultBodyAnnotationBinder.java @@ -15,14 +15,21 @@ */ package io.micronaut.http.bind.binders; +import io.micronaut.core.annotation.AnnotationMetadata; +import io.micronaut.core.annotation.AnnotationUtil; import io.micronaut.core.bind.annotation.AbstractArgumentBinder; import io.micronaut.core.convert.ArgumentConversionContext; +import io.micronaut.core.convert.ConversionContext; import io.micronaut.core.convert.ConversionService; import io.micronaut.core.convert.value.ConvertibleValues; +import io.micronaut.core.type.Argument; import io.micronaut.http.HttpRequest; import io.micronaut.http.annotation.Body; +import io.micronaut.inject.annotation.AnnotationMetadataHierarchy; +import io.micronaut.inject.annotation.MutableAnnotationMetadata; import jakarta.inject.Singleton; +import java.util.Map; import java.util.Optional; /** @@ -35,8 +42,16 @@ @Singleton public class DefaultBodyAnnotationBinder extends AbstractArgumentBinder implements BodyArgumentBinder { + private static final AnnotationMetadata NULLABLE_ANNOTATION_METADATA; + protected final ConversionService conversionService; + static { + MutableAnnotationMetadata nullable = new MutableAnnotationMetadata(); + nullable.addAnnotation(AnnotationUtil.NULLABLE, Map.of()); + NULLABLE_ANNOTATION_METADATA = nullable; + } + /** * @param conversionService The conversion service */ @@ -108,4 +123,21 @@ public BindingResult bindFullBody(ArgumentConversionContext context, HttpR Optional body = source.getBody(); return body.isPresent() ? doConvert(body.get(), context) : BindingResult.empty(); } + + /** + * Alternative to {@link #bindFullBody(ArgumentConversionContext, HttpRequest)} where the argument is marked as nullable. + * + * @param argument The argument + * @param source The request + * @return The binding result + */ + public BindingResult bindFullBodyNullable(Argument argument, HttpRequest source) { + ArgumentConversionContext context = ConversionContext.of(argument.withAnnotationMetadata( + new AnnotationMetadataHierarchy( + argument.getAnnotationMetadata(), + NULLABLE_ANNOTATION_METADATA + ) + )); + return bindFullBody(context, source); + } } From 85fd4f118bf3ce4c8f584790c587b6b9f109b46c Mon Sep 17 00:00:00 2001 From: Denis Stepanov Date: Thu, 4 Sep 2025 13:39:02 +0200 Subject: [PATCH 4/5] Correct --- .../server/netty/binders/NettyBodyAnnotationBinder.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java index 0d42c5d55a1..58727441746 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java @@ -38,12 +38,11 @@ import io.micronaut.http.body.MessageBodyReader; import io.micronaut.http.codec.CodecException; import io.micronaut.http.context.ServerHttpRequestContext; -import io.micronaut.http.netty.body.AvailableNettyByteBody; +import io.micronaut.http.netty.body.NettyByteBodyFactory; import io.micronaut.http.server.netty.FormDataHttpContentProcessor; import io.micronaut.http.server.netty.FormRouteCompleter; import io.micronaut.http.server.netty.MicronautHttpData; import io.micronaut.http.server.netty.NettyHttpRequest; -import io.micronaut.http.server.netty.NettyHttpServer; import io.micronaut.http.server.netty.configuration.NettyHttpServerConfiguration; import io.micronaut.http.server.netty.converters.NettyConverters; import io.micronaut.web.router.RouteAttributes; @@ -54,8 +53,6 @@ import io.netty.buffer.CompositeByteBuf; import io.netty.handler.codec.http.DefaultLastHttpContent; import io.netty.handler.codec.http.multipart.InterfaceHttpData; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; @@ -65,8 +62,6 @@ @Internal final class NettyBodyAnnotationBinder extends DefaultBodyAnnotationBinder { - private static final Logger LOG = LoggerFactory.getLogger(NettyHttpServer.class); - final NettyHttpServerConfiguration httpServerConfiguration; final MessageBodyHandlerRegistry bodyHandlerRegistry; From c34bacdbafd21b0f2ec471aa6d73d5dca104ad3d Mon Sep 17 00:00:00 2001 From: Denis Stepanov Date: Thu, 4 Sep 2025 15:06:36 +0200 Subject: [PATCH 5/5] Correct --- .../http/server/netty/binders/NettyBodyAnnotationBinder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java index 58727441746..1af2d2458ad 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/binders/NettyBodyAnnotationBinder.java @@ -104,7 +104,7 @@ public BindingResult bindFullBody(ArgumentConversionContext context, HttpR if (!(source instanceof NettyHttpRequest nhr)) { return super.bindFullBody(context, source); } - if (nhr.byteBody().expectedLength().orElse(-1) == 0) { + if (context.getArgument().isNullable() && nhr.byteBody().expectedLength().orElse(-1) == 0) { return BindingResult.empty(); }