Skip to content

Commit 0a5e673

Browse files
committed
mvc: Add BindParam annotation for custom parsing/mapping fix #3472
In addition to exising *Param annotation, Jooby will support a new annotation BindParam that allow you to convert current HTTP context to a desire type. Case 1: instance method on controller: @get("/new-bind") public Response doSomething(@BindParam MyBean q) { return ...; } MyBean bind(Context ctx) { // build MyBean from ctx as you want } Case 2: static method on Mapping class @get("/new-bind") public Response doSomething(@BindParam(MyConverter.class) MyBean q) { return ...; } class MyConverter { public static MyBean convert(Context ctx) { // build MyBean from ctx as you want } } In both cases mapping is function must take io.jooby.Context as argument and returns the required type. Function/method name is optional.
1 parent 1b815f2 commit 0a5e673

File tree

7 files changed

+235
-14
lines changed

7 files changed

+235
-14
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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.annotation;
7+
8+
import java.lang.annotation.ElementType;
9+
import java.lang.annotation.Retention;
10+
import java.lang.annotation.RetentionPolicy;
11+
import java.lang.annotation.Target;
12+
13+
/**
14+
* Custom mapping of HTTP request to parameter.
15+
*
16+
* @author edgar
17+
* @since 3.2.5
18+
*/
19+
@Retention(RetentionPolicy.RUNTIME)
20+
@Target(ElementType.PARAMETER)
21+
public @interface BindParam {
22+
/**
23+
* Class containing the mapping function.
24+
*
25+
* @return Default to controller class.
26+
*/
27+
Class<?> value() default void.class;
28+
29+
/**
30+
* Name of the function doing the mapping. Function must accept a single argument of type {@link
31+
* io.jooby.Context} and returns type must be the parameter type. Name is optional and only
32+
* required in case of conflict.
33+
*
34+
* @return Name of the function doing the mapping.
35+
*/
36+
String fn() default "";
37+
}

modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ public class MvcParameter {
1919
name -> name.toLowerCase().endsWith(".nullable");
2020
private static final Predicate<String> NON_NULL =
2121
name -> name.toLowerCase().endsWith(".nonnull") || name.toLowerCase().endsWith(".notnull");
22+
private final MvcRoute route;
2223
private final VariableElement parameter;
2324
private final Map<String, AnnotationMirror> annotations;
2425
private final TypeDefinition type;
2526

26-
public MvcParameter(MvcContext context, VariableElement parameter) {
27+
public MvcParameter(MvcContext context, MvcRoute route, VariableElement parameter) {
28+
this.route = route;
2729
this.parameter = parameter;
2830
this.annotations = annotationMap(parameter);
2931
this.type =
@@ -95,12 +97,13 @@ yield hasAnnotation(NULLABLE)
9597
if (strategy.isEmpty()) {
9698
// must be body
9799
yield ParameterGenerator.BodyParam.toSourceCode(
98-
kt, null, type, parameterName, isNullable(kt));
100+
kt, route, null, type, parameterName, isNullable(kt));
99101
} else {
100102
yield strategy
101103
.get()
102104
.getKey()
103-
.toSourceCode(kt, strategy.get().getValue(), type, parameterName, isNullable(kt));
105+
.toSourceCode(
106+
kt, route, strategy.get().getValue(), type, parameterName, isNullable(kt));
104107
}
105108
}
106109
};

modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public MvcRoute(MvcContext context, MvcRouter router, ExecutableElement method)
3434
this.router = router;
3535
this.method = method;
3636
this.parameters =
37-
method.getParameters().stream().map(it -> new MvcParameter(context, it)).toList();
37+
method.getParameters().stream().map(it -> new MvcParameter(context, this, it)).toList();
3838
this.suspendFun =
3939
!parameters.isEmpty()
4040
&& parameters.get(parameters.size() - 1).getType().is("kotlin.coroutines.Continuation");
@@ -48,14 +48,18 @@ public MvcRoute(MvcContext context, MvcRouter router, MvcRoute route) {
4848
this.router = router;
4949
this.method = route.method;
5050
this.parameters =
51-
method.getParameters().stream().map(it -> new MvcParameter(context, it)).toList();
51+
method.getParameters().stream().map(it -> new MvcParameter(context, this, it)).toList();
5252
this.returnType =
5353
new TypeDefinition(
5454
context.getProcessingEnvironment().getTypeUtils(), method.getReturnType());
5555
this.suspendFun = route.suspendFun;
5656
route.annotationMap.keySet().forEach(this::addHttpMethod);
5757
}
5858

59+
public MvcContext getContext() {
60+
return context;
61+
}
62+
5963
public TypeDefinition getReturnType() {
6064
var processingEnv = context.getProcessingEnvironment();
6165
var types = processingEnv.getTypeUtils();
@@ -244,30 +248,29 @@ public List<String> generateHandlerCall(boolean kt) {
244248
semicolon(false)));
245249
}
246250
}
251+
controllerVar(kt, buffer);
247252
buffer.add(
248253
statement(
249-
indent(2),
250-
"this.factory.apply(ctx).",
251-
this.method.getSimpleName(),
252-
paramList.toString(),
253-
semicolon(kt)));
254+
indent(2), "c.", this.method.getSimpleName(), paramList.toString(), semicolon(kt)));
254255
buffer.add(statement(indent(2), "return ctx.getResponseCode()", semicolon(kt)));
255256
} else if (returnType.is("io.jooby.StatusCode")) {
257+
controllerVar(kt, buffer);
256258
buffer.add(
257259
statement(
258260
indent(2),
259-
isSuspendFun() ? "val" : "var",
260-
" statusCode = this.factory.apply(ctx).",
261+
kt ? "val" : "var",
262+
" statusCode = c.",
261263
this.method.getSimpleName(),
262264
paramList.toString(),
263265
semicolon(kt)));
264266
buffer.add(statement(indent(2), "ctx.setResponseCode(statusCode)", semicolon(kt)));
265267
buffer.add(statement(indent(2), "return statusCode", semicolon(kt)));
266268
} else {
269+
controllerVar(kt, buffer);
267270
buffer.add(
268271
statement(
269272
indent(2),
270-
"return this.factory.apply(ctx).",
273+
"return c.",
271274
this.method.getSimpleName(),
272275
paramList.toString(),
273276
semicolon(kt)));
@@ -276,6 +279,11 @@ public List<String> generateHandlerCall(boolean kt) {
276279
return buffer;
277280
}
278281

282+
private void controllerVar(boolean kt, List<String> buffer) {
283+
buffer.add(
284+
statement(indent(2), kt ? "val" : "var", " c = this.factory.apply(ctx)", semicolon(kt)));
285+
}
286+
279287
public String getGeneratedName() {
280288
return generatedName;
281289
}

modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParameterGenerator.java

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@
1818
import java.util.stream.Stream;
1919

2020
import javax.lang.model.element.AnnotationMirror;
21+
import javax.lang.model.element.ExecutableElement;
22+
import javax.lang.model.element.Modifier;
2123

2224
public enum ParameterGenerator {
2325
ContextParam("getAttribute", "io.jooby.annotation.ContextParam", "jakarta.ws.rs.core.Context") {
2426
@Override
2527
public String toSourceCode(
2628
boolean kt,
29+
MvcRoute route,
2730
AnnotationMirror annotation,
2831
TypeDefinition type,
2932
String name,
@@ -71,6 +74,7 @@ public String parameterName(AnnotationMirror annotation, String defaultParameter
7174
@Override
7275
public String toSourceCode(
7376
boolean kt,
77+
MvcRoute route,
7478
AnnotationMirror annotation,
7579
TypeDefinition type,
7680
String name,
@@ -97,6 +101,84 @@ public String toSourceCode(
97101
}
98102
};
99103
}
104+
},
105+
Bind("", "io.jooby.annotation.BindParam") {
106+
@Override
107+
public String parameterName(AnnotationMirror annotation, String defaultParameterName) {
108+
return defaultParameterName;
109+
}
110+
111+
@Override
112+
public String toSourceCode(
113+
boolean kt,
114+
MvcRoute route,
115+
AnnotationMirror annotation,
116+
TypeDefinition type,
117+
String name,
118+
boolean nullable) {
119+
var typeNames = findAnnotationValue(annotation, AnnotationSupport.VALUE);
120+
var typeName = typeNames.isEmpty() ? null : typeNames.get(0);
121+
var router = route.getRouter();
122+
var targetType = router.getTargetType();
123+
var converter =
124+
typeName == null
125+
? targetType
126+
: route
127+
.getContext()
128+
.getProcessingEnvironment()
129+
.getElementUtils()
130+
.getTypeElement(typeName);
131+
var fns = findAnnotationValue(annotation, "fn"::equals);
132+
var fn = fns.isEmpty() ? null : fns.get(0);
133+
Predicate<ExecutableElement> contextAsParameter =
134+
it ->
135+
it.getParameters().size() == 1
136+
&& it.getParameters().get(0).asType().toString().equals("io.jooby.Context");
137+
Predicate<ExecutableElement> matchesReturnType =
138+
it ->
139+
new TypeDefinition(
140+
route.getContext().getProcessingEnvironment().getTypeUtils(),
141+
it.getReturnType())
142+
.getRawType()
143+
.equals(type.getRawType());
144+
var filter = contextAsParameter.and(matchesReturnType);
145+
String methodErrorName;
146+
if (fn != null) {
147+
Predicate<ExecutableElement> matchesName = it -> it.getSimpleName().toString().equals(fn);
148+
filter = filter.and(matchesName);
149+
methodErrorName = fn + "(io.jooby.Context): " + type;
150+
} else {
151+
methodErrorName = "(io.jooby.Context): " + type;
152+
}
153+
// find function by type
154+
ExecutableElement mapping =
155+
converter.getEnclosedElements().stream()
156+
.filter(ExecutableElement.class::isInstance)
157+
.map(ExecutableElement.class::cast)
158+
// filter by Context
159+
.filter(filter)
160+
.findFirst()
161+
.orElseThrow(
162+
() ->
163+
new IllegalArgumentException(
164+
"Method not found: " + converter + "." + methodErrorName));
165+
if (!mapping.getModifiers().contains(Modifier.PUBLIC)) {
166+
throw new IllegalArgumentException("Method is not public: " + converter + "." + mapping);
167+
}
168+
if (mapping.getModifiers().contains(Modifier.STATIC)) {
169+
return CodeBlock.of(
170+
converter.equals(targetType)
171+
? mapping.getSimpleName()
172+
: converter.getQualifiedName() + "." + mapping.getSimpleName(),
173+
"(ctx)");
174+
} else {
175+
if (converter.equals(targetType)) {
176+
return CodeBlock.of("c." + mapping.getSimpleName(), "(ctx)");
177+
} else {
178+
throw new IllegalArgumentException("Not a static method: " + converter + "." + mapping);
179+
}
180+
}
181+
}
100182
};
101183

102184
public String parameterName(AnnotationMirror annotation, String defaultParameterName) {
@@ -110,7 +192,12 @@ protected Predicate<String> namePredicate() {
110192
}
111193

112194
public String toSourceCode(
113-
boolean kt, AnnotationMirror annotation, TypeDefinition type, String name, boolean nullable) {
195+
boolean kt,
196+
MvcRoute route,
197+
AnnotationMirror annotation,
198+
TypeDefinition type,
199+
String name,
200+
boolean nullable) {
114201
var paramSource = source(annotation);
115202
var builtin = builtinType(kt, annotation, type, name, nullable);
116203
if (builtin == null) {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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 tests.i3472;
7+
8+
import io.jooby.Context;
9+
10+
public record BindBean(String value) {
11+
12+
public static BindBean of(Context ctx) {
13+
return new BindBean("static:" + ctx.query("value").value());
14+
}
15+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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 tests.i3472;
7+
8+
import io.jooby.Context;
9+
import io.jooby.annotation.BindParam;
10+
import io.jooby.annotation.GET;
11+
12+
public class C3472 {
13+
14+
@GET("/3472")
15+
public BindBean bind(@BindParam BindBean bean) {
16+
return bean;
17+
}
18+
19+
@GET("/3472/named")
20+
public BindBean bindName(@BindParam(fn = "bindWithName") BindBean bean) {
21+
return bean;
22+
}
23+
24+
@GET("/3472/static")
25+
public BindBean bindStatic(@BindParam(BindBean.class) BindBean bean) {
26+
return bean;
27+
}
28+
29+
public BindBean convert(Context ctx) {
30+
return new BindBean(ctx.query("value").value());
31+
}
32+
33+
public BindBean bindWithName(Context ctx) {
34+
return new BindBean("fn:" + ctx.query("value").value());
35+
}
36+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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 tests.i3472;
7+
8+
import static org.junit.jupiter.api.Assertions.*;
9+
10+
import org.junit.jupiter.api.Test;
11+
12+
import io.jooby.apt.ProcessorRunner;
13+
import io.jooby.test.MockContext;
14+
import io.jooby.test.MockRouter;
15+
16+
public class Issue3472 {
17+
18+
@Test
19+
public void shouldBindWithCustomCode() throws Exception {
20+
new ProcessorRunner(new C3472())
21+
.withRouter(
22+
app -> {
23+
var value = "xxx";
24+
MockRouter router = new MockRouter(app);
25+
MockContext ctx = new MockContext().setQueryString("?value=" + value);
26+
27+
assertEquals(new BindBean(value), router.get("/3472", ctx).value());
28+
29+
assertEquals(new BindBean("fn:" + value), router.get("/3472/named", ctx).value());
30+
31+
assertEquals(
32+
new BindBean("static:" + value), router.get("/3472/static", ctx).value());
33+
});
34+
}
35+
}

0 commit comments

Comments
 (0)