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-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..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 @@ -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; @@ -61,12 +61,13 @@ @Internal final class NettyBodyAnnotationBinder extends DefaultBodyAnnotationBinder { + final NettyHttpServerConfiguration httpServerConfiguration; final MessageBodyHandlerRegistry bodyHandlerRegistry; NettyBodyAnnotationBinder(ConversionService conversionService, NettyHttpServerConfiguration httpServerConfiguration, - MessageBodyHandlerRegistry bodyHandlerRegistry) { + MessageBodyHandlerRegistry bodyHandlerRegistry) { super(conversionService); this.httpServerConfiguration = httpServerConfiguration; this.bodyHandlerRegistry = bodyHandlerRegistry; @@ -91,10 +92,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; } } @@ -103,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(); } 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/bind/DefaultRequestBinderRegistry.java b/http/src/main/java/io/micronaut/http/bind/DefaultRequestBinderRegistry.java index 239bd3894c4..761afc66396 100644 --- a/http/src/main/java/io/micronaut/http/bind/DefaultRequestBinderRegistry.java +++ b/http/src/main/java/io/micronaut/http/bind/DefaultRequestBinderRegistry.java @@ -19,7 +19,6 @@ 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; @@ -33,8 +32,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; @@ -71,7 +70,6 @@ public class DefaultRequestBinderRegistry implements RequestBinderRegistry { private static final long CACHE_MAX_SIZE = 30; - private final Map, RequestArgumentBinder> byAnnotation = new LinkedHashMap<>(); private final Map byTypeAndAnnotation = new LinkedHashMap<>(); private final Map byType = new LinkedHashMap<>(); @@ -81,6 +79,7 @@ public class DefaultRequestBinderRegistry implements RequestBinderRegistry { private final List> unmatchedBinders = new ArrayList<>(); private final DefaultUnmatchedRequestArgumentBinder defaultUnmatchedRequestArgumentBinder; + /** * @param conversionService The conversion service * @param binders The request argument binders @@ -267,9 +266,7 @@ private static ArgumentBinder.BindingResult> convertBod .filter(arg -> arg.getType() != Object.class) .filter(arg -> arg.getType() != Void.class); if (typeVariable.isPresent()) { - @SuppressWarnings("unchecked") - ArgumentConversionContext unwrappedConversionContext = ConversionContext.of((Argument) typeVariable.get()); - 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 @@ -286,14 +283,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/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); + } } 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..d5cd8f0e268 --- /dev/null +++ b/http/src/main/java/io/micronaut/http/body/InputStreamBodyReader.java @@ -0,0 +1,48 @@ +/* + * 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 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); + } +}