From 469a613fbc94555209cfb560328d1ecba82cd08f Mon Sep 17 00:00:00 2001 From: Seonghyeon Cho Date: Sat, 25 Oct 2025 09:44:31 +0900 Subject: [PATCH 1/7] Add jspecify to version catalog Signed-off-by: Seonghyeon Cho --- dependencies.toml | 5 +++++ 1 file changed, 5 insertions(+) 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" From 8e9285df56671bc60a6753362f5f272cc540ef2c Mon Sep 17 00:00:00 2001 From: Seonghyeon Cho Date: Sat, 25 Oct 2025 10:22:02 +0900 Subject: [PATCH 2/7] Add jspecify dependency to core Signed-off-by: Seonghyeon Cho --- core/build.gradle | 3 +++ 1 file changed, 3 insertions(+) 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" From 5515e0fbba63406c6968e3d6449f15b210e031b0 Mon Sep 17 00:00:00 2001 From: Seonghyeon Cho Date: Sun, 26 Oct 2025 13:43:19 +0900 Subject: [PATCH 3/7] Support type-use `@Nullable` annotation in annotated service Signed-off-by: Seonghyeon Cho --- .../annotation/AnnotatedValueResolver.java | 30 +++++++++++++++++++ .../AnnotatedServiceNullableParamTest.java | 14 +++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) 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..99cdf9fcc86 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; @@ -1023,12 +1024,41 @@ private static Type parameterizedTypeOf(AnnotatedElement element) { } 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..eaf110a4095 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,17 @@ 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 +121,7 @@ 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")) From eed6b4f79edf1aef70ffdd0019194caed9578fb1 Mon Sep 17 00:00:00 2001 From: Seonghyeon Cho Date: Sun, 26 Oct 2025 14:10:20 +0900 Subject: [PATCH 4/7] Checkstyle - Line length Signed-off-by: Seonghyeon Cho --- .../annotation/AnnotatedServiceNullableParamTest.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 eaf110a4095..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 @@ -113,7 +113,9 @@ public String typeUseNullable(@Header @org.jspecify.annotations.Nullable String }; @ParameterizedTest - @CsvSource({ "/nullable", "/jsr305_nullable", "/other_nullable", "/default", "/optional", "type_use_nullable" }) + @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"); @@ -121,7 +123,9 @@ void params(String path) { } @ParameterizedTest - @CsvSource({ "/nullable", "/jsr305_nullable", "/other_nullable", "/default", "/optional", "/type_use_nullable" }) + @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")) From ca1e429a8a566d9ba8c174f130b55c17807ae254 Mon Sep 17 00:00:00 2001 From: Seonghyeon Cho Date: Fri, 31 Oct 2025 23:12:48 +0900 Subject: [PATCH 5/7] Add comment on `isAnnotatedNullable` method with example Signed-off-by: Seonghyeon Cho --- .../annotation/AnnotatedValueResolver.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 99cdf9fcc86..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 @@ -1023,6 +1023,23 @@ 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()) { From 8d850d9e7b5a00190af30634a0fffd4a3c02625f Mon Sep 17 00:00:00 2001 From: Seonghyeon Cho Date: Sat, 1 Nov 2025 00:06:32 +0900 Subject: [PATCH 6/7] Add test for nullable response service Signed-off-by: Seonghyeon Cho --- .../AnnotatedServiceNullableResponseTest.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceNullableResponseTest.java 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..ff315020c5f --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceNullableResponseTest.java @@ -0,0 +1,44 @@ +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(""); + } +} From c016834aae5d0a13cfb0838fda5821c204013f18 Mon Sep 17 00:00:00 2001 From: Seonghyeon Cho Date: Sat, 1 Nov 2025 01:52:22 +0900 Subject: [PATCH 7/7] Add license header Signed-off-by: Seonghyeon Cho --- .../AnnotatedServiceNullableResponseTest.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 index ff315020c5f..c99e44102f1 100644 --- 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 @@ -1,3 +1,18 @@ +/* + * 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;