Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
* <p>For example:
* <pre>{@code
* @Nullable // declaration annotation
* public String declarationAnnotatedMethod() {
* return null;
* }
*
* // type-use annotation
* public @Nullable String typeUseAnnotatedMethod() {
* return null;
* }
* }</pre>
*/
static boolean isAnnotatedNullable(AnnotatedElement annotatedElement) {
// 1) declaration annotation
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add examples so that readers can distinguish them easily? e.g.

@Nullable // declaration annotation
public String declarationAnnotatedMethod() {
    return null;
}

// type-use annotation
public @Nullable String typeUseAnnotatedMethod() {
    return null;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I added comment
ca1e429 (#6457)

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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ public String defaultValue(@Param @Default("unspecified") String value) {
public String optional(@Param Optional<String> 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() {
Expand Down Expand Up @@ -98,20 +103,29 @@ public String defaultValue(@Header @Default("unspecified") String value) {
public String optional(@Header Optional<String> value) {
return value.orElse("unspecified");
}

@Get("/type_use_nullable")
public String typeUseNullable(@Header @org.jspecify.annotations.Nullable String value) {
Copy link
Copy Markdown
Contributor

@minwoox minwoox Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you also add a test with this?

public @Nullable String typeUseAnnotatedMethod() {
    return null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may use @RequestObject to test it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test for nullable response
8d850d9 (#6457)

Also I tried to test with @RequestObject like below, but looks like @Nullable doesn't affect 👀 (empty body is rejected even if there's @Nullable)

@Post("/foo")
@ProducesJson
public Map<String, Object> foo(@RequestObject Map<String, Object> map) {
    return map;
}

@Post("/bar")
@ProducesJson
public Map<String, Object> bar(@RequestObject @Nullable Map<String, Object> map) {
    if (map == null) {
        return Map.of("this is", "null");
    }
    return map;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

empty body is rejected even if there's @nullable

I think it shouldn't throw an exception if it's annotated with the @Nullable.
What do you think of it? @line/dx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree the behavior sounds reasonable - I'm not sure whether this is enforceable for all cases though. I have no objection if for Jackson's case where a literal null body is received, a null value is passed as the request object.

Having said this, I'm fine with going ahead with merging this PR and handling this separately as they seem to be separate issues

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with going ahead with merging this PR and handling this separately as they seem to be separate issues

I agree. We don't have to deal with this in this PR. Let me merge this. 😉

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");
assertThat(client.get(path).contentUtf8()).isEqualTo("unspecified");
}

@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"))
Expand Down
Original file line number Diff line number Diff line change
@@ -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("");
}
}
5 changes: 5 additions & 0 deletions dependencies.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
Loading