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..46555a644a --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/Project.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.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-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 af03d12d2c..739bd2a9b7 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); + } +}