From 59b39688e9533be602ce3ffa3eb8f2f9b3a26f94 Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Thu, 27 Nov 2025 23:34:39 -0300 Subject: [PATCH 1/4] feat: add JsonCodec encodeWithoutTypeInfo --- .../vaadin/jsonmigration/JsonCodec.java | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonCodec.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonCodec.java index 0decf7a..3e7dbec 100644 --- a/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonCodec.java +++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonCodec.java @@ -36,7 +36,10 @@ import com.vaadin.flow.component.Component; import com.vaadin.flow.dom.Element; +import com.vaadin.flow.dom.Node; import com.vaadin.flow.internal.ReflectTools; +import com.vaadin.flow.internal.nodefeature.ReturnChannelRegistration; +import elemental.json.Json; import elemental.json.JsonType; import elemental.json.JsonValue; @@ -58,7 +61,7 @@ *

* @author Vaadin Ltd */ -class JsonCodec { +public class JsonCodec { /** * Decodes the given JSON value as the given type. @@ -101,4 +104,52 @@ public static T decodeAs(JsonValue json, Class type) { } + /** + * Helper for checking whether the type is supported by + * {@link #encodeWithoutTypeInfo(Object)}. Supported value types are + * {@link String}, {@link Integer}, {@link Double}, {@link Boolean}, + * {@link JsonValue}. + * + * @param type + * the type to check + * @return whether the type can be encoded + */ + public static boolean canEncodeWithoutTypeInfo(Class type) { + assert type != null; + return String.class.equals(type) || Integer.class.equals(type) + || Double.class.equals(type) || Boolean.class.equals(type) + || JsonValue.class.isAssignableFrom(type); + } + + /** + * Helper for encoding any "primitive" value that is directly supported in + * JSON. Supported values types are {@link String}, {@link Number}, + * {@link Boolean}, {@link JsonValue}. null is also supported. + * + * @param value + * the value to encode + * @return the value encoded as JSON + */ + public static JsonValue encodeWithoutTypeInfo(Object value) { + if (value == null) { + return Json.createNull(); + } + + assert canEncodeWithoutTypeInfo(value.getClass()); + + Class type = value.getClass(); + if (String.class.equals(value.getClass())) { + return Json.create((String) value); + } else if (Integer.class.equals(type) || Double.class.equals(type)) { + return Json.create(((Number) value).doubleValue()); + } else if (Boolean.class.equals(type)) { + return Json.create(((Boolean) value).booleanValue()); + } else if (JsonValue.class.isAssignableFrom(type)) { + return (JsonValue) value; + } + assert !canEncodeWithoutTypeInfo(type); + throw new IllegalArgumentException( + "Can't encode " + value.getClass() + " to json"); + } + } From c7658a966ad5eeffa3df1dd638af7d376f20aee1 Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Fri, 28 Nov 2025 01:02:20 -0300 Subject: [PATCH 2/4] feat: add utility for class instrumentation --- pom.xml | 6 + .../ClassInstrumentationUtil.java | 375 ++++++++++++++++++ .../vaadin/jsonmigration/JsonMigration.java | 37 ++ .../jsonmigration/JsonMigrationHelper.java | 3 + .../jsonmigration/JsonMigrationHelper25.java | 6 + .../jsonmigration/LegacyClientCallable.java | 37 ++ .../LegacyJsonMigrationHelper.java | 6 + 7 files changed, 470 insertions(+) create mode 100644 src/main/java/com/flowingcode/vaadin/jsonmigration/ClassInstrumentationUtil.java create mode 100644 src/main/java/com/flowingcode/vaadin/jsonmigration/LegacyClientCallable.java diff --git a/pom.xml b/pom.xml index 06408b4..a2a2379 100644 --- a/pom.xml +++ b/pom.xml @@ -68,6 +68,12 @@ vaadin-core true + + org.ow2.asm + asm + 9.8 + true + tools.jackson.core jackson-databind diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/ClassInstrumentationUtil.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/ClassInstrumentationUtil.java new file mode 100644 index 0000000..30cddd5 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/ClassInstrumentationUtil.java @@ -0,0 +1,375 @@ +/*- + * #%L + * Json Migration Helper + * %% + * Copyright (C) 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.jsonmigration; + +import com.vaadin.flow.component.ClientCallable; +import com.vaadin.flow.component.Component; +import elemental.json.JsonArray; +import elemental.json.JsonBoolean; +import elemental.json.JsonNumber; +import elemental.json.JsonObject; +import elemental.json.JsonString; +import elemental.json.JsonValue; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; +import lombok.experimental.UtilityClass; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.BooleanNode; +import tools.jackson.databind.node.DoubleNode; +import tools.jackson.databind.node.ObjectNode; +import tools.jackson.databind.node.StringNode; + +/** + * Utility class for instrumenting classes at runtime. + * + *

+ * This class provides methods to dynamically create subclasses of a given parent class using + * bytecode instrumentation. Methods annotated with {@link ClientCallable} that return a type + * assignable to {@link JsonValue} are automatically overridden to convert the result through + * {@link JsonMigration#convertToClientCallableResult(JsonValue)}. + *

+ * + * @author Javier Godoy / Flowing Code + */ +@UtilityClass +final class ClassInstrumentationUtil { + + private static final Map classLoaderCache = + new WeakHashMap<>(); + + /** + * Creates and returns an instance of a dynamically instrumented class that extends the specified + * parent class. + * + *

+ * This method generates a new class at runtime that extends {@code parent}, and returns a new + * instance of that generated class. The instrumented class will have a default constructor that + * delegates to the parent's default constructor. All methods annotated with + * {@link ClientCallable} that return a type assignable to {@link JsonValue} will be overridden to + * convert the result via JsonMigration.convertClientCallableResult(). + *

+ * + *

+ * Requirements: + *

+ * + * + * @param the type of the parent class + * @param parent the parent class to extend + * @return a new instance of the instrumented class extending {@code parent} + * @throws IllegalArgumentException if the parent class is final, an interface, a primitive type, + * an array type, or does not have an accessible no-argument constructor + * @throws RuntimeException if the instrumentation or instantiation fails + */ + public Class instrumentClass(Class parent) { + // Validate input + if (parent == null) { + throw new IllegalArgumentException("Parent class cannot be null"); + } + + if (parent.isInterface()) { + throw new IllegalArgumentException("Cannot instrument an interface: " + parent.getName()); + } + + if (parent.isPrimitive()) { + throw new IllegalArgumentException("Cannot instrument a primitive type: " + parent.getName()); + } + + if (parent.isArray()) { + throw new IllegalArgumentException("Cannot instrument an array type: " + parent.getName()); + } + + if (Modifier.isFinal(parent.getModifiers())) { + throw new IllegalArgumentException("Cannot instrument a final class: " + parent.getName()); + } + + if (!needsInstrumentation(parent)) { + return parent; + } + + // Check for accessible no-arg constructor + try { + Constructor defaultConstructor = parent.getDeclaredConstructor(); + if (!Modifier.isPublic(defaultConstructor.getModifiers()) + && !Modifier.isProtected(defaultConstructor.getModifiers())) { + try { + defaultConstructor.setAccessible(true); + } catch (Exception e) { + throw new IllegalArgumentException( + "Parent class must have an accessible no-argument constructor: " + parent.getName(), + e); + } + } + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException( + "Parent class must have a no-argument constructor: " + parent.getName(), e); + } + + try { + String instrumentedClassName = parent.getName() + "$Instrumented"; + return createInstrumentedClass(parent, instrumentedClassName); + } catch (Exception e) { + throw new RuntimeException("Failed to instrument " + parent.getName(), e); + } + } + + private static boolean needsInstrumentation(Class parent) { + return !getInstrumentableMethods(parent).isEmpty(); + } + + private static List getInstrumentableMethods(Class parent) { + List methods = new ArrayList<>(); + for (Method method : parent.getDeclaredMethods()) { + if (!Modifier.isStatic(method.getModifiers()) && !Modifier.isPrivate(method.getModifiers())) { + boolean isCallable = method.isAnnotationPresent(ClientCallable.class); + boolean isLegacyCallable = method.isAnnotationPresent(LegacyClientCallable.class); + if (isCallable || isLegacyCallable) { + boolean hasJsonValueReturn = JsonValue.class.isAssignableFrom(method.getReturnType()); + boolean hasJsonValueParams = hasJsonValueParameters(method); + + if (isCallable && hasJsonValueParams) { + throw new IllegalArgumentException(String.format( + "Instrumented method '%s' in class '%s' has JsonValue arguments and must be annotated with @%s instead of @ClientCallable", + method.getName(), method.getDeclaringClass().getName(), + LegacyClientCallable.class.getName())); + } else if (isCallable && hasJsonValueReturn) { + methods.add(method); + } else if (isLegacyCallable) { + methods.add(method); + } + } + } + } + return methods; + } + + private static boolean hasJsonValueParameters(Method method) { + for (Class paramType : method.getParameterTypes()) { + if (JsonValue.class.isAssignableFrom(paramType)) { + return true; + } + } + return false; + } + + private Class createInstrumentedClass(Class parent, + String className) throws Exception { + InstrumentedClassLoader classLoader = + getOrCreateInstrumentedClassLoader(parent.getClassLoader()); + return classLoader.defineInstrumentedClass(className, parent).asSubclass(parent); + } + + private InstrumentedClassLoader getOrCreateInstrumentedClassLoader(ClassLoader parent) { + synchronized (classLoaderCache) { + return classLoaderCache.computeIfAbsent(parent, InstrumentedClassLoader::new); + } + } + + private static final class InstrumentedClassLoader extends ClassLoader { + + private final Map, Class> instrumentedClassCache = new ConcurrentHashMap<>(); + + public InstrumentedClassLoader(ClassLoader parent) { + super(parent); + } + + public Class defineInstrumentedClass(String className, Class parent) { + return instrumentedClassCache.computeIfAbsent(parent, p -> { + byte[] bytecode = generateBytecode(className, p); + return defineClass(className, bytecode, 0, bytecode.length); + }); + } + + private byte[] generateBytecode(String className, Class parent) { + String internalClassName = className.replace('.', '/'); + String internalParentName = parent.getName().replace('.', '/'); + + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalClassName, null, internalParentName, null); + + generateConstructor(cw, internalParentName); + generateClientCallableOverrides(cw, parent, internalParentName); + + cw.visitEnd(); + return cw.toByteArray(); + } + + private void generateConstructor(ClassWriter cw, String internalParentName) { + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + mv.visitCode(); + mv.visitVarInsn(Opcodes.ALOAD, 0); + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, internalParentName, "", "()V", false); + mv.visitInsn(Opcodes.RETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + } + + private void generateClientCallableOverrides(ClassWriter cw, Class parent, + String internalParentName) { + for (Method method : getInstrumentableMethods(parent)) { + generateMethodOverride(cw, method, internalParentName); + } + } + + private void generateMethodOverride(ClassWriter cw, Method method, String internalParentName) { + boolean hasJsonValueReturn = JsonValue.class.isAssignableFrom(method.getReturnType()); + boolean hasJsonValueParams = hasJsonValueParameters(method); + + String overrideDescriptor = getMethodDescriptor(method, hasJsonValueParams); + String superDescriptor = getMethodDescriptor(method, false); + int access = method.getModifiers() & (Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED); + + MethodVisitor mv = cw.visitMethod(access, method.getName(), overrideDescriptor, null, + getExceptionInternalNames(method.getExceptionTypes())); + + mv.visitAnnotation(Type.getDescriptor(ClientCallable.class), true); + mv.visitCode(); + + // Load 'this' + mv.visitVarInsn(Opcodes.ALOAD, 0); + + // Load and convert parameters + Class[] paramTypes = method.getParameterTypes(); + int localVarIndex = 1; + for (Class paramType : paramTypes) { + if (hasJsonValueParams && JsonValue.class.isAssignableFrom(paramType)) { + // Load the JsonNode parameter + mv.visitVarInsn(Opcodes.ALOAD, localVarIndex); + + // Call JsonMigration.convertToJsonValue(JsonNode) -> JsonValue + mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/flowingcode/vaadin/jsonmigration/JsonMigration", + "convertToJsonValue", "(Ljava/lang/Object;)Lelemental/json/JsonValue;", false); + + // Cast to the original type if not JsonValue + if (paramType != JsonValue.class) { + mv.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(paramType)); + } + + localVarIndex++; + } else { + localVarIndex += loadParameter(mv, paramType, localVarIndex); + } + } + + // Call super.methodName(params) with original descriptor + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, internalParentName, method.getName(), superDescriptor, + false); + + if (hasJsonValueReturn) { + // Store result in local variable + mv.visitVarInsn(Opcodes.ASTORE, localVarIndex); + + // Load result back + mv.visitVarInsn(Opcodes.ALOAD, localVarIndex); + + // Call JsonMigration.convertToClientCallableResult(aux) + mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/flowingcode/vaadin/jsonmigration/JsonMigration", + "convertToClientCallableResult", "(Lelemental/json/JsonValue;)Lelemental/json/JsonValue;", + false); + } + + // Return converted result or void + if (method.getReturnType() == Void.TYPE) { + mv.visitInsn(Opcodes.RETURN); + } else { + mv.visitInsn(Opcodes.ARETURN); + } + + mv.visitMaxs(0, 0); + mv.visitEnd(); + } + + private int loadParameter(MethodVisitor mv, Class paramType, int localVarIndex) { + if (!paramType.isPrimitive()) { + mv.visitVarInsn(Opcodes.ALOAD, localVarIndex); + return 1; + } else if (paramType == Long.TYPE) { + mv.visitVarInsn(Opcodes.LLOAD, localVarIndex); + return 2; + } else if (paramType == Float.TYPE) { + mv.visitVarInsn(Opcodes.FLOAD, localVarIndex); + return 1; + } else if (paramType == Double.TYPE) { + mv.visitVarInsn(Opcodes.DLOAD, localVarIndex); + return 2; + } else { + mv.visitVarInsn(Opcodes.ILOAD, localVarIndex); + return 1; + } + } + + private String getMethodDescriptor(Method method, boolean convertJsonValueParams) { + StringBuilder sb = new StringBuilder("("); + for (Class paramType : method.getParameterTypes()) { + if (convertJsonValueParams && JsonValue.class.isAssignableFrom(paramType)) { + sb.append(getConvertedTypeDescriptor(paramType)); + } else { + sb.append(Type.getDescriptor(paramType)); + } + } + sb.append(")"); + sb.append(Type.getDescriptor(method.getReturnType())); + return sb.toString(); + } + + private String getConvertedTypeDescriptor(Class type) { + if (type == JsonObject.class) { + return Type.getDescriptor(ObjectNode.class); + } else if (type == JsonArray.class) { + return Type.getDescriptor(ArrayNode.class); + } else if (type == JsonBoolean.class) { + return Type.getDescriptor(BooleanNode.class); + } else if (type == JsonNumber.class) { + return Type.getDescriptor(DoubleNode.class); + } else if (type == JsonString.class) { + return Type.getDescriptor(StringNode.class); + } else if (JsonValue.class.isAssignableFrom(type)) { + return Type.getDescriptor(JsonNode.class); + } + return Type.getDescriptor(type); + } + + private String[] getExceptionInternalNames(Class[] exceptionTypes) { + if (exceptionTypes == null || exceptionTypes.length == 0) { + return null; + } + String[] names = new String[exceptionTypes.length]; + for (int i = 0; i < exceptionTypes.length; i++) { + names[i] = exceptionTypes[i].getName().replace('.', '/'); + } + return names; + } + } +} diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigration.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigration.java index 1c3b080..e63c8c0 100644 --- a/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigration.java +++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigration.java @@ -20,6 +20,7 @@ package com.flowingcode.vaadin.jsonmigration; import com.vaadin.flow.component.ClientCallable; +import com.vaadin.flow.component.Component; import com.vaadin.flow.component.page.PendingJavaScriptResult; import com.vaadin.flow.dom.DomEvent; import com.vaadin.flow.dom.Element; @@ -168,4 +169,40 @@ public static JsonObject getEventData(DomEvent event) { return (JsonObject) convertToJsonValue(invoke(DomEvent_getEventData, event)); } + /** + * Instruments a component class to ensure compatibility with Vaadin 25+ JSON handling changes + * in {@link ClientCallable} methods. + * + *

+ * This method creates a dynamically generated subclass of the given component class that + * automatically converts {@code JsonValue} return values from {@link ClientCallable} methods to + * the appropriate {@code JsonNode} type required by Vaadin 25+. + * + *

+ * Behavior by Vaadin version: + *

    + *
  • Vaadin 25+: Returns an instrumented subclass that overrides all + * {@code @ClientCallable} methods returning {@code JsonValue} types. These overridden methods + * call the parent implementation and then convert the result through + * {@link #convertToClientCallableResult(JsonValue)} to ensure compatibility with the new + * Jackson-based JSON API.
  • + *
  • Vaadin 24 and earlier: Returns the original class unchanged, as no instrumentation + * is needed for the elemental.json API.
  • + *
+ * + * @param the type of the component class + * @param clazz the component class to instrument + * @return in Vaadin 25+, an instrumented subclass of {@code clazz}; in earlier versions, the + * original {@code clazz} + * @throws IllegalArgumentException if the class does not meet the requirements for + * instrumentation + * @throws RuntimeException if the instrumentation fails + * @see ClientCallable + * @see InstrumentedRoute + * @see #convertToClientCallableResult(JsonValue) + */ + public static Class instrumentClass(Class clazz) { + return helper.instrumentClass(clazz); + } + } diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigrationHelper.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigrationHelper.java index 5681823..00dcea1 100644 --- a/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigrationHelper.java +++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigrationHelper.java @@ -19,6 +19,7 @@ */ package com.flowingcode.vaadin.jsonmigration; +import com.vaadin.flow.component.Component; import com.vaadin.flow.component.page.PendingJavaScriptResult; import elemental.json.JsonValue; @@ -35,4 +36,6 @@ interface JsonMigrationHelper { ElementalPendingJavaScriptResult convertPendingJavaScriptResult(PendingJavaScriptResult result); + Class instrumentClass(Class clazz); + } diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigrationHelper25.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigrationHelper25.java index 2aa6ca3..24fc762 100644 --- a/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigrationHelper25.java +++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigrationHelper25.java @@ -21,6 +21,7 @@ import java.lang.reflect.Method; import java.util.Arrays; +import com.vaadin.flow.component.Component; import com.vaadin.flow.component.page.PendingJavaScriptResult; import com.vaadin.flow.function.SerializableConsumer; import elemental.json.Json; @@ -41,6 +42,11 @@ @NoArgsConstructor class JsonMigrationHelper25 implements JsonMigrationHelper { + @Override + public Class instrumentClass(Class clazz) { + return ClassInstrumentationUtil.instrumentClass(clazz); + } + @Override public JsonValue convertToJsonValue(Object object) { if (object instanceof JsonValue) { diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/LegacyClientCallable.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/LegacyClientCallable.java new file mode 100644 index 0000000..1842928 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/LegacyClientCallable.java @@ -0,0 +1,37 @@ +/*- + * #%L + * Json Migration Helper + * %% + * Copyright (C) 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.jsonmigration; + +import com.vaadin.flow.component.ClientCallable; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * When instrumented, publishes the annotated method as if {@link ClientCallable} has been used. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@Documented +public @interface LegacyClientCallable { + +} diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/LegacyJsonMigrationHelper.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/LegacyJsonMigrationHelper.java index 449538c..ea5c75a 100644 --- a/src/main/java/com/flowingcode/vaadin/jsonmigration/LegacyJsonMigrationHelper.java +++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/LegacyJsonMigrationHelper.java @@ -19,6 +19,7 @@ */ package com.flowingcode.vaadin.jsonmigration; +import com.vaadin.flow.component.Component; import com.vaadin.flow.component.page.PendingJavaScriptResult; import java.lang.reflect.Method; import elemental.json.JsonValue; @@ -30,6 +31,11 @@ @NoArgsConstructor class LegacyJsonMigrationHelper implements JsonMigrationHelper { + @Override + public Class instrumentClass(Class clazz) { + return clazz; + } + @Override public JsonValue convertToJsonValue(Object object) { if (object instanceof JsonValue) { From 1a724449c257665a156b937b2d995c85f160e262 Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Fri, 28 Nov 2025 01:02:41 -0300 Subject: [PATCH 3/4] feat: add InstrumentationViewInitializer --- .../InstrumentationViewInitializer.java | 63 +++++++++++++++++++ .../jsonmigration/InstrumentedRoute.java | 46 ++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentationViewInitializer.java create mode 100644 src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentedRoute.java diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentationViewInitializer.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentationViewInitializer.java new file mode 100644 index 0000000..1ade68a --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentationViewInitializer.java @@ -0,0 +1,63 @@ +/*- + * #%L + * Json Migration Helper + * %% + * Copyright (C) 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.jsonmigration; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.router.RouteConfiguration; +import com.vaadin.flow.server.VaadinServiceInitListener; +import com.vaadin.flow.server.Version; + +/** + * Abstract base class for Vaadin service initializers that register instrumented views. Subclasses + * should implement {@link #serviceInit(com.vaadin.flow.server.ServiceInitEvent)} and call + * {@link #registerInstrumentedRoute(Class)} to register views with instrumented routes. + * + * @author Javier Godoy / Flowing Code + */ +@SuppressWarnings("serial") +public abstract class InstrumentationViewInitializer implements VaadinServiceInitListener { + + /** + * Registers an instrumented route for the given navigation target. The navigation target must be + * annotated with {@link InstrumentedRoute} to specify the route path. This method calls + * {@link JsonMigration#instrumentClass(Class)} to get the instrumented class and registers it as + * a Vaadin view with the route derived from the annotation. + * + * @param navigationTarget the component class to instrument and register, must be annotated with + * {@link InstrumentedRoute} + * @throws IllegalArgumentException if the navigationTarget is not annotated with + * {@link InstrumentedRoute} + */ + protected final void registerInstrumentedRoute(Class navigationTarget) { + InstrumentedRoute annotation = navigationTarget.getAnnotation(InstrumentedRoute.class); + if (annotation == null) { + throw new IllegalArgumentException( + navigationTarget.getName() + " must be annotated with @" + + InstrumentedRoute.class.getSimpleName()); + } + + String route = annotation.value(); + if (Version.getMajorVersion() > 24) { + navigationTarget = JsonMigration.instrumentClass(navigationTarget); + } + RouteConfiguration.forApplicationScope().setRoute(route, navigationTarget); + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentedRoute.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentedRoute.java new file mode 100644 index 0000000..4e93475 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentedRoute.java @@ -0,0 +1,46 @@ +/*- + * #%L + * Json Migration Helper + * %% + * Copyright (C) 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.jsonmigration; + +import com.vaadin.flow.component.Component; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to mark a {@link Component} class for instrumented route registration. + * + * @author Javier Godoy / Flowing Code + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Documented +public @interface InstrumentedRoute { + + /** + * The route path for this component. + * + * @return the route path + */ + String value(); + +} From ac509223c876ac383c27ca2f180644d5e47257de Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Fri, 28 Nov 2025 14:09:56 -0300 Subject: [PATCH 4/4] docs(readme): add documentation for new features --- README.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 88f4d3b..d93dce6 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Provides a compatibility layer for JSON handling to abstract away breaking chang - **Zero-effort migration**: Write your code once and run it seamlessly on Vaadin 14, 23, 24 and 25 - **Automatic version detection**: Detects the runtime Vaadin version and uses the appropriate JSON handling strategy - **Drop-in replacement**: Simple static methods that replace version-specific APIs +- **Client Callable compatibility**: Mechanisms to handle JSON arguments and return types in `@ClientCallable` methods. +- **JsonSerializer and JsonCodec**: Includes `JsonSerializer` and `JsonCodec` classes for serialization and deserialization of elemental JSON values. ## Download release @@ -117,12 +119,39 @@ When a `@ClientCallable` method needs to return a JSON value, use `convertToClie ```java @ClientCallable -public Object getJsonData() { +public JsonValue getJsonData() { JsonValue json = ...; return JsonMigration.convertToClientCallableResult(json); } ``` +## Receiving JSON in ClientCallable methods + +If the method receives `JsonValue` as an argument, it cannot be annotated with `ClientCallable` because of compatibility issues. `LegacyClientCallable` should be used instead. + +To use `LegacyClientCallable`, you must use instrumentation. This can be done via `JsonMigration.instrumentClass` or by using `InstrumentedRoute` / `InstrumentationViewInitializer`. + +**Note:** Instrumentation is a complex mechanism. While it might warrant a rewrite of the affected code, it is offered here to preserve compatibility with existing implementations. + +```java +@InstrumentedRoute("legacy-view") +public class ViewWithElementalCallables extends Div { + @LegacyClientCallable + public void receiveJson(JsonValue json) { + // ... + } +} + +// Register via META-INF/services/com.vaadin.flow.server.VaadinServiceInitListener +// or use `@SpringComponent` with Spring. +public class ViewInitializerImpl extends InstrumentationViewInitializer { + @Override + public void serviceInit(ServiceInitEvent event) { + registerInstrumentedRoute(ViewWithElementalCallables.class); + } +} +``` + ## Direct Usage The helper methods can also be used directly from the `JsonMigration` class: