Skip to content

Commit a53cb11

Browse files
committed
mvc: Add BindParam annotation for custom parsing/mapping
- Add extra lookup for mapping function - Add some docs - ref #3472
1 parent ff5f6f4 commit a53cb11

File tree

14 files changed

+293
-61
lines changed

14 files changed

+293
-61
lines changed

docs/asciidoc/mvc-api.adoc

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,75 @@ class MyController {
383383
}
384384
----
385385

386+
==== Bind
387+
388+
You can use the javadoc:annotation.BindParam[] annotation which allow custom mapping from HTTP request.
389+
390+
.Use the annotation
391+
[source, java, role = "primary"]
392+
----
393+
public class Controller {
394+
395+
@GET("/{foo}")
396+
public String bind(@BindParam MyBean bean) {
397+
return "with custom mapping: " + bean;
398+
}
399+
}
400+
----
401+
402+
.Kotlin
403+
[source, kotlin, role = "secondary"]
404+
----
405+
class Controller {
406+
407+
@GET("/{foo}")
408+
fun bind(@BindParam bean: MyBean) = "with custom mapping: $bean"
409+
}
410+
----
411+
412+
.Write the mapping function
413+
[source, java, role = "primary"]
414+
----
415+
public record MyBean(String value) {
416+
417+
public static MyBean of(Context ctx) {
418+
// build MyBean from HTTP request
419+
}
420+
}
421+
----
422+
423+
.Kotlin
424+
[source, kotlin, role = "secondary"]
425+
----
426+
class MyBean constructor(value: String) {
427+
428+
companion object {
429+
@JvmStatic
430+
fun of(ctx: Context) : Person {
431+
// build MyBean from HTTP request
432+
}
433+
}
434+
}
435+
----
436+
437+
It works as:
438+
439+
- The javadoc:annotation.BindParam[] allow you to convert HTTP request to an Java Object in the way you wish
440+
- The annotation looks for public method/function that takes a javadoc:Context[] as parameter and returns the same type required as parameter.
441+
- It looks in the parameter type or fallback into the controller class
442+
443+
Alternative you can specify the factory class:
444+
445+
----
446+
@BindParam(MyFactoryClass.class)
447+
----
448+
449+
And/or function name:
450+
451+
----
452+
@BindParam(value = MyFactoryClass.class, fn = "fromContext")
453+
----
454+
386455
==== Flash
387456

388457
Provisioning of flash attribute is available via javadoc:annotation.FlashParam[] annotation:

jooby/src/main/java/io/jooby/annotation/BindParam.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@
2020
@Target({ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
2121
public @interface BindParam {
2222
/**
23-
* Class containing the mapping function.
23+
* Class containing the mapping function. If no class is specified it looks at:
2424
*
25-
* @return Default to controller class.
25+
* <p>- Parameter type for {@link #fn()} method. - Or fallback to controller class.
26+
*
27+
* @return Source of mapping function.
2628
*/
2729
Class<?> value() default void.class;
2830

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

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@
1717
import java.util.function.Predicate;
1818
import java.util.stream.Stream;
1919

20-
import javax.lang.model.element.AnnotationMirror;
21-
import javax.lang.model.element.ExecutableElement;
22-
import javax.lang.model.element.Modifier;
20+
import javax.lang.model.element.*;
2321

2422
public enum ParameterGenerator {
2523
ContextParam("getAttribute", "io.jooby.annotation.ContextParam", "jakarta.ws.rs.core.Context") {
@@ -116,32 +114,37 @@ public String toSourceCode(
116114
TypeDefinition type,
117115
String name,
118116
boolean nullable) {
117+
List<Element> converters = new ArrayList<>();
119118
var typeNames = findAnnotationValue(annotation, AnnotationSupport.VALUE);
120119
var typeName = typeNames.isEmpty() ? null : typeNames.get(0);
121120
var router = route.getRouter();
122121
var targetType = router.getTargetType();
123-
var converter =
124-
typeName == null
125-
? targetType
126-
: route
127-
.getContext()
128-
.getProcessingEnvironment()
129-
.getElementUtils()
130-
.getTypeElement(typeName);
122+
var env = route.getContext().getProcessingEnvironment();
123+
if (typeName != null) {
124+
converters.add(env.getElementUtils().getTypeElement(typeName));
125+
} else {
126+
// Fallback bean class first
127+
converters.add(env.getTypeUtils().asElement(type.getRawType()));
128+
// Fallback controller class later
129+
converters.add(targetType);
130+
}
131131
var fns = findAnnotationValue(annotation, "fn"::equals);
132132
var fn = fns.isEmpty() ? null : fns.get(0);
133133
Predicate<ExecutableElement> contextAsParameter =
134134
it ->
135135
it.getParameters().size() == 1
136136
&& 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);
137+
Predicate<ExecutableElement> matchesType =
138+
it -> {
139+
var returnType =
140+
new TypeDefinition(
141+
route.getContext().getProcessingEnvironment().getTypeUtils(),
142+
it.getReturnType())
143+
.getRawType();
144+
return returnType.equals(type.getRawType())
145+
|| (it.getSimpleName().toString().equals("<init>"));
146+
};
147+
var filter = contextAsParameter.and(matchesType);
145148
String methodErrorName;
146149
if (fn != null) {
147150
Predicate<ExecutableElement> matchesName = it -> it.getSimpleName().toString().equals(fn);
@@ -152,7 +155,8 @@ public String toSourceCode(
152155
}
153156
// find function by type
154157
ExecutableElement mapping =
155-
converter.getEnclosedElements().stream()
158+
converters.stream()
159+
.flatMap(it -> it.getEnclosedElements().stream())
156160
.filter(ExecutableElement.class::isInstance)
157161
.map(ExecutableElement.class::cast)
158162
// filter by Context
@@ -161,21 +165,23 @@ public String toSourceCode(
161165
.orElseThrow(
162166
() ->
163167
new IllegalArgumentException(
164-
"Method not found: " + converter + ".[unnamed]" + methodErrorName));
168+
"Method not found: " + methodErrorName + " on " + converters));
165169
if (!mapping.getModifiers().contains(Modifier.PUBLIC)) {
166-
throw new IllegalArgumentException("Method is not public: " + converter + "." + mapping);
170+
throw new IllegalArgumentException(
171+
"Method is not public: " + mapping.getEnclosingElement() + "." + mapping);
167172
}
168173
if (mapping.getModifiers().contains(Modifier.STATIC)) {
169174
return CodeBlock.of(
170-
converter.equals(targetType)
171-
? mapping.getSimpleName()
172-
: converter.getQualifiedName() + "." + mapping.getSimpleName(),
173-
"(ctx)");
175+
mapping.getEnclosingElement().asType() + "." + mapping.getSimpleName(), "(ctx)");
174176
} else {
175-
if (converter.equals(targetType)) {
177+
if (mapping.getEnclosingElement().equals(targetType)) {
176178
return CodeBlock.of("c." + mapping.getSimpleName(), "(ctx)");
177179
} else {
178-
throw new IllegalArgumentException("Not a static method: " + converter + "." + mapping);
180+
if (mapping.getKind() == ElementKind.CONSTRUCTOR) {
181+
return CodeBlock.of(kt ? "" : "new ", type.getName(), "(ctx)");
182+
}
183+
throw new IllegalArgumentException(
184+
"Not a static method: " + mapping.getEnclosingElement() + "." + mapping);
179185
}
180186
}
181187
}

modules/jooby-apt/src/test/java/tests/i3472/BeanMapping.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99

1010
public class BeanMapping {
1111
public static BindBean map(Context ctx) {
12-
return new BindBean("extends:" + ctx.query("value").value());
12+
return new BindBean("mapping:" + ctx.query("value").value());
13+
}
14+
15+
public static BindBean withName(Context ctx) {
16+
return new BindBean("withName:" + ctx.query("value").value());
1317
}
1418
}

modules/jooby-apt/src/test/java/tests/i3472/BindBean.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@
1010
public record BindBean(String value) {
1111

1212
public static BindBean of(Context ctx) {
13-
return new BindBean("static:" + ctx.query("value").value());
13+
return new BindBean("bean-factory-method:" + ctx.query("value").value());
1414
}
1515
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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 class BindBeanConstructor {
11+
12+
private final String value;
13+
14+
public BindBeanConstructor(Context ctx) {
15+
this.value = "bean-constructor:" + ctx.query("value").value();
16+
}
17+
18+
@Override
19+
public String toString() {
20+
return value;
21+
}
22+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
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+
public record BindController(String value) {}

modules/jooby-apt/src/test/java/tests/i3472/C3472.java

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,37 +5,19 @@
55
*/
66
package tests.i3472;
77

8-
import io.jooby.Context;
98
import io.jooby.annotation.BindParam;
109
import io.jooby.annotation.GET;
1110

1211
public class C3472 {
1312

14-
@GET("/3472")
15-
public BindBean bind(@BindParam BindBean bean) {
13+
@GET("/3472/mapping")
14+
public BindBean bind(@BindParam(BeanMapping.class) BindBean bean) {
1615
return bean;
1716
}
1817

19-
@GET("/3472/named")
20-
public BindBean bindName(@BindParam(fn = "bindWithName") BindBean bean) {
18+
@GET("/3472/withName")
19+
public BindBean bindWithName(
20+
@BindParam(value = BeanMapping.class, fn = "withName") BindBean bean) {
2121
return bean;
2222
}
23-
24-
@GET("/3472/static")
25-
public BindBean bindStatic(@BindParam(BindBean.class) BindBean bean) {
26-
return bean;
27-
}
28-
29-
@GET("/3472/extends")
30-
public BindBean bindExtends(@SubAnnotation BindBean bean) {
31-
return bean;
32-
}
33-
34-
public BindBean convert(Context ctx) {
35-
return new BindBean(ctx.query("value").value());
36-
}
37-
38-
public BindBean bindWithName(Context ctx) {
39-
return new BindBean("fn:" + ctx.query("value").value());
40-
}
4123
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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.annotation.BindParam;
9+
import io.jooby.annotation.GET;
10+
11+
public class C3472b {
12+
13+
@GET("/3472/bean-factory-method")
14+
public BindBean bind(@BindParam BindBean bean) {
15+
return bean;
16+
}
17+
18+
@GET("/3472/bean-constructor")
19+
public String bind(@BindParam BindBeanConstructor bean) {
20+
return bean.toString();
21+
}
22+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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 C3472c {
13+
14+
@GET("/3472/bean-controller")
15+
public BindController bind(@BindParam BindController bean) {
16+
return bean;
17+
}
18+
19+
public BindController create(Context ctx) {
20+
return new BindController("bean-controller:" + ctx.query("value").value());
21+
}
22+
}

0 commit comments

Comments
 (0)