Skip to content

Commit 0280cee

Browse files
committed
open-api: add jakarta.validation.constraints support
- fix #3760
1 parent 449eee6 commit 0280cee

File tree

12 files changed

+787
-29
lines changed

12 files changed

+787
-29
lines changed

modules/jooby-openapi/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424
<artifactId>jakarta.ws.rs-api</artifactId>
2525
</dependency>
2626

27+
<dependency>
28+
<groupId>jakarta.validation</groupId>
29+
<artifactId>jakarta.validation-api</artifactId>
30+
</dependency>
31+
2732
<!-- ASM -->
2833
<dependency>
2934
<groupId>org.ow2.asm</groupId>

modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -118,14 +118,7 @@ public Optional<String> getDefaultValue(List<AnnotationNode> annotations) {
118118
if (a.values != null) {
119119
var matches = names.stream().anyMatch(it -> Type.getDescriptor(it).equals(a.desc));
120120
if (matches) {
121-
for (int i = 0; i < a.values.size(); i++) {
122-
if (a.values.get(i).equals("value")) {
123-
Object value = a.values.get(i + 1);
124-
if (value != null && !value.toString().trim().isEmpty()) {
125-
return Optional.of(value.toString().trim());
126-
}
127-
}
128-
}
121+
return AnnotationUtils.findAnnotationValue(a, "value").map(Objects::toString);
129122
}
130123
}
131124
}
@@ -145,14 +138,8 @@ public Optional<String> getHttpName(List<AnnotationNode> annotations) {
145138
.findFirst()
146139
.orElse(null);
147140
if (mapping != null) {
148-
for (int i = 0; i < a.values.size(); i++) {
149-
if (a.values.get(i).equals(mapping.getValue())) {
150-
Object value = a.values.get(i + 1);
151-
if (value != null && !value.toString().trim().isEmpty()) {
152-
return Optional.of(value.toString().trim());
153-
}
154-
}
155-
}
141+
return AnnotationUtils.findAnnotationValue(a, mapping.getValue())
142+
.map(Objects::toString);
156143
}
157144
}
158145
}
@@ -489,6 +476,7 @@ private static List<ParameterExt> routerArguments(
489476
ParameterExt argument = new ParameterExt();
490477
argument.setJavaType(javaType);
491478
argument.setName(paramType.getHttpName(annotations).orElse(parameter.name));
479+
argument.setAnnotations(annotations);
492480
paramType
493481
.getDefaultValue(annotations)
494482
.flatMap(value -> convertValue(ctx, javaType, value))
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal.openapi;
7+
8+
import java.util.Optional;
9+
10+
import org.objectweb.asm.tree.AnnotationNode;
11+
12+
public final class AnnotationUtils {
13+
14+
public static Optional<Object> findAnnotationValue(AnnotationNode node, String name) {
15+
for (int i = 0; i < node.values.size(); i++) {
16+
if (node.values.get(i).equals(name)) {
17+
Object value = node.values.get(i + 1);
18+
if (value != null && !value.toString().trim().isEmpty()) {
19+
return value instanceof String
20+
? Optional.of(value.toString().trim())
21+
: Optional.of(value);
22+
}
23+
}
24+
}
25+
return Optional.empty();
26+
}
27+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal.openapi;
7+
8+
import java.math.BigDecimal;
9+
import java.util.LinkedHashSet;
10+
import java.util.List;
11+
import java.util.Optional;
12+
import java.util.stream.Stream;
13+
14+
import org.objectweb.asm.Opcodes;
15+
import org.objectweb.asm.tree.AnnotationNode;
16+
import org.objectweb.asm.tree.ClassNode;
17+
18+
import com.google.common.base.CaseFormat;
19+
import io.swagger.v3.oas.models.media.Schema;
20+
21+
public class JakartaConstraints {
22+
23+
public static void apply(ClassNode node, Schema<?> schema) {
24+
if (schema.getProperties() != null) {
25+
schema
26+
.getProperties()
27+
.forEach(
28+
(property, value) -> {
29+
var annotations = getAnnotations(node, property);
30+
apply(value, annotations);
31+
});
32+
}
33+
}
34+
35+
private static List<AnnotationNode> getAnnotations(ClassNode node, String property) {
36+
var methods =
37+
Optional.ofNullable(node.methods).orElse(List.of()).stream()
38+
.filter(
39+
method ->
40+
method.name.equals(property)
41+
|| method.name.equals(
42+
"get" + CaseFormat.LOWER_CAMEL.to(CaseFormat.UPPER_CAMEL, property)))
43+
.filter(method -> method.parameters == null || !method.parameters.isEmpty())
44+
.filter(method -> (method.access & Opcodes.ACC_PUBLIC) != 0)
45+
.flatMap(
46+
method ->
47+
method.visibleAnnotations == null
48+
? Stream.empty()
49+
: method.visibleAnnotations.stream())
50+
.toList();
51+
var fields =
52+
Optional.ofNullable(node.fields).orElse(List.of()).stream()
53+
.filter(field -> field.name.equals(property))
54+
.filter(field -> (field.access & Opcodes.ACC_STATIC) == 0)
55+
.flatMap(
56+
method ->
57+
method.visibleAnnotations == null
58+
? Stream.empty()
59+
: method.visibleAnnotations.stream())
60+
.toList();
61+
return Stream.concat(methods.stream(), fields.stream()).toList();
62+
}
63+
64+
public static void apply(Schema<?> schema, List<AnnotationNode> annotations) {
65+
// AssertFalse
66+
// AssertTrue
67+
for (var annotation : annotations) {
68+
switch (annotation.desc) {
69+
case "Ljakarta/validation/constraints/Digits;":
70+
{
71+
var integer =
72+
AnnotationUtils.findAnnotationValue(annotation, "integer")
73+
.map(Object::toString)
74+
.map(Integer::valueOf)
75+
.orElse(null);
76+
var fraction =
77+
AnnotationUtils.findAnnotationValue(annotation, "fraction")
78+
.map(Object::toString)
79+
.map(Integer::valueOf)
80+
.orElse(null);
81+
if (integer != null && fraction != null) {
82+
var multipleOf = BigDecimal.ONE.divide(BigDecimal.TEN.pow(fraction));
83+
var maximum = BigDecimal.TEN.pow(integer).subtract(multipleOf);
84+
schema.setMaximum(maximum);
85+
schema.setMultipleOf(multipleOf);
86+
schema.setType("number");
87+
}
88+
}
89+
break;
90+
case "Ljakarta/validation/constraints/Email;":
91+
{
92+
schema.setFormat("email");
93+
}
94+
break;
95+
case "Ljakarta/validation/constraints/DecimalMin;":
96+
{
97+
AnnotationUtils.findAnnotationValue(annotation, "value")
98+
.map(Object::toString)
99+
.ifPresent(
100+
value -> {
101+
schema.setMinimum(new BigDecimal(value));
102+
var inclusive =
103+
AnnotationUtils.findAnnotationValue(annotation, "inclusive")
104+
.map(Object::toString)
105+
.map(Boolean::valueOf)
106+
.orElse(Boolean.TRUE);
107+
if (!inclusive) {
108+
schema.setExclusiveMinimum(true);
109+
}
110+
});
111+
}
112+
break;
113+
case "Ljakarta/validation/constraints/DecimalMax;":
114+
{
115+
AnnotationUtils.findAnnotationValue(annotation, "value")
116+
.map(Object::toString)
117+
.ifPresent(
118+
value -> {
119+
schema.setMaximum(new BigDecimal(value));
120+
var inclusive =
121+
AnnotationUtils.findAnnotationValue(annotation, "inclusive")
122+
.map(Object::toString)
123+
.map(Boolean::valueOf)
124+
.orElse(Boolean.TRUE);
125+
if (!inclusive) {
126+
schema.setExclusiveMinimum(true);
127+
}
128+
});
129+
}
130+
break;
131+
// Ignored
132+
// case "Ljakarta/validation/constraints/Future;":{}break;
133+
// case "Ljakarta/validation/constraints/FutureOrPresent;":{}break;
134+
case "Ljakarta/validation/constraints/Max;":
135+
{
136+
AnnotationUtils.findAnnotationValue(annotation, "value")
137+
.map(Long.class::cast)
138+
.ifPresent(
139+
value -> {
140+
schema.setMaximum(new BigDecimal(value.toString()));
141+
});
142+
}
143+
break;
144+
case "Ljakarta/validation/constraints/Min;":
145+
{
146+
AnnotationUtils.findAnnotationValue(annotation, "value")
147+
.map(Object::toString)
148+
.ifPresent(
149+
value -> {
150+
schema.setMinimum(new BigDecimal(value));
151+
});
152+
}
153+
break;
154+
case "Ljakarta/validation/constraints/Negative;":
155+
{
156+
schema.setMaximum(BigDecimal.ZERO);
157+
schema.setExclusiveMaximum(true);
158+
}
159+
break;
160+
case "Ljakarta/validation/constraints/NegativeOrZero;":
161+
{
162+
schema.setMaximum(BigDecimal.ZERO);
163+
}
164+
break;
165+
case "Ljakarta/validation/constraints/NotBlank;":
166+
{
167+
schema.setPattern(".*\\S.*");
168+
schema.setMinLength(1);
169+
}
170+
break;
171+
case "Ljakarta/validation/constraints/NotEmpty;":
172+
{
173+
schema.minLength(1);
174+
}
175+
break;
176+
case "Ljakarta/validation/constraints/NotNull;":
177+
{
178+
schema.setNullable(false);
179+
}
180+
break;
181+
case "Ljakarta/validation/constraints/Null;":
182+
{
183+
schema.setNullable(true);
184+
var types = new LinkedHashSet<String>();
185+
Optional.ofNullable(schema.getType()).ifPresent(types::add);
186+
Optional.ofNullable(schema.getTypes()).ifPresent(types::addAll);
187+
types.add("null");
188+
schema.setTypes(types);
189+
}
190+
break;
191+
// Ignored
192+
// case "Ljakarta/validation/constraints/Past;":{}break;
193+
// case "Ljakarta/validation/constraints/PastOrPresent;":{}break;
194+
case "Ljakarta/validation/constraints/Pattern;":
195+
{
196+
AnnotationUtils.findAnnotationValue(annotation, "regexp")
197+
.map(String.class::cast)
198+
.ifPresent(schema::setPattern);
199+
}
200+
break;
201+
case "Ljakarta/validation/constraints/Positive;":
202+
{
203+
schema.setMinimum(BigDecimal.ONE);
204+
}
205+
break;
206+
case "Ljakarta/validation/constraints/PositiveOrZero;":
207+
{
208+
schema.setMinimum(BigDecimal.ZERO);
209+
}
210+
break;
211+
case "Ljakarta/validation/constraints/Size;":
212+
{
213+
AnnotationUtils.findAnnotationValue(annotation, "min")
214+
.map(Object::toString)
215+
.ifPresent(
216+
value -> {
217+
schema.setMinimum(new BigDecimal(value));
218+
});
219+
AnnotationUtils.findAnnotationValue(annotation, "max")
220+
.map(Object::toString)
221+
.ifPresent(
222+
value -> {
223+
schema.setMaximum(new BigDecimal(value));
224+
});
225+
}
226+
break;
227+
}
228+
}
229+
}
230+
}

modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
*/
66
package io.jooby.internal.openapi;
77

8+
import java.util.List;
89
import java.util.Objects;
910

11+
import org.objectweb.asm.tree.AnnotationNode;
12+
1013
import com.fasterxml.jackson.annotation.JsonIgnore;
1114
import edu.umd.cs.findbugs.annotations.NonNull;
1215
import edu.umd.cs.findbugs.annotations.Nullable;
@@ -21,6 +24,8 @@ public class ParameterExt extends Parameter {
2124

2225
@JsonIgnore private boolean single = true;
2326

27+
@JsonIgnore private List<AnnotationNode> annotations = List.of();
28+
2429
public void setJavaType(String javaType) {
2530
this.javaType = javaType;
2631
}
@@ -69,8 +74,20 @@ public static Parameter header(@NonNull String name, @Nullable String value) {
6974
return basic(name, "header", value);
7075
}
7176

72-
public static Parameter cookie(@NonNull String name, @Nullable String value) {
73-
return basic(name, "cookie", value);
77+
@JsonIgnore
78+
public boolean isPassword() {
79+
return getSchema() instanceof StringSchema
80+
&& ("password".equalsIgnoreCase(getName())
81+
|| "pass".equalsIgnoreCase(getName())
82+
|| "secret".equalsIgnoreCase(getName()));
83+
}
84+
85+
public List<AnnotationNode> getAnnotations() {
86+
return annotations;
87+
}
88+
89+
public void setAnnotations(List<AnnotationNode> annotations) {
90+
this.annotations = annotations;
7491
}
7592

7693
public static Parameter basic(@NonNull String name, @NonNull String in, @Nullable String value) {
@@ -82,4 +99,8 @@ public static Parameter basic(@NonNull String name, @NonNull String in, @Nullabl
8299
param.setJavaType(String.class.getName());
83100
return param;
84101
}
102+
103+
public void processConstraints() {
104+
JakartaConstraints.apply(getSchema(), annotations);
105+
}
85106
}

0 commit comments

Comments
 (0)