From cdfba4e34725e38e60de035a52fb8db178328b6c Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 22 Feb 2026 20:05:25 -0300 Subject: [PATCH 1/3] FEATURE: Unified JSON Projection API Provide a consistent way of filtering JSON output. This API allows developers to define exactly which fields of a Java object should be serialized, supporting both simple flat structures and deep nested graphs. The API is designed to be library-agnostic, allowing the same projection definition to work across different JSON providers without requiring changes to domain models or POJOs. Flexible Syntax: Supports Dot-notation (address.city) and Avaje/LinkedIn-style grouping (address(city, zip)). Type-Safety: Full support for Java method references (User::getName) to provide refactor-safe projections. Hierarchical Validation: Projections are validated against the target class hierarchy at definition time, including support for unwrapping Collections and Maps. Fluent & Declarative API: A Projected wrapper for programmatic use and a `@Project` annotation for MVC controllers. - Programmatic (Script API): ```java get("/user/{id}", ctx -> { User user = service.find(ctx.path("id").value()); return Projected.wrap(user) .include("id, name") .include(User::getAddress, addr -> addr.include("city")); }); ``` - Declarative (MVC API): ```java @GET @Project("id, name, address(city)") public User getUser(String id) { return service.find(id); } ``` The feature consists of three primary components that work together to define, wrap, and apply the filtering logic. 1. Projection The engine that parses syntax and validates paths against the class hierarchy. ```java // Manual definition Projection p = Projection.of(User.class) .include("id, address(city)"); ``` 2. Projected ```java // Script API get("/user", ctx -> { return Projected.wrap(user).include(User::getName); }); ``` 3. `@Project` A declarative annotation for MVC controllers. ```java // MVC API @GET @Project("id, name") public User getUser() { ... } ``` --- jooby/src/main/java/io/jooby/Projected.java | 63 ++ jooby/src/main/java/io/jooby/Projection.java | 583 ++++++++++++++++++ .../java/io/jooby/annotation/Project.java | 49 ++ jooby/src/main/java/module-info.java | 1 - .../test/java/io/jooby/ProjectionTest.java | 319 ++++++++++ .../java/io/jooby/internal/apt/MvcRoute.java | 6 +- .../java/io/jooby/internal/apt/Types.java | 2 + .../src/test/java/tests/i3854/C3854.java | 20 + .../src/test/java/tests/i3854/Issue3854.java | 21 + .../src/test/java/tests/i3854/U3854.java | 8 + .../jooby/avaje/jsonb/AvajeJsonbModule.java | 38 +- .../java/io/jooby/jackson/JacksonModule.java | 46 +- .../jackson/JacksonProjectedSerializer.java | 48 ++ .../jackson/JacksonProjectionFilter.java | 101 +++ .../io/jooby/jackson3/Jackson3Module.java | 59 +- .../jackson3/JacksonProjectionFilter.java | 97 +++ tests/pom.xml | 5 + .../test/java/io/jooby/i3853/Issue3853.java | 278 +++++++++ 18 files changed, 1711 insertions(+), 33 deletions(-) create mode 100644 jooby/src/main/java/io/jooby/Projected.java create mode 100644 jooby/src/main/java/io/jooby/Projection.java create mode 100644 jooby/src/main/java/io/jooby/annotation/Project.java create mode 100644 jooby/src/test/java/io/jooby/ProjectionTest.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3854/C3854.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3854/Issue3854.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3854/U3854.java create mode 100644 modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectedSerializer.java create mode 100644 modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectionFilter.java create mode 100644 modules/jooby-jackson3/src/main/java/io/jooby/jackson3/JacksonProjectionFilter.java create mode 100644 tests/src/test/java/io/jooby/i3853/Issue3853.java diff --git a/jooby/src/main/java/io/jooby/Projected.java b/jooby/src/main/java/io/jooby/Projected.java new file mode 100644 index 0000000000..df14577fa9 --- /dev/null +++ b/jooby/src/main/java/io/jooby/Projected.java @@ -0,0 +1,63 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby; + +import java.util.function.Consumer; + +/** + * A wrapper for a value and its associated {@link Projection}. + * + * @param The value type. + * @author edgar + * @since 4.0.0 + */ +public class Projected { + private final T value; + private final Projection projection; + + private Projected(T value, Projection projection) { + this.value = value; + this.projection = projection; + } + + @SuppressWarnings("unchecked") + public static Projected wrap(T value) { + return new Projected<>(value, Projection.of((Class) value.getClass())); + } + + public static Projected wrap(T value, Projection projection) { + return new Projected<>(value, projection); + } + + public T getValue() { + return value; + } + + public Projection getProjection() { + return projection; + } + + public Projected include(String... paths) { + projection.include(paths); + return this; + } + + @SafeVarargs + public final Projected include(Projection.Property... props) { + projection.include(props); + return this; + } + + public Projected include(Projection.Property prop, Consumer> child) { + projection.include(prop, child); + return this; + } + + @Override + public String toString() { + return projection.toString(); + } +} diff --git a/jooby/src/main/java/io/jooby/Projection.java b/jooby/src/main/java/io/jooby/Projection.java new file mode 100644 index 0000000000..099cb7c582 --- /dev/null +++ b/jooby/src/main/java/io/jooby/Projection.java @@ -0,0 +1,583 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby; + +import java.io.Serializable; +import java.lang.invoke.SerializedLambda; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/** + * Hierarchical schema for JSON field selection. A Projection defines exactly which fields of a Java + * object should be serialized to JSON. + * + *

It supports multiple declaration styles, all of which are validated against the target class + * hierarchy (including unwrapping Collections and Maps) at definition time. + * + *

1. Dot Notation

+ * + *

Standard path-based selection for nested objects. + * + *

{@code
+ * Projection.of(User.class).include("name", "address.city");
+ * }
+ * + *

2. Avaje Notation

+ * + *

Parenthesis-based grouping for complex nested graphs, compatible with LinkedIn-style syntax. + * + *

{@code
+ * Projection.of(User.class).include("id, address(city, zip, geo(lat, lon))");
+ * }
+ * + *

3. Type-Safe Method References

+ * + *

Refactor-safe selection using Java method references. These are validated by the compiler. + * + *

{@code
+ * Projection.of(User.class).include(User::getName, User::getId);
+ * }
+ * + *

4. Functional Nested DSL

+ * + *

A type-safe way to define deep projections while maintaining IDE autocomplete for nested + * types. + * + *

{@code
+ * Projection.of(User.class)
+ * .include(User::getName)
+ * .include(User::getAddress, addr -> addr
+ * .include(Address::getCity)
+ * );
+ * }
+ * + *

Polymorphism and Validation

+ * + *

By default, projections strictly validate requested fields against the declared return type + * using reflection. If a field is not found, an {@link IllegalArgumentException} is thrown at + * compilation time. + * + *

If your route returns polymorphic types (e.g., a {@code List} containing {@code Dog} + * and {@code Cat} instances), strict validation will fail if you request a subclass-specific field + * like {@code barkVolume}. To support polymorphic shaping, you can disable strict validation using + * {@link #validate()} prior to calling {@code include()}: + * + *

{@code
+ * Projection.of(Animal.class)
+ * .validate(false)
+ * .include("name, barkVolume")
+ * }
+ * + *

Performance

+ * + *

Projections are pre-compiled. All reflection and path validation happen during the + * include calls. In a production environment, it is recommended to define Projections as + * static final constants. + * + * @param The root type being projected. + * @author edgar + * @since 4.0.0 + */ +public class Projection { + + /** + * Functional interface for capturing method references. + * + * @param The type containing the property. + * @param The return type of the property. + */ + @FunctionalInterface + public interface Property extends Serializable { + /** + * Captures the property method reference. + * + * @param instance The instance to apply the method to. + * @return The property value. + */ + R apply(T instance); + } + + private static final Map, String> PROP_CACHE = new ConcurrentHashMap<>(); + + private final Class type; + private final Map> children = new LinkedHashMap<>(); + private String view = ""; + private final boolean root; + private boolean validate; + + private Projection(Class type, boolean root, boolean validate) { + this.type = Objects.requireNonNull(type); + this.root = root; + this.validate = validate; + } + + /** + * Creates a new Projection for the given type. + * + * @param Root type. + * @param type Root class. + * @return A new Projection instance. + */ + public static Projection of(Class type) { + return new Projection<>(type, true, false); + } + + /** + * Includes fields via string notation. Supports both Dot notation ({@code a.b}) and Avaje + * notation ({@code a(b,c)}). + * + * @param paths Field paths to include. + * @return This projection instance. + * @throws IllegalArgumentException If a field name is not found on the class hierarchy. + */ + public Projection include(String... paths) { + for (String path : paths) { + if (path == null || path.isEmpty()) continue; + validateParentheses(path); + for (String segment : splitByComma(path)) { + parseAndValidate(segment.trim()); + } + } + rebuild(); + return this; + } + + /** + * Includes fields via type-safe method references. + * + * @param props Method references. + * @return This projection instance. + */ + @SafeVarargs + public final Projection include(Property... props) { + for (Property prop : props) { + String name = getFieldName(prop); + children.computeIfAbsent( + name, k -> new Projection<>(resolveFieldType(this.type, name), false, validate)); + } + rebuild(); + return this; + } + + /** + * Includes a nested field and configures its sub-projection using a lambda. This provides full + * type-safety for nested objects. + * + * @param The type of the nested field. + * @param prop The method reference to the nested field. + * @param childSpec A consumer that configures the nested projection. + * @return This projection instance. + */ + public Projection include(Property prop, Consumer> childSpec) { + String name = getFieldName(prop); + Class childType = (Class) resolveFieldType(this.type, name); + Projection child = + (Projection) + children.computeIfAbsent(name, k -> new Projection<>(childType, false, validate)); + childSpec.accept(child); + child.rebuild(); + rebuild(); + return this; + } + + public Map> getChildren() { + return Collections.unmodifiableMap(children); + } + + /** + * Configures whether the projection should fail when a requested property is not found on the + * declared class type. + * + * @return This projection instance. + */ + public Projection validate() { + this.validate = true; + return this; + } + + /** + * Returns the Avaje-compatible DSL string. + * + * @return The pre-compiled view string. + */ + public String toView() { + return view; + } + + public Class getType() { + return type; + } + + private void validateParentheses(String path) { + int depth = 0; + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + } + + // If depth drops below 0, we have an extra closing parenthesis like "id)" + if (depth < 0) { + throw new IllegalArgumentException("Mismatched parentheses in projection: " + path); + } + } + + // If depth is not 0 at the end, we are missing a closing parenthesis + if (depth > 0) { + throw new IllegalArgumentException("Missing closing parenthesis in projection: " + path); + } + } + + private void parseAndValidate(String path) { + if (path == null || path.trim().isEmpty()) return; + path = path.trim(); + + // 1. Root-level grouping: "(id, name, address)" + if (path.startsWith("(") && path.endsWith(")")) { + String content = path.substring(1, path.length() - 1).trim(); + for (String p : splitByComma(content)) { + parseAndValidate(p); + } + return; + } + + int parenIdx = path.indexOf('('); + int dotIdx = path.indexOf('.'); + + // 2. Nested grouping: "address(city, loc)" or "address(*)" + if (parenIdx != -1 && (dotIdx == -1 || parenIdx < dotIdx)) { + String parentName = path.substring(0, parenIdx).trim(); + if (parentName.isEmpty()) return; + + String content = path.substring(parenIdx + 1, path.lastIndexOf(')')).trim(); + + Class childType = resolveFieldType(this.type, parentName); + Projection child = + children.computeIfAbsent(parentName, k -> new Projection<>(childType, false, validate)); + + for (String p : splitByComma(content)) { + p = p.trim(); + // Ignore explicit wildcard to leave children map empty (triggering allow-all later) + if (!p.equals("*") && !p.isEmpty()) { + child.parseAndValidate(p); + } + } + child.rebuild(); + } + // 3. Dot notation: "address.city" + else if (dotIdx != -1) { + String parentName = path.substring(0, dotIdx).trim(); + String content = path.substring(dotIdx + 1).trim(); + + Class childType = resolveFieldType(this.type, parentName); + Projection child = + children.computeIfAbsent(parentName, k -> new Projection<>(childType, false, validate)); + + if (!content.equals("*") && !content.isEmpty()) { + child.parseAndValidate(content); + } + child.rebuild(); + } + // 4. Flat field: "id" + else { + if (!path.equals("*")) { + Class childType = resolveFieldType(this.type, path); + children.computeIfAbsent(path, k -> new Projection<>(childType, false, validate)); + } + } + } + + private List splitByComma(String s) { + List result = new ArrayList<>(); + int depth = 0; + StringBuilder sb = new StringBuilder(); + for (char c : s.toCharArray()) { + if (c == '(') depth++; + else if (c == ')') depth--; + + if (c == ',' && depth == 0) { + result.add(sb.toString()); + sb.setLength(0); + } else { + sb.append(c); + } + } + result.add(sb.toString()); + return result; + } + + private void rebuild() { + StringBuilder buffer = new StringBuilder(); + int i = 0; + for (Map.Entry> entry : children.entrySet()) { + if (i > 0) { + buffer.append(","); + } + + buffer.append(entry.getKey()); + Projection child = entry.getValue(); + + if (!child.getChildren().isEmpty()) { + // Node has explicit children, recurse normally + buffer.append("(").append(child.toView()).append(")"); + } else { + // Option 3: Deep Smart Wildcard injection + Class childType = child.type; + if (!childType.isPrimitive() && !childType.getName().startsWith("java.")) { + // It's a complex POJO with no explicit children. + // We must build a full explicit wildcard string for Avaje. + String deepWildcard = buildDeepWildcard(childType); + if (!deepWildcard.isEmpty()) { + buffer.append("(").append(deepWildcard).append(")"); + } + } + } + i++; + } + + String result = buffer.toString(); + + // Ensure root-level multi-fields are strictly wrapped for Avaje + if (root && !result.startsWith("(") && result.contains(",")) { + this.view = "(" + result + ")"; + } else { + this.view = result; + } + } + + private String buildDeepWildcard(Class type) { + return buildDeepWildcard(type, new HashSet<>()); + } + + private String buildDeepWildcard(Class type, Set> seen) { + if (type == null || type.isPrimitive() || type.getName().startsWith("java.")) { + return ""; + } + + if (!seen.add(type)) { + return ""; + } + + Map properties = new TreeMap<>(); + + // 1. Getters FIRST (The ultimate source of truth for JSON serialization) + for (Method method : type.getMethods()) { + if (method.getDeclaringClass() == Object.class + || method.getParameterCount() > 0 + || java.lang.reflect.Modifier.isStatic(method.getModifiers())) { + continue; + } + + String methodName = method.getName(); + String propName = null; + + if (methodName.startsWith("get") && methodName.length() > 3) { + propName = Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4); + } else if (methodName.startsWith("is") && methodName.length() > 2) { + Class retType = method.getReturnType(); + if (retType == boolean.class || retType == Boolean.class) { + propName = Character.toLowerCase(methodName.charAt(2)) + methodName.substring(3); + } + } + + if (propName != null) { + properties.putIfAbsent(propName, method.getGenericReturnType()); + } + } + + // 2. Fields SECOND (Fallback for properties without getters, like Java Records or plain fields) + Class currentClass = type; + while (currentClass != null && currentClass != Object.class) { + for (java.lang.reflect.Field field : currentClass.getDeclaredFields()) { + int modifiers = field.getModifiers(); + if (java.lang.reflect.Modifier.isStatic(modifiers) + || java.lang.reflect.Modifier.isTransient(modifiers)) { + continue; + } + // Only adds the field if a getter didn't already claim this property name + properties.putIfAbsent(field.getName(), field.getGenericType()); + } + currentClass = currentClass.getSuperclass(); + } + + // 3. Build the View String + StringBuilder sb = new StringBuilder(); + int count = 0; + + for (Map.Entry entry : properties.entrySet()) { + if (count > 0) sb.append(","); + sb.append(entry.getKey()); + + Type propType = entry.getValue(); + Class rawType = null; + + if (propType instanceof Class) { + rawType = (Class) propType; + } else if (propType instanceof ParameterizedType) { + ParameterizedType paramType = (ParameterizedType) propType; + Type raw = paramType.getRawType(); + + if (raw instanceof Class) { + Class rawClass = (Class) raw; + if (Collection.class.isAssignableFrom(rawClass)) { + Type typeArg = paramType.getActualTypeArguments()[0]; + if (typeArg instanceof Class) rawType = (Class) typeArg; + } else if (Map.class.isAssignableFrom(rawClass)) { + Type typeArg = paramType.getActualTypeArguments()[1]; + if (typeArg instanceof Class) rawType = (Class) typeArg; + } else { + rawType = rawClass; + } + } + } + + if (rawType != null && !rawType.isPrimitive() && !rawType.getName().startsWith("java.")) { + String nested = buildDeepWildcard(rawType, seen); + if (!nested.isEmpty()) { + sb.append("(").append(nested).append(")"); + } + } + count++; + } + + seen.remove(type); + return sb.toString(); + } + + private Class resolveFieldType(Class currentType, String fieldName) { + // 1. If we are already in a dynamic tree, keep returning Object.class + if (currentType == null || currentType == Object.class) { + return Object.class; + } + + Type genericType = null; + Class rawType = null; + + // 2. Try Getters FIRST (The ultimate source of truth for JSON serialization) + String capitalized = Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1); + + try { + Method method = currentType.getMethod("get" + capitalized); + rawType = method.getReturnType(); + genericType = method.getGenericReturnType(); + } catch (NoSuchMethodException e1) { + try { + Method method = currentType.getMethod("is" + capitalized); + Class retType = method.getReturnType(); + if (retType == boolean.class || retType == Boolean.class) { + rawType = retType; + genericType = method.getGenericReturnType(); + } + } catch (NoSuchMethodException e2) { + // Ignore + } + } + + // Try record-style / fluent getter if standard getters weren't found + if (rawType == null) { + try { + Method method = currentType.getMethod(fieldName); + rawType = method.getReturnType(); + genericType = method.getGenericReturnType(); + } catch (NoSuchMethodException e3) { + // Ignore + } + } + + // 3. Fallback to Fields SECOND (climbing the hierarchy) + if (rawType == null) { + Class clazz = currentType; + while (clazz != null && clazz != Object.class) { + try { + Field field = clazz.getDeclaredField(fieldName); + rawType = field.getType(); + genericType = field.getGenericType(); + break; // Found it! + } catch (NoSuchFieldException ignored) { + clazz = clazz.getSuperclass(); // Check the parent class + } + } + } + + // 4. Handle Not Found + if (rawType == null) { + // Dynamic map keys fallback + if (currentType.getName().startsWith("java.")) { + return Object.class; + } + if (validate) { + throw new IllegalArgumentException( + "Invalid projection path: '" + + fieldName + + "' not found on " + + currentType.getName() + + " or its superclasses."); + } + return Object.class; + } + + // 5. Unwrap Generics (e.g., List -> Role) + if (genericType instanceof ParameterizedType) { + ParameterizedType paramType = (ParameterizedType) genericType; + + if (Collection.class.isAssignableFrom(rawType)) { + Type typeArg = paramType.getActualTypeArguments()[0]; + if (typeArg instanceof Class) return (Class) typeArg; + } + + if (Map.class.isAssignableFrom(rawType)) { + Type typeArg = paramType.getActualTypeArguments()[1]; // Maps resolve to Value type + if (typeArg instanceof Class) return (Class) typeArg; + } + } + + return rawType; + } + + private static String getFieldName(Property property) { + return PROP_CACHE.computeIfAbsent( + property.getClass(), + clz -> { + try { + Method m = clz.getDeclaredMethod("writeReplace"); + m.setAccessible(true); + SerializedLambda l = (SerializedLambda) m.invoke(property); + String n = l.getImplMethodName(); + int s = n.startsWith("get") ? 3 : (n.startsWith("is") ? 2 : 0); + return Character.toLowerCase(n.charAt(s)) + n.substring(s + 1); + } catch (Exception x) { + throw new IllegalArgumentException("Could not resolve field from method reference.", x); + } + }); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Projection that = (Projection) o; + return root == that.root + && Objects.equals(type, that.type) + && Objects.equals(children, that.children); + } + + @Override + public int hashCode() { + return Objects.hash(type, children, root); + } + + @Override + public String toString() { + return type.getSimpleName() + view; + } +} diff --git a/jooby/src/main/java/io/jooby/annotation/Project.java b/jooby/src/main/java/io/jooby/annotation/Project.java new file mode 100644 index 0000000000..84791444e1 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/Project.java @@ -0,0 +1,49 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation; + +import java.lang.annotation.*; + +/** + * Declarative JSON projection for route handlers. + * + *

When applied to a method or class, Jooby automatically filters the JSON output to include only + * the specified fields. * + * + *

String Notation Support:

+ * + *
    + *
  • Dot Notation: {@code "address.city"} + *
  • Avaje Notation: {@code "address(city, zip)"} + *
+ * + * * + * + *

Usage:

+ * + *
{@code
+ * @GET
+ * @Project({"id", "name", "address(city, zip)"})
+ * public User getUser() {
+ * return userService.find(1);
+ * }
+ * }
+ * + * @author edgar + * @since 4.0.0 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface Project { + /** + * Field paths to include. Supports dot-notation and avaje-notation. + * + * @return The array of field paths. + */ + String[] value() default {}; +} diff --git a/jooby/src/main/java/module-info.java b/jooby/src/main/java/module-info.java index 87b3707006..d8382ec3b5 100644 --- a/jooby/src/main/java/module-info.java +++ b/jooby/src/main/java/module-info.java @@ -14,7 +14,6 @@ exports io.jooby.problem; exports io.jooby.value; exports io.jooby.output; - exports io.jooby.internal.output; uses io.jooby.Server; uses io.jooby.SslProvider; diff --git a/jooby/src/test/java/io/jooby/ProjectionTest.java b/jooby/src/test/java/io/jooby/ProjectionTest.java new file mode 100644 index 0000000000..a5f816dcd6 --- /dev/null +++ b/jooby/src/test/java/io/jooby/ProjectionTest.java @@ -0,0 +1,319 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; + +import org.junit.jupiter.api.Test; + +/** Tests for Jooby Projection API. */ +public class ProjectionTest { + + // --- Test Models --- + + public static class User { + private String id; + private String name; + private Address address; + private List roles; + private Map meta; + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public Address getAddress() { + return address; + } + + public List getRoles() { + return roles; + } + + public Map getMeta() { + return meta; + } + } + + public static class ExtendedUser extends User { + public String getFullName() { + return getName(); + } + } + + public static class NamedGroup { + private String name; + + private Group group; + + public String getName() { + return name; + } + + public Group getGroup() { + return group; + } + } + + public static class Group { + private List users; + + public List getUsers() { + return users; + } + } + + public static class Address { + private String city; + private Location loc; + + public String getCity() { + return city; + } + + public Location getLoc() { + return loc; + } + } + + public record Role(String name, int level) {} + + public record Location(double lat, double lon) {} + + // --- Tests --- + + @Test + public void testOrderPreservation() { + // LinkedMap should preserve the order 'name' then 'id' + Projection p = Projection.of(User.class).include("name", "id"); + assertEquals("(name,id)", p.toView()); + + // Swapping order should result in swapped view + Projection p2 = Projection.of(User.class).include("id", "name"); + assertEquals("(id,name)", p2.toView()); + } + + @Test + public void testAvajeNotationRoot() { + // Root level should be wrapped in parentheses for Avaje + Projection p = Projection.of(User.class).include("id, address(city, loc(lat, lon))"); + + assertEquals("(id,address(city,loc(lat,lon)))", p.toView()); + } + + @Test + public void testInherited() { + Projection group = Projection.of(NamedGroup.class).include("id, group(*)"); + + assertEquals( + "(id,group(users(address(city,loc(lat,lon)),fullName,id,meta,name,roles(level,name))))", + group.toView()); + + Projection p = Projection.of(ExtendedUser.class).include("id, fullname"); + + assertEquals("(id,fullname)", p.toView()); + } + + @Test + public void testMixedNotationRecursive() { + // Validates that nested children still use parentheses + Projection p = Projection.of(User.class).include("address.loc(lat, lon)", "roles(name)"); + + assertEquals("(address(loc(lat,lon)),roles(name))", p.toView()); + } + + @Test + public void testTypeSafeInclude() { + // Type-safe references also follow the defined order + Projection p = Projection.of(User.class).include(User::getName, User::getId); + assertEquals("(name,id)", p.toView()); + assertEquals("User(name,id)", p.toString()); + } + + @Test + public void testCollectionGenericUnwrapping() { + Projection p = Projection.of(User.class).include("roles.name"); + assertEquals("roles(name)", p.toView()); + } + + @Test + public void testMapGenericUnwrapping() { + // Maps resolve to their value type (String in this case) + assertEquals("meta(bytes)", Projection.of(User.class).include("meta.bytes").toView()); + assertEquals( + "(id,meta(target))", Projection.of(User.class).include("(id, meta(target))").toView()); + } + + @Test + public void testRecordSupport() { + Projection p = Projection.of(Role.class).include("name", "level"); + assertEquals("(name,level)", p.toView()); + } + + @Test + public void testFailFastValidation() { + // Ensures we still blow up on typos during pre-compilation + assertThrows( + IllegalArgumentException.class, + () -> Projection.of(User.class).validate().include("address(ctiy)")); + } + + @Test + public void testRootParenthesesBug() { + assertEquals( + "(name,address(city))", + Projection.of(User.class).include("(name, address(city))").toView()); + + // Address expands to its deep explicit wildcard definition for Avaje + assertEquals( + "(name,address(city,loc(lat,lon)))", + Projection.of(User.class).include("(name, address)").toView()); + } + + @Test + public void testRootParentheses() { + Projection p = Projection.of(User.class).include("(id, name, address)"); + + assertTrue(p.getChildren().containsKey("id")); + assertTrue(p.getChildren().containsKey("name")); + assertTrue(p.getChildren().containsKey("address")); + + // Address should have no explicitly defined children initially + // (the deep wildcard happens during toView()) + assertTrue(p.getChildren().get("address").getChildren().isEmpty()); + + // Test the expanded view + assertEquals("(id,name,address(city,loc(lat,lon)))", p.toView()); + } + + @Test + public void testAvajeWildcardSyntax() { + Projection p = Projection.of(User.class).include("id, name, address(*)"); + + assertTrue(p.getChildren().containsKey("id")); + assertTrue(p.getChildren().containsKey("name")); + assertTrue(p.getChildren().containsKey("address")); + + // The explicit '*' should result in an empty children map for address + assertTrue(p.getChildren().get("address").getChildren().isEmpty()); + + // Test the expanded view + assertEquals("(id,name,address(city,loc(lat,lon)))", p.toView()); + } + + @Test + public void testNestedWildcardSyntax() { + Projection p = Projection.of(User.class).include("id, address(city, loc(*))"); + + assertTrue(p.getChildren().containsKey("id")); + assertTrue(p.getChildren().containsKey("address")); + + Projection addressProj = p.getChildren().get("address"); + assertTrue(addressProj.getChildren().containsKey("city")); + assertTrue(addressProj.getChildren().containsKey("loc")); + + // loc(*) should result in an empty children map for loc + assertTrue(addressProj.getChildren().get("loc").getChildren().isEmpty()); + + // Test the expanded view (loc expands to its fields) + assertEquals("(id,address(city,loc(lat,lon)))", p.toView()); + } + + @Test + public void testCollectionNestedSyntax() { + // Tests: (id, roles(name)) + Projection p = Projection.of(User.class).include("(id, roles(name))"); + + assertTrue(p.getChildren().containsKey("id")); + assertTrue(p.getChildren().containsKey("roles")); + + Projection rolesProj = p.getChildren().get("roles"); + assertTrue(rolesProj.getChildren().containsKey("name")); + + assertEquals("(id,roles(name))", p.toView()); + } + + @Test + public void testMissingClosingParenthesis() { + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> { + Projection.of(User.class).include("(id, name, address(*)"); + }); + + assertEquals( + "Missing closing parenthesis in projection: (id, name, address(*)", ex.getMessage()); + } + + @Test + public void testExtraClosingParenthesis() { + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> { + Projection.of(User.class).include("address(city))"); + }); + + assertEquals("Mismatched parentheses in projection: address(city))", ex.getMessage()); + } + + @Test + public void testCollectionDeepWildcardSyntax() { + // Test: Requesting the 'roles' list without explicitly defining its children. + // The projection engine should see that 'roles' is a List, + // extract the Role class, and expand it to its explicit fields (name, level) for Avaje. + Projection p = Projection.of(User.class).include("(id, roles)"); + + assertTrue(p.getChildren().containsKey("id")); + assertTrue(p.getChildren().containsKey("roles")); + + // The 'roles' node itself has no explicitly parsed children + assertTrue(p.getChildren().get("roles").getChildren().isEmpty()); + + // But the resulting view string should be fully expanded! + assertEquals("(id,roles(level,name))", p.toView()); + } + + @Test + public void testExplicitCollectionDeepWildcardSyntax() { + // Test: Requesting the 'roles' list using the explicit (*) syntax. + Projection p = Projection.of(User.class).include("id, roles(*)"); + + assertTrue(p.getChildren().containsKey("id")); + assertTrue(p.getChildren().containsKey("roles")); + + // The resulting view string should be fully expanded! + assertEquals("(id,roles(level,name))", p.toView()); + } + + @Test + public void testValidateToggle() { + // 1. Verify default strict behavior (throws exception) + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> Projection.of(User.class).validate().include("address(zipcode)")); + assertTrue(ex.getMessage().contains("zipcode")); + + // 2. Verify polymorphic/unknown fields are accepted when flag is false + Projection p = + Projection.of(User.class).include("address(zipcode), extraPolymorphicField"); + + // The projection shouldn't throw, and it should successfully generate the explicit paths + assertEquals("(address(zipcode),extraPolymorphicField)", p.toView()); + + // Verify the internal tree mapped them correctly as generic leaves + assertTrue(p.getChildren().containsKey("extraPolymorphicField")); + assertTrue(p.getChildren().get("address").getChildren().containsKey("zipcode")); + } +} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java index 975a746d46..939794e3f1 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java @@ -69,7 +69,10 @@ public TypeDefinition getReturnType() { var processingEnv = context.getProcessingEnvironment(); var types = processingEnv.getTypeUtils(); var elements = processingEnv.getElementUtils(); - if (returnType.isVoid()) { + var project = AnnotationSupport.findAnnotationByName(method, Types.PROJECT); + if (project != null) { + return new TypeDefinition(types, elements.getTypeElement(Types.PROJECTED).asType()); + } else if (returnType.isVoid()) { return new TypeDefinition(types, elements.getTypeElement("io.jooby.StatusCode").asType()); } else if (isSuspendFun()) { var continuation = parameters.get(parameters.size() - 1).getType(); @@ -220,6 +223,7 @@ public List generateHandlerCall(boolean kt) { var returnTypeGenerics = getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); var returnTypeString = type(kt, getReturnType().toString()); + boolean nullable = false; if (kt) { nullable = diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/Types.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/Types.java index f653f32547..cf8b603976 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/Types.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/Types.java @@ -14,6 +14,8 @@ import java.util.TimeZone; class Types { + static final String PROJECT = "io.jooby.annotation.Project"; + static final String PROJECTED = "io.jooby.annotation.Projected"; static final Set BUILT_IN = Set.of( String.class.getName(), diff --git a/modules/jooby-apt/src/test/java/tests/i3854/C3854.java b/modules/jooby-apt/src/test/java/tests/i3854/C3854.java new file mode 100644 index 0000000000..c657cc3c96 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3854/C3854.java @@ -0,0 +1,20 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3854; + +import io.jooby.annotation.GET; +import io.jooby.annotation.Path; +import io.jooby.annotation.Project; +import tests.i3804.Base3804; + +@Path("/3854") +public class C3854 extends Base3804 { + @GET() + @Project("(id, name)") + public U3854 projectUser() { + return new U3854(1, "Projected User", "Projected", "User"); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3854/Issue3854.java b/modules/jooby-apt/src/test/java/tests/i3854/Issue3854.java new file mode 100644 index 0000000000..01b154edae --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3854/Issue3854.java @@ -0,0 +1,21 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3854; + +import org.junit.jupiter.api.Test; + +import io.jooby.apt.ProcessorRunner; + +public class Issue3854 { + @Test + public void shouldSupportNameAttribute() throws Exception { + new ProcessorRunner(new C3854()) + .withSourceCode( + source -> { + System.out.println(source); + }); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3854/U3854.java b/modules/jooby-apt/src/test/java/tests/i3854/U3854.java new file mode 100644 index 0000000000..6d0bcdc701 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3854/U3854.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3854; + +public record U3854(long id, String name, String firstName, String lastName) {} diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java index 706e737b77..0a1ab457a3 100644 --- a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java @@ -7,17 +7,14 @@ import java.io.InputStream; import java.lang.reflect.Type; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import edu.umd.cs.findbugs.annotations.NonNull; +import io.avaje.json.JsonWriter; +import io.avaje.jsonb.JsonView; import io.avaje.jsonb.Jsonb; -import io.jooby.Body; -import io.jooby.Context; -import io.jooby.Extension; -import io.jooby.Jooby; -import io.jooby.MediaType; -import io.jooby.MessageDecoder; -import io.jooby.MessageEncoder; -import io.jooby.ServiceRegistry; +import io.jooby.*; import io.jooby.internal.avaje.jsonb.BufferedJsonOutput; import io.jooby.output.Output; @@ -67,6 +64,8 @@ */ public class AvajeJsonbModule implements Extension, MessageDecoder, MessageEncoder { + private final ConcurrentMap> viewCache = new ConcurrentHashMap<>(); + private final Jsonb jsonb; /** @@ -104,14 +103,33 @@ public Object decode(@NonNull Context ctx, @NonNull Type type) throws Exception } } - @NonNull @Override + @Override public Output encode(@NonNull Context ctx, @NonNull Object value) { ctx.setDefaultResponseType(MediaType.json); var factory = ctx.getOutputFactory(); var buffer = factory.allocate(); try (var writer = jsonb.writer(new BufferedJsonOutput(buffer))) { - jsonb.toJson(value, writer); + if (value instanceof Projected projected) { + encodeProjection(writer, projected); + } else { + jsonb.toJson(value, writer); + } return buffer; } } + + @SuppressWarnings("unchecked") + private void encodeProjection(JsonWriter writer, Projected projected) { + // Generate the Avaje-compatible view string (e.g., "(id,name,address(city))") + var value = projected.getValue(); + var projection = projected.getProjection(); + var viewString = projection.toView(); + var type = projection.getType(); + var view = + (JsonView) + viewCache.computeIfAbsent( + type.getName() + viewString, k -> jsonb.type(type).view(viewString)); + + view.toJson(value, writer); + } } diff --git a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java index 70633961cb..fd8e960342 100644 --- a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java +++ b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java @@ -5,33 +5,32 @@ */ package io.jooby.jackson; +import static com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter.*; + import java.io.InputStream; import java.lang.reflect.Type; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Stream; +import com.fasterxml.jackson.annotation.JsonFilter; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.Module; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; import com.fasterxml.jackson.databind.type.TypeFactory; import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; import edu.umd.cs.findbugs.annotations.NonNull; -import io.jooby.Body; -import io.jooby.Context; -import io.jooby.Extension; -import io.jooby.Jooby; -import io.jooby.MediaType; -import io.jooby.MessageDecoder; -import io.jooby.MessageEncoder; -import io.jooby.ServiceRegistry; -import io.jooby.StatusCode; +import io.jooby.*; import io.jooby.output.Output; /** @@ -78,6 +77,14 @@ * @since 2.0.0 */ public class JacksonModule implements Extension, MessageDecoder, MessageEncoder { + public static final String FILTER_ID = "jooby.projection"; + + // Cache for ObjectWriters tied to specific projection strings + private final Map writerCache = new ConcurrentHashMap<>(); + + @JsonFilter(FILTER_ID) + private interface ProjectionMixIn {} + private final MediaType mediaType; private final ObjectMapper mapper; @@ -143,6 +150,14 @@ public void install(@NonNull Jooby application) { // Parsing exception as 400 application.errorCode(JsonParseException.class, StatusCode.BAD_REQUEST); + // Filter + var defaultProvider = new SimpleFilterProvider().setFailOnUnknownId(false); + mapper.addMixIn(Object.class, ProjectionMixIn.class); + mapper.setFilterProvider(defaultProvider); + var projectionModule = new SimpleModule(); + projectionModule.addSerializer(Projected.class, new JacksonProjectedSerializer(mapper)); + mapper.registerModule(projectionModule); + application.onStarting( () -> { for (Class type : modules) { @@ -156,6 +171,19 @@ public void install(@NonNull Jooby application) { public Output encode(@NonNull Context ctx, @NonNull Object value) throws Exception { var factory = ctx.getOutputFactory(); ctx.setDefaultResponseType(mediaType); + if (value instanceof Projected projected) { + var p = projected.getProjection(); + var writer = + writerCache.computeIfAbsent( + p.getType().getName() + p.toView(), + k -> { + // Use a specialized ObjectWriter with our custom path filter + var filters = + new SimpleFilterProvider().addFilter(FILTER_ID, new JacksonProjectionFilter(p)); + return mapper.writer(filters); + }); + return factory.wrap(writer.writeValueAsBytes(projected.getValue())); + } // let jackson uses his own cache, so wrap the bytes return factory.wrap(mapper.writeValueAsBytes(value)); } diff --git a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectedSerializer.java b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectedSerializer.java new file mode 100644 index 0000000000..13fce9d27a --- /dev/null +++ b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectedSerializer.java @@ -0,0 +1,48 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jackson; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; +import io.jooby.Projected; +import io.jooby.Projection; + +public class JacksonProjectedSerializer extends JsonSerializer { + private final Map, ObjectWriter> writerCache = new ConcurrentHashMap<>(); + + private final ObjectMapper mapper; + + public JacksonProjectedSerializer(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public void serialize( + Projected projected, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) + throws IOException { + // Create a dynamic provider for this specific projection + var writer = + writerCache.computeIfAbsent( + projected.getProjection(), + p -> { + var filters = + new SimpleFilterProvider() + .addFilter(JacksonModule.FILTER_ID, new JacksonProjectionFilter(p)); + return mapper.writer(filters); + }); + + // Write the value using the filtered writer + writer.writeValue(jsonGenerator, projected.getValue()); + } +} diff --git a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectionFilter.java b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectionFilter.java new file mode 100644 index 0000000000..f6650e4e40 --- /dev/null +++ b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectionFilter.java @@ -0,0 +1,101 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jackson; + +import java.util.ArrayDeque; +import java.util.Deque; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonStreamContext; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.PropertyWriter; +import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter; +import io.jooby.Projection; + +/** + * High-performance, fully stateless Jackson filter for Jooby Projections. Determines the correct + * filtering context by walking Jackson's internal stream context path back to the root. + * + * @author edgar + * @since 4.0.0 + */ +public class JacksonProjectionFilter extends SimpleBeanPropertyFilter { + + private final Projection root; + + public JacksonProjectionFilter(Projection root) { + this.root = root; + } + + @Override + public void serializeAsField( + Object pojo, JsonGenerator jgen, SerializerProvider provider, PropertyWriter writer) + throws Exception { + + // Bypass projection filtering for Map entries to match Avaje's behavior. + // We want to dump the entire Map payload without validating its dynamic keys against the static + // tree. + if (pojo instanceof java.util.Map) { + writer.serializeAsField(pojo, jgen, provider); + return; + } + + // 1. Resolve the active projection node for the object currently being serialized. + Projection current = resolveNode(jgen.getOutputContext()); + + if (current != null) { + String fieldName = writer.getName(); + + // 2. If the current node has no children defined, it acts as a wildcard (e.g., user + // requested 'address' instead of 'address(city)'), so we include all fields. + // Otherwise, we strictly check if the field is in the children map. + if (current.getChildren().isEmpty() || current.getChildren().containsKey(fieldName)) { + writer.serializeAsField(pojo, jgen, provider); + } + } + } + + private Projection resolveNode(JsonStreamContext context) { + if (context == null) { + return root; + } + + // Use a Deque to build the path in the correct (root-to-leaf) order by + // inserting at the front, eliminating the need for Collections.reverse(). + Deque path = new ArrayDeque<>(); + + // 1. Start from the parent context to build the path TO the current object being serialized. + // The current context's name is the property currently being evaluated, not the path. + JsonStreamContext curr = context.getParent(); + + while (curr != null && !curr.inRoot()) { + // 2. Only extract names from Object contexts. Array boundaries are ignored + // so that lists (e.g., List) map seamlessly to their parent field name. + if (curr.inObject() && curr.getCurrentName() != null) { + path.addFirst(curr.getCurrentName()); + } + curr = curr.getParent(); + } + + Projection node = root; + for (String segment : path) { + if (node == null) { + return null; // The path Jackson took is completely outside our projection + } + + // If we hit a node in our projection tree that exists but has no explicitly + // defined children, it means the user wants this entire subgraph. + // We stop traversing Jackson's path and return this wildcard node. + if (node != root && node.getChildren().isEmpty()) { + return node; + } + + node = node.getChildren().get(segment); + } + + return node; + } +} diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java index 57e2b8d083..32b92b5884 100644 --- a/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java +++ b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java @@ -5,6 +5,16 @@ */ package io.jooby.jackson3; +import java.io.InputStream; +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; + +import com.fasterxml.jackson.annotation.JsonFilter; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.*; import io.jooby.output.Output; @@ -12,17 +22,11 @@ import tools.jackson.databind.JacksonModule; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectWriter; import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.ser.std.SimpleFilterProvider; import tools.jackson.databind.type.TypeFactory; -import java.io.InputStream; -import java.lang.reflect.Type; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; -import java.util.stream.Stream; - /** * JSON module using Jackson3: https://jooby.io/modules/jackson3. * @@ -47,8 +51,8 @@ * }); * } * } - *

- * For body decoding the client must specify the Content-Type header set to + * + *

For body decoding the client must specify the Content-Type header set to * application/json. * *

You can retrieve the {@link ObjectMapper} via require call: @@ -65,9 +69,16 @@ * @since 4.1.0 */ public class Jackson3Module implements Extension, MessageDecoder, MessageEncoder { + // A hardcoded ID for our filter + public static final String FILTER_ID = "jooby.projection"; + + // Cache for ObjectWriters tied to specific projection strings + private final Map writerCache = new ConcurrentHashMap<>(); + private final MediaType mediaType; private final ObjectMapper mapper; + private ObjectMapper projectionMapper; private final TypeFactory typeFactory; @@ -82,7 +93,7 @@ public class Jackson3Module implements Extension, MessageDecoder, MessageEncoder /** * Creates a Jackson module. * - * @param mapper Object mapper to use. + * @param mapper Object mapper to use. * @param contentType Content type. */ public Jackson3Module(@NonNull ObjectMapper mapper, @NonNull MediaType contentType) { @@ -101,7 +112,8 @@ public Jackson3Module(@NonNull ObjectMapper mapper) { } /** - * Creates a Jackson module using the default object mapper from {@link #create(JacksonModule...)}. + * Creates a Jackson module using the default object mapper from {@link + * #create(JacksonModule...)}. */ public Jackson3Module() { this(create()); @@ -134,6 +146,11 @@ public void install(@NonNull Jooby application) { application.errorCode(StreamReadException.class, StatusCode.BAD_REQUEST); application.onStarting(() -> onStarting(application, services, mapperType)); + + // 2. Branch off a specialized mapper JUST for Projections. + // .rebuild() copies the user's configuration, and we add our global MixIn + // strictly to this specialized instance. + projectionMapper = mapper.rebuild().addMixIn(Object.class, ProjectionMixIn.class).build(); } @SuppressWarnings({"rawtypes", "unchecked"}) @@ -154,6 +171,20 @@ private void onStarting(Jooby application, ServiceRegistry services, Class mappe public Output encode(@NonNull Context ctx, @NonNull Object value) { var factory = ctx.getOutputFactory(); ctx.setDefaultResponseType(mediaType); + if (value instanceof Projected projected) { + var p = projected.getProjection(); + + var writer = + writerCache.computeIfAbsent( + p.getType().getName() + p.toView(), + k -> { + // Build the filter and writer only once per unique projection string + var filters = + new SimpleFilterProvider().addFilter(FILTER_ID, new JacksonProjectionFilter(p)); + return projectionMapper.writer(filters); + }); + return factory.wrap(writer.writeValueAsBytes(projected.getValue())); + } // let jackson uses his own cache, so wrap the bytes return factory.wrap(mapper.writeValueAsBytes(value)); } @@ -189,4 +220,8 @@ public static ObjectMapper create(JacksonModule... modules) { return builder.build(); } + + /** Global MixIn to force Jackson to apply our filter to ALL outgoing objects. */ + @JsonFilter(FILTER_ID) + private interface ProjectionMixIn {} } diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/JacksonProjectionFilter.java b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/JacksonProjectionFilter.java new file mode 100644 index 0000000000..ce74da8e85 --- /dev/null +++ b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/JacksonProjectionFilter.java @@ -0,0 +1,97 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jackson3; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import io.jooby.Projection; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.TokenStreamContext; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ser.AnyGetterWriter; +import tools.jackson.databind.ser.PropertyWriter; +import tools.jackson.databind.ser.std.SimpleBeanPropertyFilter; + +/** A Jackson 3 property filter that enforces a Jooby Projection. */ +public class JacksonProjectionFilter extends SimpleBeanPropertyFilter { + + private final Projection projection; + + public JacksonProjectionFilter(Projection projection) { + this.projection = projection; + } + + @Override + public void serializeAsProperty( + Object pojo, JsonGenerator gen, SerializationContext provider, PropertyWriter writer) + throws Exception { + + // Bypass projection filtering for Map entries to match Avaje's behavior. + // We want to dump the entire Map payload without validating its dynamic keys against the static + // tree. + if (pojo instanceof java.util.Map) { + writer.serializeAsProperty(pojo, gen, provider); + return; + } + + if (include(writer, gen)) { + writer.serializeAsProperty(pojo, gen, provider); + } else if (!gen.canOmitProperties()) { + writer.serializeAsOmittedProperty(pojo, gen, provider); + } else if (writer instanceof AnyGetterWriter) { + // Support for @JsonAnyGetter maps + ((AnyGetterWriter) writer).getAndFilter(pojo, gen, provider, this); + } + } + + // Custom include method that takes JsonGenerator so we can access the context + private boolean include(PropertyWriter writer, JsonGenerator gen) { + if (projection == null || projection.getChildren().isEmpty()) { + return true; // No projection applied, serialize everything + } + + String propName = writer.getName(); + TokenStreamContext context = gen.streamWriteContext(); + + // 1. Build the current path from Jackson's TokenStreamContext + List path = new ArrayList<>(); + path.add(propName); + + // Walk up the context tree to build the full property path. + // We skip ARRAY contexts because projections don't care about array indexes. + TokenStreamContext parent = context.getParent(); + while (parent != null && !parent.inRoot()) { + if (parent.currentName() != null) { + path.add(parent.currentName()); + } + parent = parent.getParent(); + } + + // Context gives us leaf-to-root, so we reverse it for root-to-leaf traversal + Collections.reverse(path); + + // 2. Traverse our Projection tree + Projection currentNode = projection; + + for (String pathSegment : path) { + // If the node has no children defined, it acts as a "deep wildcard" + if (currentNode.getChildren().isEmpty()) { + return true; + } + + currentNode = currentNode.getChildren().get(pathSegment); + + // If the path segment isn't found in the projection tree, block it + if (currentNode == null) { + return false; + } + } + + return true; + } +} diff --git a/tests/pom.xml b/tests/pom.xml index fc3db481e5..0d390d96a2 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -317,6 +317,11 @@ avaje-validator-generator ${avaje.validator.version} + + io.avaje + avaje-jsonb-generator + ${avaje.jsonb.version} + org.openjdk.jmh jmh-generator-annprocess diff --git a/tests/src/test/java/io/jooby/i3853/Issue3853.java b/tests/src/test/java/io/jooby/i3853/Issue3853.java new file mode 100644 index 0000000000..50fe90abb6 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3853/Issue3853.java @@ -0,0 +1,278 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3853; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.avaje.jsonb.Json; +import io.jooby.Extension; +import io.jooby.Projected; +import io.jooby.Projection; +import io.jooby.avaje.jsonb.AvajeJsonbModule; +import io.jooby.jackson.JacksonModule; +import io.jooby.jackson3.Jackson3Module; +import io.jooby.junit.ServerTest; +import io.jooby.junit.ServerTestRunner; + +public class Issue3853 { + + Projection STUB = Projection.of(User.class).include("(id, name)"); + + @ServerTest + public void shouldProjectJackson2Data(ServerTestRunner runner) { + shouldProjectData(runner, new JacksonModule()); + } + + @ServerTest + public void shouldProjectJackson3Data(ServerTestRunner runner) { + shouldProjectData(runner, new Jackson3Module()); + } + + @ServerTest + public void shouldProjectAvajeData(ServerTestRunner runner) { + shouldProjectData(runner, new AvajeJsonbModule()); + } + + public void shouldProjectData(ServerTestRunner runner, Extension extension) { + runner + .define( + app -> { + app.install(extension); + + app.get( + "/stub", + ctx -> { + return Projected.wrap(createUser(), STUB); + }); + app.get( + "/stub/meta", + ctx -> { + return Projected.wrap(createUser()).include("(id, meta(target))"); + }); + app.get( + "/stub/roles", + ctx -> { + return Projected.wrap(createUser()).include("(id, roles(name))"); + }); + app.get( + "/stub/address", + ctx -> { + return Projected.wrap(createUser()).include("(id, name, address(*))"); + }); + app.get( + "/stub/address-stub", + ctx -> { + return Projected.wrap(createUser()).include("(id, name, address(city))"); + }); + app.get( + "/stub/address-loc-lat", + ctx -> { + return Projected.wrap(createUser()).include("(id, name, address(loc(lat)))"); + }); + app.get( + "/stub/address-stub-ref", + ctx -> { + return Projected.wrap(createUser()) + .include(User::getId, User::getName) + .include(User::getAddress, addr -> addr.include(Address::getCity)); + }); + }) + .ready( + http -> { + http.get( + "/stub/meta", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","meta":{"target":"Robert Fischer","objective":"Inception","status":"Synchronizing Kicks"}} + """); + }); + http.get( + "/stub", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb"} + """); + }); + http.get( + "/stub/address", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb","address":{"city":"Snow Fortress (Level 3)","loc":{"lat":80.0,"lon":-20.0}}} + """); + }); + http.get( + "/stub/address-stub", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb","address":{"city":"Snow Fortress (Level 3)"}} + """); + }); + http.get( + "/stub/address-stub-ref", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb","address":{"city":"Snow Fortress (Level 3)"}} + """); + }); + http.get( + "/stub/address-loc-lat", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb","address":{"loc":{"lat":80.0}}} + """); + }); + http.get( + "/stub/roles", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","roles":[{"name":"The Extractor"},{"name":"The Architect"},{"name":"The Point Man"},{"name":"The Forger"}]} + """); + }); + }); + } + + @ServerTest + public void jackson2ShouldNotThrowInvalidDefinitionException(ServerTestRunner runner) { + jacksonShouldNotThrowInvalidDefinitionException(runner, new JacksonModule()); + } + + @ServerTest + public void jackson3ShouldNotThrowInvalidDefinitionException(ServerTestRunner runner) { + jacksonShouldNotThrowInvalidDefinitionException(runner, new Jackson3Module()); + } + + public void jacksonShouldNotThrowInvalidDefinitionException( + ServerTestRunner runner, Extension extension) { + runner + .define( + app -> { + app.install(extension); + app.get( + "/user", + ctx -> { + return createUser(); + }); + }) + .ready( + http -> { + http.get( + "/user", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb","address":{"city":"Snow Fortress (Level 3)","loc":{"lat":80.0,"lon":-20.0}},"roles":[{"name":"The Extractor","level":10},{"name":"The Architect","level":9},{"name":"The Point Man","level":8},{"name":"The Forger","level":8}],"meta":{"target":"Robert Fischer","objective":"Inception","status":"Synchronizing Kicks"}} + """); + }); + }); + } + + @Json + public static class User { + private final String id; + private final String name; + private final Address address; + private final List roles; + private final Map meta; + + public User( + String id, String name, Address address, List roles, Map meta) { + this.id = id; + this.name = name; + this.address = address; + this.roles = roles; + this.meta = meta; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public Address getAddress() { + return address; + } + + public List getRoles() { + return roles; + } + + public Map getMeta() { + return meta; + } + } + + @Json + public static class Address { + private final String city; + private final Location loc; + + public Address(String city, Location loc) { + this.city = city; + this.loc = loc; + } + + public String getCity() { + return city; + } + + public Location getLoc() { + return loc; + } + } + + @Json + public record Role(String name, int level) {} + + @Json + public record Location(double lat, double lon) {} + + public static User createUser() { + // Nested Location: The Fortress in the Snow (Level 3) + Location fortress = new Location(80.0, -20.0); + + // Address: Represents the "Dream Layer" + Address dreamLayer = new Address("Snow Fortress (Level 3)", fortress); + + // Roles: The Extraction Team + List roles = + List.of( + new Role("The Extractor", 10), + new Role("The Architect", 9), + new Role("The Point Man", 8), + new Role("The Forger", 8)); + + // Metadata: Mission specs + Map meta = new LinkedHashMap<>(); + meta.put("target", "Robert Fischer"); + meta.put("objective", "Inception"); + meta.put("status", "Synchronizing Kicks"); + + // Root User: Dom Cobb + return new User("cobb-001", "Dom Cobb", dreamLayer, roles, meta); + } +} From 94782057bf6401aba0edd4a036ac2cf1bc90dd09 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 26 Feb 2026 15:43:58 -0300 Subject: [PATCH 2/3] - implement `@Project` annotation on MVC ref #3853 --- jooby/src/main/java/io/jooby/Projected.java | 13 ++++- .../test/java/io/jooby/ProjectionTest.java | 18 ++++++ .../java/io/jooby/internal/apt/MvcRoute.java | 37 ++++++++++-- .../io/jooby/internal/apt/TypeDefinition.java | 10 ++++ .../java/io/jooby/internal/apt/Types.java | 2 +- .../src/test/java/tests/i3853/C3853.java | 42 ++++++++++++++ .../src/test/java/tests/i3853/Issue3853.java | 45 ++++++++++++++ .../{i3854/U3854.java => i3853/U3853.java} | 4 +- .../src/test/java/tests/i3854/C3854.java | 20 ------- .../src/test/java/tests/i3854/Issue3854.java | 21 ------- .../jooby/avaje/jsonb/AvajeJsonbModule.java | 25 +++++++- .../test/java/io/jooby/i3853/Issue3853.java | 58 ++++++++++++++++++- 12 files changed, 239 insertions(+), 56 deletions(-) create mode 100644 modules/jooby-apt/src/test/java/tests/i3853/C3853.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3853/Issue3853.java rename modules/jooby-apt/src/test/java/tests/{i3854/U3854.java => i3853/U3853.java} (62%) delete mode 100644 modules/jooby-apt/src/test/java/tests/i3854/C3854.java delete mode 100644 modules/jooby-apt/src/test/java/tests/i3854/Issue3854.java diff --git a/jooby/src/main/java/io/jooby/Projected.java b/jooby/src/main/java/io/jooby/Projected.java index df14577fa9..a230bdcd74 100644 --- a/jooby/src/main/java/io/jooby/Projected.java +++ b/jooby/src/main/java/io/jooby/Projected.java @@ -5,6 +5,7 @@ */ package io.jooby; +import java.util.*; import java.util.function.Consumer; /** @@ -25,7 +26,17 @@ private Projected(T value, Projection projection) { @SuppressWarnings("unchecked") public static Projected wrap(T value) { - return new Projected<>(value, Projection.of((Class) value.getClass())); + return new Projected(value, Projection.of(computeProjectionType(value))); + } + + @SuppressWarnings("rawtypes") + private static Class computeProjectionType(Object value) { + return switch (value) { + case Set col -> col.isEmpty() ? Object.class : col.iterator().next().getClass(); + case Collection col -> col.isEmpty() ? Object.class : col.iterator().next().getClass(); + case Optional optional -> optional.isEmpty() ? Object.class : optional.get().getClass(); + default -> value.getClass(); + }; } public static Projected wrap(T value, Projection projection) { diff --git a/jooby/src/test/java/io/jooby/ProjectionTest.java b/jooby/src/test/java/io/jooby/ProjectionTest.java index a5f816dcd6..2bc16d310b 100644 --- a/jooby/src/test/java/io/jooby/ProjectionTest.java +++ b/jooby/src/test/java/io/jooby/ProjectionTest.java @@ -316,4 +316,22 @@ public void testValidateToggle() { assertTrue(p.getChildren().containsKey("extraPolymorphicField")); assertTrue(p.getChildren().get("address").getChildren().containsKey("zipcode")); } + + @Test + public void testTopLevelListProjection() { + // If your route returns a List, the root projection type is just User.class. + // The JSON engines (Jackson/Avaje) will naturally apply this User projection + // to every element in the JSON array. + Projection projection = Projection.of(User.class).include("id, email"); + + // Assert: Avaje view string + assertEquals("(id,email)", projection.toView()); + + // Assert: Tree Structure validates against User + assertEquals(User.class, projection.getType()); + assertEquals(2, projection.getChildren().size()); + assertTrue(projection.getChildren().containsKey("id")); + assertTrue(projection.getChildren().containsKey("email")); + assertFalse(projection.getChildren().containsKey("name")); + } } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java index 939794e3f1..7b53401600 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java @@ -65,13 +65,23 @@ public MvcContext getContext() { return context; } + public String getProjection() { + var project = AnnotationSupport.findAnnotationByName(method, Types.PROJECT); + if (project != null) { + return AnnotationSupport.findAnnotationValue(project, VALUE).stream().findFirst().orElse(""); + } + return null; + } + public TypeDefinition getReturnType() { var processingEnv = context.getProcessingEnvironment(); var types = processingEnv.getTypeUtils(); var elements = processingEnv.getElementUtils(); - var project = AnnotationSupport.findAnnotationByName(method, Types.PROJECT); - if (project != null) { - return new TypeDefinition(types, elements.getTypeElement(Types.PROJECTED).asType()); + var isProjection = + !returnType.is(Types.PROJECTED) + && AnnotationSupport.findAnnotationByName(method, Types.PROJECT) != null; + if (isProjection) { + return new TypeDefinition(types, elements.getTypeElement(Types.PROJECTED).asType(), true); } else if (returnType.isVoid()) { return new TypeDefinition(types, elements.getTypeElement("io.jooby.StatusCode").asType()); } else if (isSuspendFun()) { @@ -223,6 +233,12 @@ public List generateHandlerCall(boolean kt) { var returnTypeGenerics = getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); var returnTypeString = type(kt, getReturnType().toString()); + var customReturnType = getReturnType(); + if (customReturnType.isProjection()) { + // Override for projection + returnTypeGenerics = ""; + returnTypeString = Types.PROJECTED + "<" + returnType + ">"; + } boolean nullable = false; if (kt) { @@ -321,7 +337,10 @@ public List generateHandlerCall(boolean kt) { buffer.add(statement(indent(2), "return statusCode", semicolon(kt))); } else { controllerVar(kt, buffer); - var cast = getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); + var cast = + customReturnType.isProjection() + ? "" + : customReturnType.getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); var kotlinNotEnoughTypeInformation = !cast.isEmpty() && kt ? "" : ""; var call = of( @@ -333,7 +352,15 @@ public List generateHandlerCall(boolean kt) { setUncheckedCast(true); call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; } - buffer.add(statement(indent(2), "return ", call, kt && nullable ? "!!" : "", semicolon(kt))); + if (customReturnType.isProjection()) { + var projected = + of(Types.PROJECTED, ".wrap(", call, ").include(", string(getProjection()), ")"); + buffer.add( + statement(indent(2), "return ", projected, kt && nullable ? "!!" : "", semicolon(kt))); + } else { + buffer.add( + statement(indent(2), "return ", call, kt && nullable ? "!!" : "", semicolon(kt))); + } } buffer.add(statement("}", System.lineSeparator())); if (uncheckedCast) { diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TypeDefinition.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TypeDefinition.java index 7213a0df5a..089adc4bc2 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TypeDefinition.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TypeDefinition.java @@ -22,12 +22,22 @@ public class TypeDefinition { private final TypeMirror type; private final TypeMirror unwrapType; private final TypeMirror rawType; + private final boolean projection; public TypeDefinition(Types types, TypeMirror type) { + this(types, type, false); + } + + public TypeDefinition(Types types, TypeMirror type, boolean projection) { this.typeUtils = types; this.type = type; this.unwrapType = unwrapType(type); this.rawType = typeUtils.erasure(unwrapType); + this.projection = projection; + } + + public boolean isProjection() { + return projection; } public String toSourceCode(boolean kt) { diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/Types.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/Types.java index cf8b603976..a0b41a10dd 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/Types.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/Types.java @@ -15,7 +15,7 @@ class Types { static final String PROJECT = "io.jooby.annotation.Project"; - static final String PROJECTED = "io.jooby.annotation.Projected"; + static final String PROJECTED = "io.jooby.Projected"; static final Set BUILT_IN = Set.of( String.class.getName(), diff --git a/modules/jooby-apt/src/test/java/tests/i3853/C3853.java b/modules/jooby-apt/src/test/java/tests/i3853/C3853.java new file mode 100644 index 0000000000..91ff160d94 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3853/C3853.java @@ -0,0 +1,42 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3853; + +import java.util.List; +import java.util.Optional; + +import io.jooby.Projected; +import io.jooby.annotation.GET; +import io.jooby.annotation.Path; +import io.jooby.annotation.Project; + +@Path("/3854") +public class C3853 { + @GET("/stub") + @Project("(id, name)") + public U3853 projectUser() { + return new U3853(1, "Projected User", "Projected", "User"); + } + + @GET("/optinal") + @Project("(id, name)") + public Optional findUser() { + return Optional.of(new U3853(1, "Projected User", "Projected", "User")); + } + + @GET("/list") + @Project("(id, name)") + public List findUsers() { + return List.of(new U3853(1, "Projected User", "Projected", "User")); + } + + @GET("/list") + @Project("(id, name)") + public Projected projected() { + return Projected.wrap(new U3853(1, "Projected User", "Projected", "User")) + .include("(id, name)"); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3853/Issue3853.java b/modules/jooby-apt/src/test/java/tests/i3853/Issue3853.java new file mode 100644 index 0000000000..9ed33d7293 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3853/Issue3853.java @@ -0,0 +1,45 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3853; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import io.jooby.apt.ProcessorRunner; + +public class Issue3853 { + @Test + public void shouldSupportNameAttribute() throws Exception { + new ProcessorRunner(new C3853()) + .withSourceCode( + source -> { + Assertions.assertTrue( + source.contains( + "return io.jooby.Projected.wrap(c.projectUser()).include(\"(id, name)\");")); + Assertions.assertTrue( + source.contains( + "return io.jooby.Projected.wrap(c.findUser()).include(\"(id, name)\");")); + Assertions.assertTrue( + source.contains( + "return io.jooby.Projected.wrap(c.findUsers()).include(\"(id, name)\");")); + Assertions.assertTrue(source.contains("return c.projected();")); + }) + .withSourceCode( + true, + source -> { + Assertions.assertTrue( + source.contains( + "return io.jooby.Projected.wrap(c.projectUser()).include(\"(id, name)\")")); + Assertions.assertTrue( + source.contains( + "return io.jooby.Projected.wrap(c.findUser()).include(\"(id, name)\")")); + Assertions.assertTrue( + source.contains( + "return io.jooby.Projected.wrap(c.findUsers()).include(\"(id, name)\")")); + Assertions.assertTrue(source.contains("return c.projected()")); + }); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3854/U3854.java b/modules/jooby-apt/src/test/java/tests/i3853/U3853.java similarity index 62% rename from modules/jooby-apt/src/test/java/tests/i3854/U3854.java rename to modules/jooby-apt/src/test/java/tests/i3853/U3853.java index 6d0bcdc701..d143fc39b4 100644 --- a/modules/jooby-apt/src/test/java/tests/i3854/U3854.java +++ b/modules/jooby-apt/src/test/java/tests/i3853/U3853.java @@ -3,6 +3,6 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package tests.i3854; +package tests.i3853; -public record U3854(long id, String name, String firstName, String lastName) {} +public record U3853(long id, String name, String firstName, String lastName) {} diff --git a/modules/jooby-apt/src/test/java/tests/i3854/C3854.java b/modules/jooby-apt/src/test/java/tests/i3854/C3854.java deleted file mode 100644 index c657cc3c96..0000000000 --- a/modules/jooby-apt/src/test/java/tests/i3854/C3854.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package tests.i3854; - -import io.jooby.annotation.GET; -import io.jooby.annotation.Path; -import io.jooby.annotation.Project; -import tests.i3804.Base3804; - -@Path("/3854") -public class C3854 extends Base3804 { - @GET() - @Project("(id, name)") - public U3854 projectUser() { - return new U3854(1, "Projected User", "Projected", "User"); - } -} diff --git a/modules/jooby-apt/src/test/java/tests/i3854/Issue3854.java b/modules/jooby-apt/src/test/java/tests/i3854/Issue3854.java deleted file mode 100644 index 01b154edae..0000000000 --- a/modules/jooby-apt/src/test/java/tests/i3854/Issue3854.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package tests.i3854; - -import org.junit.jupiter.api.Test; - -import io.jooby.apt.ProcessorRunner; - -public class Issue3854 { - @Test - public void shouldSupportNameAttribute() throws Exception { - new ProcessorRunner(new C3854()) - .withSourceCode( - source -> { - System.out.println(source); - }); - } -} diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java index 0a1ab457a3..9fc7ebad4a 100644 --- a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java @@ -7,6 +7,7 @@ import java.io.InputStream; import java.lang.reflect.Type; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -128,8 +129,26 @@ private void encodeProjection(JsonWriter writer, Projected projected) { var view = (JsonView) viewCache.computeIfAbsent( - type.getName() + viewString, k -> jsonb.type(type).view(viewString)); - - view.toJson(value, writer); + value.getClass().getName() + viewString, + k -> { + var jsonbType = jsonb.type(type); + jsonbType = + switch (value) { + case Set ignored -> jsonbType.set(); + case Collection ignored -> jsonbType.list(); + default -> jsonbType; + }; + return jsonbType.view(viewString); + }); + if (value instanceof Optional optional) { + if (optional.isEmpty()) { + writer.serializeNulls(true); + writer.nullValue(); + } else { + view.toJson(optional.get(), writer); + } + } else { + view.toJson(value, writer); + } } } diff --git a/tests/src/test/java/io/jooby/i3853/Issue3853.java b/tests/src/test/java/io/jooby/i3853/Issue3853.java index 50fe90abb6..89675bfc26 100644 --- a/tests/src/test/java/io/jooby/i3853/Issue3853.java +++ b/tests/src/test/java/io/jooby/i3853/Issue3853.java @@ -7,9 +7,7 @@ import static org.assertj.core.api.Assertions.assertThat; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import io.avaje.jsonb.Json; import io.jooby.Extension; @@ -51,6 +49,28 @@ public void shouldProjectData(ServerTestRunner runner, Extension extension) { ctx -> { return Projected.wrap(createUser(), STUB); }); + + app.get( + "/stub-list", + ctx -> { + return Projected.wrap(List.of(createUser())).include("(id, name)"); + }); + + app.get( + "/stub-set", + ctx -> { + return Projected.wrap(Set.of(createUser())).include("(id, name)"); + }); + app.get( + "/stub-optional", + ctx -> { + return Projected.wrap(Optional.of(createUser())).include("(id, name)"); + }); + app.get( + "/stub-optional-null", + ctx -> { + return Projected.wrap(Optional.empty()).include("(id, name)"); + }); app.get( "/stub/meta", ctx -> { @@ -104,6 +124,38 @@ public void shouldProjectData(ServerTestRunner runner, Extension extension) { {"id":"cobb-001","name":"Dom Cobb"} """); }); + http.get( + "/stub-list", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + [{"id":"cobb-001","name":"Dom Cobb"}] + """); + }); + http.get( + "/stub-set", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + [{"id":"cobb-001","name":"Dom Cobb"}] + """); + }); + http.get( + "/stub-optional", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb"} + """); + }); + http.get( + "/stub-optional-null", + rsp -> { + assertThat(rsp.body().string()).isEqualTo("null"); + }); http.get( "/stub/address", rsp -> { From bf21582ffe3c482326ed528927e246ed5d2c911e Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 26 Feb 2026 18:52:19 -0300 Subject: [PATCH 3/3] - implement `projection` shortcut for MVC - add new test --- docs/asciidoc/context.adoc | 4 +- .../main/java/io/jooby/annotation/DELETE.java | 10 ++ .../main/java/io/jooby/annotation/GET.java | 10 ++ .../main/java/io/jooby/annotation/PATCH.java | 10 ++ .../main/java/io/jooby/annotation/POST.java | 10 ++ .../main/java/io/jooby/annotation/PUT.java | 10 ++ .../java/io/jooby/annotation/Project.java | 15 ++- modules/jooby-apt/pom.xml | 6 + .../java/io/jooby/internal/apt/MvcRoute.java | 28 ++++- .../src/test/java/tests/i3853/C3853.java | 9 +- .../src/test/java/tests/i3853/Issue3853.java | 10 +- .../jooby/avaje/jsonb/AvajeJsonbModule.java | 44 +++---- tests/src/test/java/io/jooby/i3853/A3853.java | 27 +++++ tests/src/test/java/io/jooby/i3853/C3853.java | 45 +++++++ .../test/java/io/jooby/i3853/Issue3853.java | 111 +++--------------- .../java/io/jooby/i3853/Issue3853Mvc.java | 91 ++++++++++++++ tests/src/test/java/io/jooby/i3853/L3853.java | 11 ++ tests/src/test/java/io/jooby/i3853/R3853.java | 11 ++ tests/src/test/java/io/jooby/i3853/U3853.java | 74 ++++++++++++ 19 files changed, 402 insertions(+), 134 deletions(-) create mode 100644 tests/src/test/java/io/jooby/i3853/A3853.java create mode 100644 tests/src/test/java/io/jooby/i3853/C3853.java create mode 100644 tests/src/test/java/io/jooby/i3853/Issue3853Mvc.java create mode 100644 tests/src/test/java/io/jooby/i3853/L3853.java create mode 100644 tests/src/test/java/io/jooby/i3853/R3853.java create mode 100644 tests/src/test/java/io/jooby/i3853/U3853.java diff --git a/docs/asciidoc/context.adoc b/docs/asciidoc/context.adoc index 47f5921248..abf769983c 100644 --- a/docs/asciidoc/context.adoc +++ b/docs/asciidoc/context.adoc @@ -406,7 +406,7 @@ curl -d "id=root&pass=pwd" -X POST http://localhost:8080/user <2> Form as `multi-value map` => `{id=root, pass=[pwd]}` <3> Form variable `id` => `root` <4> Form variable `pass` => `pwd` -<5> Form as `User` object => `User(id=root, pass=pwd)` +<5> Form as `U3853` object => `User(id=root, pass=pwd)` ==== Multipart @@ -482,7 +482,7 @@ curl -F id=root -F pass=root -F pic=@/path/to/local/file/profile.png http://loca <3> Form variable `id` => `root` <4> Form variable `pass` => `pwd` <5> javadoc:FileUpload[] variable `pic` -<6> Form as `User` object => `User(id=root, pass=pwd, pic=profile.png)` +<6> Form as `U3853` object => `User(id=root, pass=pwd, pic=profile.png)` [NOTE] .File Upload diff --git a/jooby/src/main/java/io/jooby/annotation/DELETE.java b/jooby/src/main/java/io/jooby/annotation/DELETE.java index 4eefcf3fd0..720324407a 100644 --- a/jooby/src/main/java/io/jooby/annotation/DELETE.java +++ b/jooby/src/main/java/io/jooby/annotation/DELETE.java @@ -57,4 +57,14 @@ * @return Consume types. */ String[] consumes() default {}; + + /** + * Field paths to include. Supports dot-notation and avaje-notation. It is a shortcut for {@link + * Project}. + * + *

Example: id, name, address(city, zip) + * + * @return Field paths to include. Supports dot-notation and avaje-notation. + */ + String projection() default ""; } diff --git a/jooby/src/main/java/io/jooby/annotation/GET.java b/jooby/src/main/java/io/jooby/annotation/GET.java index 9d3a93e0c9..a32b1f44e0 100644 --- a/jooby/src/main/java/io/jooby/annotation/GET.java +++ b/jooby/src/main/java/io/jooby/annotation/GET.java @@ -57,4 +57,14 @@ * @return Consume types. */ String[] consumes() default {}; + + /** + * Field paths to include. Supports dot-notation and avaje-notation. It is a shortcut for {@link + * Project}. + * + *

Example: id, name, address(city, zip) + * + * @return Field paths to include. Supports dot-notation and avaje-notation. + */ + String projection() default ""; } diff --git a/jooby/src/main/java/io/jooby/annotation/PATCH.java b/jooby/src/main/java/io/jooby/annotation/PATCH.java index 96e8ffe158..5ce1fd7311 100644 --- a/jooby/src/main/java/io/jooby/annotation/PATCH.java +++ b/jooby/src/main/java/io/jooby/annotation/PATCH.java @@ -57,4 +57,14 @@ * @return Consume types. */ String[] consumes() default {}; + + /** + * Field paths to include. Supports dot-notation and avaje-notation. It is a shortcut for {@link + * Project}. + * + *

Example: id, name, address(city, zip) + * + * @return Field paths to include. Supports dot-notation and avaje-notation. + */ + String projection() default ""; } diff --git a/jooby/src/main/java/io/jooby/annotation/POST.java b/jooby/src/main/java/io/jooby/annotation/POST.java index b7a3e6e0d4..3e1fb597f3 100644 --- a/jooby/src/main/java/io/jooby/annotation/POST.java +++ b/jooby/src/main/java/io/jooby/annotation/POST.java @@ -57,4 +57,14 @@ * @return Consume types. */ String[] consumes() default {}; + + /** + * Field paths to include. Supports dot-notation and avaje-notation. It is a shortcut for {@link + * Project}. + * + *

Example: id, name, address(city, zip) + * + * @return Field paths to include. Supports dot-notation and avaje-notation. + */ + String projection() default ""; } diff --git a/jooby/src/main/java/io/jooby/annotation/PUT.java b/jooby/src/main/java/io/jooby/annotation/PUT.java index e4d48d747c..61e455392d 100644 --- a/jooby/src/main/java/io/jooby/annotation/PUT.java +++ b/jooby/src/main/java/io/jooby/annotation/PUT.java @@ -57,4 +57,14 @@ * @return Consume types. */ String[] consumes() default {}; + + /** + * Field paths to include. Supports dot-notation and avaje-notation. It is a shortcut for {@link + * Project}. + * + *

Example: id, name, address(city, zip) + * + * @return Field paths to include. Supports dot-notation and avaje-notation. + */ + String projection() default ""; } diff --git a/jooby/src/main/java/io/jooby/annotation/Project.java b/jooby/src/main/java/io/jooby/annotation/Project.java index 84791444e1..730c9d3dd0 100644 --- a/jooby/src/main/java/io/jooby/annotation/Project.java +++ b/jooby/src/main/java/io/jooby/annotation/Project.java @@ -32,6 +32,15 @@ * } * } * + * Or + * + *

{@code
+ * @GET(projection = "id, name, address(city, zip)")
+ * public User getUser() {
+ * return userService.find(1);
+ * }
+ * }
+ * * @author edgar * @since 4.0.0 */ @@ -41,9 +50,9 @@ @Documented public @interface Project { /** - * Field paths to include. Supports dot-notation and avaje-notation. + * Example: {@code "id, name, address(city, zip)"} * - * @return The array of field paths. + * @return Avaje notation. */ - String[] value() default {}; + String value() default ""; } diff --git a/modules/jooby-apt/pom.xml b/modules/jooby-apt/pom.xml index cb94b192dd..454298979c 100644 --- a/modules/jooby-apt/pom.xml +++ b/modules/jooby-apt/pom.xml @@ -113,6 +113,12 @@ swagger-annotations test + + + org.assertj + assertj-core + test + diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java index 7b53401600..b50423de28 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java @@ -68,19 +68,35 @@ public MvcContext getContext() { public String getProjection() { var project = AnnotationSupport.findAnnotationByName(method, Types.PROJECT); if (project != null) { - return AnnotationSupport.findAnnotationValue(project, VALUE).stream().findFirst().orElse(""); + return AnnotationSupport.findAnnotationValue(project, VALUE).stream() + .findFirst() + .orElse(null); } - return null; + // look inside the method annotation + var httpMethod = annotationMap.values().iterator().next(); + var projection = AnnotationSupport.findAnnotationValue(httpMethod, "projection"::equals); + return projection.stream().findFirst().orElse(null); + } + + public boolean isProjection() { + if (returnType.is(Types.PROJECTED)) { + return false; + } + var isProjection = AnnotationSupport.findAnnotationByName(method, Types.PROJECT) != null; + if (isProjection) { + return true; + } + // look inside the method annotation + var httpMethod = annotationMap.values().iterator().next(); + var projection = AnnotationSupport.findAnnotationValue(httpMethod, "projection"::equals); + return !projection.isEmpty(); } public TypeDefinition getReturnType() { var processingEnv = context.getProcessingEnvironment(); var types = processingEnv.getTypeUtils(); var elements = processingEnv.getElementUtils(); - var isProjection = - !returnType.is(Types.PROJECTED) - && AnnotationSupport.findAnnotationByName(method, Types.PROJECT) != null; - if (isProjection) { + if (isProjection()) { return new TypeDefinition(types, elements.getTypeElement(Types.PROJECTED).asType(), true); } else if (returnType.isVoid()) { return new TypeDefinition(types, elements.getTypeElement("io.jooby.StatusCode").asType()); diff --git a/modules/jooby-apt/src/test/java/tests/i3853/C3853.java b/modules/jooby-apt/src/test/java/tests/i3853/C3853.java index 91ff160d94..51ecf302c8 100644 --- a/modules/jooby-apt/src/test/java/tests/i3853/C3853.java +++ b/modules/jooby-apt/src/test/java/tests/i3853/C3853.java @@ -15,8 +15,7 @@ @Path("/3854") public class C3853 { - @GET("/stub") - @Project("(id, name)") + @GET(value = "/stub", projection = "(id, name)") public U3853 projectUser() { return new U3853(1, "Projected User", "Projected", "User"); } @@ -39,4 +38,10 @@ public Projected projected() { return Projected.wrap(new U3853(1, "Projected User", "Projected", "User")) .include("(id, name)"); } + + @GET(value = "/list", projection = "(id, name)") + public Projected projectedProjection() { + return Projected.wrap(new U3853(1, "Projected User", "Projected", "User")) + .include("(id, name)"); + } } diff --git a/modules/jooby-apt/src/test/java/tests/i3853/Issue3853.java b/modules/jooby-apt/src/test/java/tests/i3853/Issue3853.java index 9ed33d7293..b280bdd0ae 100644 --- a/modules/jooby-apt/src/test/java/tests/i3853/Issue3853.java +++ b/modules/jooby-apt/src/test/java/tests/i3853/Issue3853.java @@ -5,6 +5,8 @@ */ package tests.i3853; +import static org.assertj.core.api.Assertions.assertThat; + import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -16,9 +18,9 @@ public void shouldSupportNameAttribute() throws Exception { new ProcessorRunner(new C3853()) .withSourceCode( source -> { - Assertions.assertTrue( - source.contains( - "return io.jooby.Projected.wrap(c.projectUser()).include(\"(id, name)\");")); + assertThat(source) + .contains( + "return io.jooby.Projected.wrap(c.projectUser()).include(\"(id, name)\");"); Assertions.assertTrue( source.contains( "return io.jooby.Projected.wrap(c.findUser()).include(\"(id, name)\");")); @@ -26,6 +28,7 @@ public void shouldSupportNameAttribute() throws Exception { source.contains( "return io.jooby.Projected.wrap(c.findUsers()).include(\"(id, name)\");")); Assertions.assertTrue(source.contains("return c.projected();")); + Assertions.assertTrue(source.contains("return c.projectedProjection();")); }) .withSourceCode( true, @@ -40,6 +43,7 @@ public void shouldSupportNameAttribute() throws Exception { source.contains( "return io.jooby.Projected.wrap(c.findUsers()).include(\"(id, name)\")")); Assertions.assertTrue(source.contains("return c.projected()")); + Assertions.assertTrue(source.contains("return c.projectedProjection()")); }); } } diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java index 9fc7ebad4a..e5e10ef7ff 100644 --- a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java @@ -8,8 +8,6 @@ import java.io.InputStream; import java.lang.reflect.Type; import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; import edu.umd.cs.findbugs.annotations.NonNull; import io.avaje.json.JsonWriter; @@ -65,8 +63,6 @@ */ public class AvajeJsonbModule implements Extension, MessageDecoder, MessageEncoder { - private final ConcurrentMap> viewCache = new ConcurrentHashMap<>(); - private final Jsonb jsonb; /** @@ -121,34 +117,30 @@ public Output encode(@NonNull Context ctx, @NonNull Object value) { @SuppressWarnings("unchecked") private void encodeProjection(JsonWriter writer, Projected projected) { - // Generate the Avaje-compatible view string (e.g., "(id,name,address(city))") var value = projected.getValue(); - var projection = projected.getProjection(); - var viewString = projection.toView(); - var type = projection.getType(); - var view = - (JsonView) - viewCache.computeIfAbsent( - value.getClass().getName() + viewString, - k -> { - var jsonbType = jsonb.type(type); - jsonbType = - switch (value) { - case Set ignored -> jsonbType.set(); - case Collection ignored -> jsonbType.list(); - default -> jsonbType; - }; - return jsonbType.view(viewString); - }); if (value instanceof Optional optional) { if (optional.isEmpty()) { writer.serializeNulls(true); writer.nullValue(); - } else { - view.toJson(optional.get(), writer); + return; } - } else { - view.toJson(value, writer); + value = optional.get(); + } + if (value instanceof Collection collection && collection.isEmpty()) { + writer.emptyArray(); + return; } + var projection = projected.getProjection(); + var viewString = projection.toView(); + var type = projection.getType(); + var jsonbType = jsonb.type(type); + jsonbType = + switch (value) { + case Set ignored -> jsonbType.set(); + case Collection ignored -> jsonbType.list(); + default -> jsonbType; + }; + var view = (JsonView) jsonbType.view(viewString); + view.toJson(value, writer); } } diff --git a/tests/src/test/java/io/jooby/i3853/A3853.java b/tests/src/test/java/io/jooby/i3853/A3853.java new file mode 100644 index 0000000000..9fc02a142e --- /dev/null +++ b/tests/src/test/java/io/jooby/i3853/A3853.java @@ -0,0 +1,27 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3853; + +import io.avaje.jsonb.Json; + +@Json +public class A3853 { + private final String city; + private final L3853 loc; + + public A3853(String city, L3853 loc) { + this.city = city; + this.loc = loc; + } + + public String getCity() { + return city; + } + + public L3853 getLoc() { + return loc; + } +} diff --git a/tests/src/test/java/io/jooby/i3853/C3853.java b/tests/src/test/java/io/jooby/i3853/C3853.java new file mode 100644 index 0000000000..234f5f4381 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3853/C3853.java @@ -0,0 +1,45 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3853; + +import java.util.List; +import java.util.Optional; + +import io.jooby.Projected; +import io.jooby.annotation.GET; +import io.jooby.annotation.Path; +import io.jooby.annotation.Project; + +@Path("/3853") +public class C3853 { + @GET(value = "/stub", projection = "(id, name)") + public U3853 projectUser() { + return U3853.createUser(); + } + + @GET("/optional") + @Project("(id, name)") + public Optional findUser() { + return Optional.of(U3853.createUser()); + } + + @GET("/list") + @Project("(id)") + public List findUsers() { + return List.of(U3853.createUser()); + } + + @GET("/projected") + @Project("(id, name)") + public Projected projected() { + return Projected.wrap(U3853.createUser()).include("(id, name)"); + } + + @GET(value = "/projectedProjection", projection = "(id, name)") + public Projected projectedProjection() { + return Projected.wrap(U3853.createUser()).include("(id, name)"); + } +} diff --git a/tests/src/test/java/io/jooby/i3853/Issue3853.java b/tests/src/test/java/io/jooby/i3853/Issue3853.java index 89675bfc26..92d434a559 100644 --- a/tests/src/test/java/io/jooby/i3853/Issue3853.java +++ b/tests/src/test/java/io/jooby/i3853/Issue3853.java @@ -5,11 +5,11 @@ */ package io.jooby.i3853; +import static io.jooby.i3853.U3853.createUser; import static org.assertj.core.api.Assertions.assertThat; import java.util.*; -import io.avaje.jsonb.Json; import io.jooby.Extension; import io.jooby.Projected; import io.jooby.Projection; @@ -21,7 +21,7 @@ public class Issue3853 { - Projection STUB = Projection.of(User.class).include("(id, name)"); + Projection STUB = Projection.of(U3853.class).include("(id, name)"); @ServerTest public void shouldProjectJackson2Data(ServerTestRunner runner) { @@ -56,6 +56,12 @@ public void shouldProjectData(ServerTestRunner runner, Extension extension) { return Projected.wrap(List.of(createUser())).include("(id, name)"); }); + app.get( + "/stub-empty-list", + ctx -> { + return Projected.wrap(List.of()).include("(id, name)"); + }); + app.get( "/stub-set", ctx -> { @@ -100,8 +106,8 @@ public void shouldProjectData(ServerTestRunner runner, Extension extension) { "/stub/address-stub-ref", ctx -> { return Projected.wrap(createUser()) - .include(User::getId, User::getName) - .include(User::getAddress, addr -> addr.include(Address::getCity)); + .include(U3853::getId, U3853::getName) + .include(U3853::getAddress, addr -> addr.include(A3853::getCity)); }); }) .ready( @@ -151,6 +157,15 @@ public void shouldProjectData(ServerTestRunner runner, Extension extension) { {"id":"cobb-001","name":"Dom Cobb"} """); }); + http.get( + "/stub-empty-list", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + [] + """); + }); http.get( "/stub-optional-null", rsp -> { @@ -239,92 +254,4 @@ public void jacksonShouldNotThrowInvalidDefinitionException( }); }); } - - @Json - public static class User { - private final String id; - private final String name; - private final Address address; - private final List roles; - private final Map meta; - - public User( - String id, String name, Address address, List roles, Map meta) { - this.id = id; - this.name = name; - this.address = address; - this.roles = roles; - this.meta = meta; - } - - public String getId() { - return id; - } - - public String getName() { - return name; - } - - public Address getAddress() { - return address; - } - - public List getRoles() { - return roles; - } - - public Map getMeta() { - return meta; - } - } - - @Json - public static class Address { - private final String city; - private final Location loc; - - public Address(String city, Location loc) { - this.city = city; - this.loc = loc; - } - - public String getCity() { - return city; - } - - public Location getLoc() { - return loc; - } - } - - @Json - public record Role(String name, int level) {} - - @Json - public record Location(double lat, double lon) {} - - public static User createUser() { - // Nested Location: The Fortress in the Snow (Level 3) - Location fortress = new Location(80.0, -20.0); - - // Address: Represents the "Dream Layer" - Address dreamLayer = new Address("Snow Fortress (Level 3)", fortress); - - // Roles: The Extraction Team - List roles = - List.of( - new Role("The Extractor", 10), - new Role("The Architect", 9), - new Role("The Point Man", 8), - new Role("The Forger", 8)); - - // Metadata: Mission specs - Map meta = new LinkedHashMap<>(); - meta.put("target", "Robert Fischer"); - meta.put("objective", "Inception"); - meta.put("status", "Synchronizing Kicks"); - - // Root User: Dom Cobb - return new User("cobb-001", "Dom Cobb", dreamLayer, roles, meta); - } } diff --git a/tests/src/test/java/io/jooby/i3853/Issue3853Mvc.java b/tests/src/test/java/io/jooby/i3853/Issue3853Mvc.java new file mode 100644 index 0000000000..b563a088dd --- /dev/null +++ b/tests/src/test/java/io/jooby/i3853/Issue3853Mvc.java @@ -0,0 +1,91 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3853; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.jooby.Extension; +import io.jooby.avaje.jsonb.AvajeJsonbModule; +import io.jooby.jackson.JacksonModule; +import io.jooby.jackson3.Jackson3Module; +import io.jooby.junit.ServerTest; +import io.jooby.junit.ServerTestRunner; + +public class Issue3853Mvc { + + @ServerTest + public void shouldProjectJackson2Data(ServerTestRunner runner) { + shouldProjectData(runner, new JacksonModule()); + } + + @ServerTest + public void shouldProjectJackson3Data(ServerTestRunner runner) { + shouldProjectData(runner, new Jackson3Module()); + } + + @ServerTest + public void shouldProjectAvajeData(ServerTestRunner runner) { + shouldProjectData(runner, new AvajeJsonbModule()); + } + + public void shouldProjectData(ServerTestRunner runner, Extension extension) { + runner + .define( + app -> { + app.install(extension); + + app.mvc(new C3853_()); + }) + .ready( + http -> { + http.get( + "/3853/stub", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb"} + """); + }); + http.get( + "/3853/optional", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb"} + """); + }); + http.get( + "/3853/list", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + [{"id":"cobb-001"}] + """); + }); + http.get( + "/3853/projected", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb"} + """); + }); + http.get( + "/3853/projectedProjection", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb"} + """); + }); + }); + } +} diff --git a/tests/src/test/java/io/jooby/i3853/L3853.java b/tests/src/test/java/io/jooby/i3853/L3853.java new file mode 100644 index 0000000000..918c6b79f5 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3853/L3853.java @@ -0,0 +1,11 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3853; + +import io.avaje.jsonb.Json; + +@Json +public record L3853(double lat, double lon) {} diff --git a/tests/src/test/java/io/jooby/i3853/R3853.java b/tests/src/test/java/io/jooby/i3853/R3853.java new file mode 100644 index 0000000000..52fb11294b --- /dev/null +++ b/tests/src/test/java/io/jooby/i3853/R3853.java @@ -0,0 +1,11 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3853; + +import io.avaje.jsonb.Json; + +@Json +public record R3853(String name, int level) {} diff --git a/tests/src/test/java/io/jooby/i3853/U3853.java b/tests/src/test/java/io/jooby/i3853/U3853.java new file mode 100644 index 0000000000..45b66e1644 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3853/U3853.java @@ -0,0 +1,74 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3853; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.avaje.jsonb.Json; + +@Json +public class U3853 { + private final String id; + private final String name; + private final A3853 address; + private final List roles; + private final Map meta; + + public U3853(String id, String name, A3853 address, List roles, Map meta) { + this.id = id; + this.name = name; + this.address = address; + this.roles = roles; + this.meta = meta; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public A3853 getAddress() { + return address; + } + + public List getRoles() { + return roles; + } + + public Map getMeta() { + return meta; + } + + public static U3853 createUser() { + // Nested Location: The Fortress in the Snow (Level 3) + L3853 fortress = new L3853(80.0, -20.0); + + // Address: Represents the "Dream Layer" + A3853 dreamLayer = new A3853("Snow Fortress (Level 3)", fortress); + + // Roles: The Extraction Team + List roles = + List.of( + new R3853("The Extractor", 10), + new R3853("The Architect", 9), + new R3853("The Point Man", 8), + new R3853("The Forger", 8)); + + // Metadata: Mission specs + Map meta = new LinkedHashMap<>(); + meta.put("target", "Robert Fischer"); + meta.put("objective", "Inception"); + meta.put("status", "Synchronizing Kicks"); + + // Root User: Dom Cobb + return new U3853("cobb-001", "Dom Cobb", dreamLayer, roles, meta); + } +}