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"