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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,22 @@ Set<ConstraintViolation<Pet>> validationResult = validator.validate(myPet);
assertEquals(1, validationResult.size());
```

## Requiring a property to be present

`@Present` checks that a `JsonNullable` was set: `JsonNullable.undefined()` and a `null` reference are invalid, an explicit `null` (`JsonNullable.of(null)`) and any value are valid.
It is the opposite of `@NotNull` on a `JsonNullable`, which accepts an undefined property and rejects an explicit `null`.
The `ValueExtractor` unwraps `JsonNullable` by default, so the constraint only sees the wrapper when unwrapping is skipped where it is used:
```java
import jakarta.validation.valueextraction.Unwrapping;
import org.openapitools.jackson.nullable.jakarta.Present;

@Present(payload = Unwrapping.Skip.class)
public JsonNullable<String> color = JsonNullable.undefined();
```
The annotation comes in two packages with the same name, `org.openapitools.jackson.nullable.jakarta` for `jakarta.validation` and `org.openapitools.jackson.nullable.javax` for `javax.validation`.
Without the payload, while the module's `ValueExtractor` is registered (the default on the class path), an undefined property and a `null` reference pass unchecked and a defined value, an explicit `null` included, makes Hibernate Validator throw an `UnexpectedTypeException` (HV000030); without the extractor a plain `@Present` simply checks presence. This was measured on Hibernate Validator 6.2.5, 7.0.5, 8.0.2, 9.0.1 and 9.1.4.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The 'Without the payload...' sentence is a long run-on that stacks the pass-through case, the throw case, and the no-extractor case in one clause chain, which makes the most important caveat in this section hard to follow. Split it into separate sentences: '...an undefined property and a null reference pass unchecked. A defined value (an explicit null included) makes Hibernate Validator throw an UnexpectedTypeException (HV000030). Without the extractor, a plain @Present simply checks presence.'

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At README.md, line 124:

<comment>The 'Without the payload...' sentence is a long run-on that stacks the pass-through case, the throw case, and the no-extractor case in one clause chain, which makes the most important caveat in this section hard to follow. Split it into separate sentences: '...an undefined property and a `null` reference pass unchecked. A defined value (an explicit `null` included) makes Hibernate Validator throw an `UnexpectedTypeException` (HV000030). Without the extractor, a plain `@Present` simply checks presence.'</comment>

<file context>
@@ -108,6 +108,22 @@ Set<ConstraintViolation<Pet>> validationResult = validator.validate(myPet);
+public JsonNullable<String> color = JsonNullable.undefined();
+```
+The annotation comes in two packages with the same name, `org.openapitools.jackson.nullable.jakarta` for `jakarta.validation` and `org.openapitools.jackson.nullable.javax` for `javax.validation`.
+Without the payload, while the module's `ValueExtractor` is registered (the default on the class path), an undefined property and a `null` reference pass unchecked and a defined value, an explicit `null` included, makes Hibernate Validator throw an `UnexpectedTypeException` (HV000030); without the extractor a plain `@Present` simply checks presence. This was measured on Hibernate Validator 6.2.5, 7.0.5, 8.0.2, 9.0.1 and 9.1.4.
+In a composed constraint that carries `@Present(payload = Unwrapping.Skip.class)` as a meta-annotation the payload is ignored and an undefined property passes; give the payload where the composed annotation is used.
+
</file context>
Suggested change
Without the payload, while the module's `ValueExtractor` is registered (the default on the class path), an undefined property and a `null` reference pass unchecked and a defined value, an explicit `null` included, makes Hibernate Validator throw an `UnexpectedTypeException` (HV000030); without the extractor a plain `@Present` simply checks presence. This was measured on Hibernate Validator 6.2.5, 7.0.5, 8.0.2, 9.0.1 and 9.1.4.
Without the payload, while the module's `ValueExtractor` is registered (the default on the class path), an undefined property and a `null` reference pass unchecked. A defined value (an explicit `null` included) makes Hibernate Validator throw an `UnexpectedTypeException` (HV000030). Without the extractor a plain `@Present` simply checks presence. This was measured on Hibernate Validator 6.2.5, 7.0.5, 8.0.2, 9.0.1 and 9.1.4.

In a composed constraint that carries `@Present(payload = Unwrapping.Skip.class)` as a meta-annotation the payload is ignored and an undefined property passes; give the payload where the composed annotation is used.

## Limitations

* Doesn't work with `@JsonUnwrapped`.
Expand Down
1 change: 1 addition & 0 deletions src/it/javax-classpath/invoker.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
invoker.goals=clean verify
90 changes: 90 additions & 0 deletions src/it/javax-classpath/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>org.openapitools.it</groupId>
<artifactId>test</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>

<properties>
<java.version>8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
<dependency>
<groupId>org.openapitools</groupId>
<artifactId>jackson-databind-nullable</artifactId>
<version>@project.version@</version>
</dependency>
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.2.5.Final</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.21.6</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
<configuration>
<release>${java.version}</release>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>prepare-package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<overWriteReleases>false</overWriteReleases>
<overWriteSnapshots>false</overWriteSnapshots>
<overWriteIfNewer>true</overWriteIfNewer>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.2</version>
<executions>
<execution>
<id>run</id>
<phase>verify</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<executable>java</executable>
<commandlineArgs>--class-path ${project.build.directory}/test-1.0-SNAPSHOT.jar${path.separator}${project.build.directory}/lib/* org.openapitools.it.Main</commandlineArgs>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This IT targets Java 8 (<java.version>8</java.version> with <release>8</release>), but java --class-path is only recognized by the JDK 9+ launcher (JEP 293 long-form options; a JDK 8 java exits with "Unrecognized option: --class-path"). The sibling jakarta-jar-classpath IT pairs the same --class-path invocation with java.version=9, which is consistent. Since the javax flavor exists specifically to cover the Java 8 runtime, run this under a JDK 8 java and the verify phase fails. Use -classpath (valid on every JDK) so the Java 8 scenario is actually runnable; the current CI matrix (17/21/25) masks this.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/it/javax-classpath/pom.xml, line 82:

<comment>This IT targets Java 8 (`<java.version>8</java.version>` with `<release>8</release>`), but `java --class-path` is only recognized by the JDK 9+ launcher (JEP 293 long-form options; a JDK 8 java exits with "Unrecognized option: --class-path"). The sibling `jakarta-jar-classpath` IT pairs the same `--class-path` invocation with `java.version=9`, which is consistent. Since the javax flavor exists specifically to cover the Java 8 runtime, run this under a JDK 8 `java` and the verify phase fails. Use `-classpath` (valid on every JDK) so the Java 8 scenario is actually runnable; the current CI matrix (17/21/25) masks this.</comment>

<file context>
@@ -0,0 +1,90 @@
+                        </goals>
+                        <configuration>
+                            <executable>java</executable>
+                            <commandlineArgs>--class-path ${project.build.directory}/test-1.0-SNAPSHOT.jar${path.separator}${project.build.directory}/lib/* org.openapitools.it.Main</commandlineArgs>
+                        </configuration>
+                    </execution>
</file context>
Suggested change
<commandlineArgs>--class-path ${project.build.directory}/test-1.0-SNAPSHOT.jar${path.separator}${project.build.directory}/lib/* org.openapitools.it.Main</commandlineArgs>
+ <commandlineArgs>-classpath ${project.build.directory}/test-1.0-SNAPSHOT.jar${path.separator}${project.build.directory}/lib/* org.openapitools.it.Main</commandlineArgs>

</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

</project>
205 changes: 205 additions & 0 deletions src/it/javax-classpath/src/main/java/org/openapitools/it/Main.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package org.openapitools.it;

import org.hibernate.validator.messageinterpolation.ParameterMessageInterpolator;
import org.openapitools.jackson.nullable.JsonNullable;
import org.openapitools.jackson.nullable.JsonNullableValueExtractor;
import org.openapitools.jackson.nullable.javax.Present;

import javax.validation.ConstraintViolation;
import javax.validation.UnexpectedTypeException;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import javax.validation.constraints.Size;
import javax.validation.groups.Default;
import javax.validation.valueextraction.Unwrapping;
import javax.validation.valueextraction.ValueExtractor;
import java.lang.reflect.Constructor;
import java.util.Set;
import java.util.concurrent.Callable;

public class Main {

private static int failures;

public static void main(String[] args) throws Exception {
tryLoadingJavaxValidation();

ValidatorFactory factory = Validation.byDefaultProvider()
.configure()
.messageInterpolator(new ParameterMessageInterpolator())
.buildValidatorFactory();
Validator validator = factory.getValidator();

// the existing javax value extractor unwraps JsonNullable
check("extractor: undefined is not validated", violations(validator, new Sized(JsonNullable.<String>undefined())) == 0);
check("extractor: null is not validated", violations(validator, new Sized(JsonNullable.<String>of(null))) == 0);
check("extractor: short value is valid", violations(validator, new Sized(JsonNullable.of("abc"))) == 0);
check("extractor: long value is unwrapped and rejected", violations(validator, new Sized(JsonNullable.of("too long"))) == 1);

// @Present on a field
check("field: undefined is invalid", violations(validator, new OnField(JsonNullable.<String>undefined())) == 1);
check("field: explicit null is valid", violations(validator, new OnField(JsonNullable.<String>of(null))) == 0);
check("field: value is valid", violations(validator, new OnField(JsonNullable.of("red"))) == 0);
check("field: null reference is invalid", violations(validator, new OnField(null)) == 1);
check("field: default message", "must be present".equals(
firstMessage(validator, new OnField(JsonNullable.<String>undefined()))));

// on a getter
check("getter: undefined is invalid", violations(validator, new OnGetter(JsonNullable.<String>undefined())) == 1);
check("getter: explicit null is valid", violations(validator, new OnGetter(JsonNullable.<String>of(null))) == 0);
check("getter: value is valid", violations(validator, new OnGetter(JsonNullable.of("red"))) == 0);
check("getter: null reference is invalid", violations(validator, new OnGetter(null)) == 1);

// on a constructor parameter
Constructor<OnConstructor> constructor = OnConstructor.class.getConstructor(JsonNullable.class);
check("constructor: undefined is invalid", validator.forExecutables().validateConstructorParameters(
constructor, new Object[]{JsonNullable.undefined()}).size() == 1);
check("constructor: explicit null is valid", validator.forExecutables().validateConstructorParameters(
constructor, new Object[]{JsonNullable.of(null)}).isEmpty());
check("constructor: value is valid", validator.forExecutables().validateConstructorParameters(
constructor, new Object[]{JsonNullable.of("red")}).isEmpty());

// groups, repetition, message
OnGroup onGroup = new OnGroup(JsonNullable.<String>undefined());
check("group: not applied in the default group", validator.validate(onGroup).isEmpty());
check("group: applied in its own group", validator.validate(onGroup, Strict.class).size() == 1);
OnRepeated onRepeated = new OnRepeated(JsonNullable.<String>undefined());
check("repeated: one violation per group", validator.validate(onRepeated).size() == 1
&& validator.validate(onRepeated, Default.class, Strict.class).size() == 2);
check("message: custom message", "color is required".equals(
firstMessage(validator, new OnMessage(JsonNullable.<String>undefined()))));

// without the payload the wrapper is never offered to the validator
check("no payload: undefined passes silently", violations(validator, new WithoutSkip(JsonNullable.<String>undefined())) == 0);
check("no payload: null reference passes silently", violations(validator, new WithoutSkip(null)) == 0);
check("no payload: explicit null is refused", refused(validator, new WithoutSkip(JsonNullable.<String>of(null))));
check("no payload: value is refused", refused(validator, new WithoutSkip(JsonNullable.of("red"))));

factory.close();
if (failures > 0) {
System.out.println(failures + " check(s) failed");
System.exit(1);
}
System.out.println("Success");
}

private static int violations(Validator validator, Object bean) {
Set<ConstraintViolation<Object>> violations = validator.validate(bean);
return violations.size();
}

private static String firstMessage(Validator validator, Object bean) {
Set<ConstraintViolation<Object>> violations = validator.validate(bean);
return violations.isEmpty() ? null : violations.iterator().next().getMessage();
}

private static boolean refused(final Validator validator, final Object bean) throws Exception {
Callable<Object> validation = new Callable<Object>() {
@Override
public Object call() {
return validator.validate(bean);
}
};
try {
validation.call();
return false;
} catch (UnexpectedTypeException e) {
return e.getMessage().contains("HV000030");
}
}

private static void check(String name, boolean ok) {
System.out.println((ok ? "ok " : "FAILED ") + name);
if (!ok) {
failures++;
}
}

private static void tryLoadingJavaxValidation() {
for (ValueExtractor<?> ve : java.util.ServiceLoader.load(ValueExtractor.class)) {
if (ve instanceof JsonNullableValueExtractor) {
System.out.println("Successfully loaded JsonNullableValueExtractor via ServiceLoader");
return;
}
}
System.out.println("Failed to load JsonNullableValueExtractor via ServiceLoader");
System.exit(1);
}

public interface Strict {
}

public static class Sized {
@Size(max = 5)
public JsonNullable<String> value;

public Sized(JsonNullable<String> value) {
this.value = value;
}
}

public static class OnField {
@Present(payload = Unwrapping.Skip.class)
public JsonNullable<String> color;

public OnField(JsonNullable<String> color) {
this.color = color;
}
}

public static class OnGetter {
private final JsonNullable<String> color;

public OnGetter(JsonNullable<String> color) {
this.color = color;
}

@Present(payload = Unwrapping.Skip.class)
public JsonNullable<String> getColor() {
return color;
}
}

public static class OnConstructor {
public OnConstructor(@Present(payload = Unwrapping.Skip.class) JsonNullable<String> color) {
}
}

public static class OnGroup {
@Present(groups = Strict.class, payload = Unwrapping.Skip.class)
public JsonNullable<String> color;

public OnGroup(JsonNullable<String> color) {
this.color = color;
}
}

public static class OnRepeated {
@Present(payload = Unwrapping.Skip.class)
@Present(groups = Strict.class, payload = Unwrapping.Skip.class)
public JsonNullable<String> color;

public OnRepeated(JsonNullable<String> color) {
this.color = color;
}
}

public static class OnMessage {
@Present(message = "color is required", payload = Unwrapping.Skip.class)
public JsonNullable<String> color;

public OnMessage(JsonNullable<String> color) {
this.color = color;
}
}

public static class WithoutSkip {
@Present
public JsonNullable<String> color;

public WithoutSkip(JsonNullable<String> color) {
this.color = color;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package org.openapitools.jackson.nullable.jakarta;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.Documented;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.CONSTRUCTOR;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.ElementType.TYPE_USE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

/**
* The annotated {@code JsonNullable} must be present, that is not undefined. An explicit {@code null} value is
* present and therefore valid; a {@code null} reference is not.
* <p>
* The value extractor of this module unwraps {@code JsonNullable} by default, so the constraint only sees the
* wrapper when unwrapping is skipped at the place of use:
* <pre>
* &#64;Present(payload = Unwrapping.Skip.class)
* private JsonNullable&lt;String&gt; color = JsonNullable.undefined();
* </pre>
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(Present.List.class)
@Documented
@Constraint(validatedBy = { PresentValidator.class })
public @interface Present {

String message() default "must be present";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The default message() is a plain literal, so the default violation message cannot be overridden globally through ValidationMessages.properties (the standard Bean Validation mechanism). Users must spell out message = "{...}" at every use site to change or localize it. Publish a message key instead, e.g. default "{org.openapitools.jackson.nullable.jakarta.Present.message}", with a matching META-INF/ValidationMessages.properties entry, following how jakarta.validation.constraints.* define their defaults. Applies to the javax variant too.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/main/java/org/openapitools/jackson/nullable/jakarta/Present.java, line 37:

<comment>The default `message()` is a plain literal, so the default violation message cannot be overridden globally through `ValidationMessages.properties` (the standard Bean Validation mechanism). Users must spell out `message = "{...}"` at every use site to change or localize it. Publish a message key instead, e.g. `default "{org.openapitools.jackson.nullable.jakarta.Present.message}"`, with a matching `META-INF/ValidationMessages.properties` entry, following how `jakarta.validation.constraints.*` define their defaults. Applies to the javax variant too.</comment>

<file context>
@@ -0,0 +1,53 @@
+@Constraint(validatedBy = { PresentValidator.class })
+public @interface Present {
+
+    String message() default "must be present";
+
+    Class<?>[] groups() default { };
</file context>


Class<?>[] groups() default { };

Class<? extends Payload>[] payload() default { };

/**
* Defines several {@link Present} annotations on the same element.
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@interface List {

Present[] value();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.openapitools.jackson.nullable.jakarta;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.openapitools.jackson.nullable.JsonNullable;

/**
* Validator for {@link Present} (modern jakarta-validation version)
*/
public class PresentValidator implements ConstraintValidator<Present, JsonNullable<?>> {
@Override
public boolean isValid(JsonNullable<?> value, ConstraintValidatorContext context) {
return value != null && value.isPresent();
}
}
Loading
Loading