diff --git a/core/build.gradle b/core/build.gradle index 43dca01d8e8..d4b3a61f6d4 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -167,6 +167,9 @@ dependencies { optionalImplementation libs.brotli4j.osx.aarch64 optionalImplementation libs.brotli4j.windows + // Nullability Support + api libs.jspecify + // for testing the observation API with tracing testImplementation (libs.micrometer.tracing.integration.test) { exclude group: "org.mockito" diff --git a/core/src/main/java/com/linecorp/armeria/internal/server/annotation/AnnotatedValueResolver.java b/core/src/main/java/com/linecorp/armeria/internal/server/annotation/AnnotatedValueResolver.java index 65da47177d4..fd2cafe3e51 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/server/annotation/AnnotatedValueResolver.java +++ b/core/src/main/java/com/linecorp/armeria/internal/server/annotation/AnnotatedValueResolver.java @@ -30,6 +30,7 @@ import java.io.File; import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.AnnotatedType; import java.lang.reflect.Constructor; import java.lang.reflect.Executable; import java.lang.reflect.Field; @@ -1022,13 +1023,59 @@ private static Type parameterizedTypeOf(AnnotatedElement element) { element.getClass().getSimpleName()); } + /** + * Return if the given {@link AnnotatedElement} is annotated with {@code @Nullable} annotation. + * This method checks both declaration annotation and type-use annotation. + * + *

For example: + *

{@code
+     * @Nullable // declaration annotation
+     * public String declarationAnnotatedMethod() {
+     *     return null;
+     * }
+     *
+     * // type-use annotation
+     * public @Nullable String typeUseAnnotatedMethod() {
+     *     return null;
+     * }
+     * }
+ */ static boolean isAnnotatedNullable(AnnotatedElement annotatedElement) { + // 1) declaration annotation for (Annotation a : annotatedElement.getAnnotations()) { final String annotationTypeName = a.annotationType().getName(); if (annotationTypeName.endsWith(".Nullable")) { return true; } } + + // 2) type-use annotation + if (annotatedElement instanceof Field) { + final Field field = (Field) annotatedElement; + final AnnotatedType annotatedType = field.getAnnotatedType(); + return isAnnotatedNullableType(annotatedType); + } + if (annotatedElement instanceof Method) { + final Method method = (Method) annotatedElement; + final AnnotatedType annotatedType = method.getAnnotatedReturnType(); + return isAnnotatedNullableType(annotatedType); + } + if (annotatedElement instanceof Parameter) { + final Parameter parameter = (Parameter) annotatedElement; + final AnnotatedType annotatedType = parameter.getAnnotatedType(); + return isAnnotatedNullableType(annotatedType); + } + + return false; + } + + private static boolean isAnnotatedNullableType(AnnotatedType annotatedType) { + for (Annotation a : annotatedType.getAnnotations()) { + if (a.annotationType().getName().endsWith(".Nullable")) { + return true; + } + } + return false; } diff --git a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceNullableParamTest.java b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceNullableParamTest.java index 5d58ddd6d9d..a641707e416 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceNullableParamTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceNullableParamTest.java @@ -68,6 +68,11 @@ public String defaultValue(@Param @Default("unspecified") String value) { public String optional(@Param Optional value) { return value.orElse("unspecified"); } + + @Get("/type_use_nullable") + public String typeUseNullable(@Param @org.jspecify.annotations.Nullable String value) { + return nullable(value); + } }); sb.annotatedService("/headers", new Object() { @@ -98,12 +103,19 @@ public String defaultValue(@Header @Default("unspecified") String value) { public String optional(@Header Optional value) { return value.orElse("unspecified"); } + + @Get("/type_use_nullable") + public String typeUseNullable(@Header @org.jspecify.annotations.Nullable String value) { + return nullable(value); + } }); } }; @ParameterizedTest - @CsvSource({ "/nullable", "/jsr305_nullable", "/other_nullable", "/default", "/optional" }) + @CsvSource({ + "/nullable", "/jsr305_nullable", "/other_nullable", "/default", "/optional", "type_use_nullable" + }) void params(String path) { final BlockingWebClient client = BlockingWebClient.of(server.httpUri().resolve("/params")); assertThat(client.get(path + "?value=foo").contentUtf8()).isEqualTo("foo"); @@ -111,7 +123,9 @@ void params(String path) { } @ParameterizedTest - @CsvSource({ "/nullable", "/jsr305_nullable", "/other_nullable", "/default", "/optional" }) + @CsvSource({ + "/nullable", "/jsr305_nullable", "/other_nullable", "/default", "/optional", "/type_use_nullable" + }) void headers(String path) { final BlockingWebClient client = BlockingWebClient.of(server.httpUri().resolve("/headers")); assertThat(client.execute(RequestHeaders.of(HttpMethod.GET, path, "value", "foo")) diff --git a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceNullableResponseTest.java b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceNullableResponseTest.java new file mode 100644 index 00000000000..c99e44102f1 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceNullableResponseTest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation 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: + * + * 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 com.linecorp.armeria.internal.server.annotation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import com.linecorp.armeria.client.BlockingWebClient; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.annotation.Get; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +class AnnotatedServiceNullableResponseTest { + + @RegisterExtension + static final ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) throws Exception { + sb.annotatedService("/response", new Object() { + @SuppressWarnings("checkstyle:LegacyNullableAnnotation") + @Get("/jsr305_nullable") + @javax.annotation.Nullable + public String jsr305Nullable() { + return null; + } + + @Get("/type_use_nullable") + public @org.jspecify.annotations.Nullable String typeUseNullable() { + return null; + } + }); + } + }; + + @ParameterizedTest + @CsvSource({ + "/jsr305_nullable", "/type_use_nullable" + }) + void response(String path) { + final BlockingWebClient client = BlockingWebClient.of(server.httpUri().resolve("/response")); + assertThat(client.get(path).contentUtf8()).isEqualTo(""); + } +} diff --git a/dependencies.toml b/dependencies.toml index 037d8c56c44..e5a10eeedc2 100644 --- a/dependencies.toml +++ b/dependencies.toml @@ -74,6 +74,7 @@ joor = "0.9.15" # Don't upgrade json-unit to 3.0.0 that requires Java 17 json-unit = "2.38.0" jsoup = "1.21.1" +jspecify = "1.0.0" junit4 = "4.13.2" junit5 = "5.13.4" # Don't upgrade junit-pioneer to 2.x.x that requires Java 11 @@ -782,6 +783,10 @@ version.ref = "json-unit" module = "org.jsoup:jsoup" version.ref = "jsoup" +[libraries.jspecify] +module = "org.jspecify:jspecify" +version.ref = "jspecify" + [libraries.junit4] module = "junit:junit" version.ref = "junit4"