View Javadoc
1   /**
2    * This file Copyright (c) 2019 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.i18nsystem.bytebudddy;
35  
36  import static info.magnolia.i18nsystem.bytebudddy.AnyTypeInGenericSignatureMatcher.annotationIsPresentInGenericSignature;
37  import static info.magnolia.i18nsystem.bytebudddy.MethodOrSuperMethodMatcher.annotationIsPresentInHierarchy;
38  import static java.util.stream.Collectors.*;
39  import static net.bytebuddy.dynamic.loading.ClassLoadingStrategy.Default.WRAPPER;
40  import static net.bytebuddy.implementation.MethodDelegation.to;
41  import static net.bytebuddy.matcher.ElementMatchers.*;
42  
43  import info.magnolia.context.Context;
44  import info.magnolia.dynamic.MagnoliaProxy;
45  import info.magnolia.i18nsystem.ContextLocaleProvider;
46  import info.magnolia.i18nsystem.I18nKeyGenerator;
47  import info.magnolia.i18nsystem.I18nKeyGeneratorFactory;
48  import info.magnolia.i18nsystem.I18nParentable;
49  import info.magnolia.i18nsystem.I18nText;
50  import info.magnolia.i18nsystem.I18nable;
51  import info.magnolia.i18nsystem.I18nizer;
52  import info.magnolia.i18nsystem.TranslationService;
53  
54  import java.lang.reflect.Field;
55  import java.lang.reflect.InvocationTargetException;
56  import java.lang.reflect.Method;
57  import java.lang.reflect.Type;
58  import java.lang.reflect.TypeVariable;
59  import java.util.Arrays;
60  import java.util.Collection;
61  import java.util.LinkedHashMap;
62  import java.util.List;
63  import java.util.Map;
64  import java.util.Optional;
65  import java.util.concurrent.ConcurrentHashMap;
66  import java.util.function.BiFunction;
67  import java.util.stream.Stream;
68  
69  import javax.inject.Inject;
70  import javax.inject.Provider;
71  import javax.inject.Singleton;
72  
73  import org.objenesis.Objenesis;
74  import org.objenesis.ObjenesisStd;
75  import org.slf4j.Logger;
76  import org.slf4j.LoggerFactory;
77  
78  import com.google.common.collect.Collections2;
79  
80  import net.bytebuddy.ByteBuddy;
81  import net.bytebuddy.TypeCache;
82  import net.bytebuddy.description.type.TypeDescription;
83  import net.bytebuddy.dynamic.DynamicType;
84  import net.bytebuddy.implementation.FieldAccessor;
85  import net.bytebuddy.implementation.bind.annotation.AllArguments;
86  import net.bytebuddy.implementation.bind.annotation.Origin;
87  import net.bytebuddy.implementation.bind.annotation.RuntimeType;
88  import net.bytebuddy.implementation.bind.annotation.This;
89  import net.sf.cglib.proxy.Enhancer;
90  
91  /**
92   * I18nizer implementation based on ByteBuddy framework.
93   */
94  @Singleton
95  public class ByteBuddyI18nizer implements I18nizer {
96  
97      private static final Logger log = LoggerFactory.getLogger(ByteBuddyI18nizer.class);
98  
99      private static final String I18N_SOURCE = "_I18N_SOURCE";
100     private static final String I18N_PARENT = "_I18N_PARENT";
101 
102     private final Objenesis objenesis = new ObjenesisStd(true);
103 
104     private final TypeCache<Class> typeCache = new TypeCache.WithInlineExpunction<>(TypeCache.Sort.SOFT);
105     private final Map<Method, String> i18nTextFallbackCache = new ConcurrentHashMap<>();
106 
107     private final TranslationService translationService;
108     private final Provider<Context> contextProvider;
109 
110     @Inject
111     public ByteBuddyI18nizer(TranslationService translationService, Provider<Context> contextProvider) {
112         this.translationService = translationService;
113         this.contextProvider = contextProvider;
114     }
115 
116     @Override
117     public <C> C decorate(C obj) {
118         if (obj == null) {
119             return null;
120         }
121         // Check if this has already been decorated to avoid redundancy in client code
122         if (isProxied(obj.getClass()) && obj instanceof I18nParentable) {
123             throw new IllegalStateException(obj + " has already been enhanced by " + getClass().getSimpleName());
124         }
125 
126         return decorateInstance(obj, null);
127     }
128 
129     @SuppressWarnings("unchecked")
130     private <C> C decorateInstance(C instance, Object parent) {
131         if (instance == null) {
132             return null;
133         }
134 
135         Class<C> clazz = getOriginalType(instance);
136         Class<C> proxyType = (Class<C>) typeCache.findOrInsert(getClass().getClassLoader(), clazz, () -> generateByteBuddyDecorator(clazz));
137 
138         final C proxy = objenesis.newInstance(proxyType);
139         setFieldValue(proxy, I18N_SOURCE, instance);
140         setFieldValue(proxy, I18N_PARENT, parent);
141 
142         return proxy;
143     }
144 
145     private <C> Class generateByteBuddyDecorator(Class<C> type) {
146         TypeVariable<Class<C>>[] typeParameters = type.getTypeParameters();
147         DynamicType.Builder<C> subclass;
148 
149         if (typeParameters.length == 0) {
150             subclass = new ByteBuddy().subclass(type);
151         } else {
152             Type[] stubParameterTypes = Arrays.stream(typeParameters).map(param -> {
153                 Type[] bounds = param.getBounds();
154                 return bounds.length == 0 ? Object.class : bounds[0];
155             }).toArray(Type[]::new);
156 
157             subclass = (DynamicType.Builder<C>) new ByteBuddy().subclass(TypeDescription.Generic.Builder.parameterizedType(type, stubParameterTypes).build());
158         }
159 
160         return subclass
161                 .defineField(I18N_SOURCE, type)
162                 .defineField(I18N_PARENT, Object.class)
163                 .implement(MagnoliaProxy.class)
164                 .implement(DelegateResolver.class)
165                 .implement(I18nParentable.class)
166                 .method(not(isDeclaredBy(Object.class)).and(isPublic()))
167                 .intercept(to(new DefaultInterceptor<C>()))
168                 .method(isGetter().and(annotationIsPresentInHierarchy(I18nText.class)))
169                 .intercept(to((new I18NTextAnnotatedGetterInterceptor<>(translationService, I18nKeyGeneratorFactory.newKeyGeneratorFor(type)))))
170                 .method(isGetter().and(returnsGeneric(annotationIsPresentInGenericSignature(I18nable.class))))
171                 .intercept(to(new I18NableGetterInterceptor<>(this::decorateInstance)))
172                 .method(isDeclaredBy(DelegateResolver.class))
173                 .intercept(FieldAccessor.ofField(I18N_SOURCE))
174                 .method(isDeclaredBy(I18nParentable.class))
175                 .intercept(FieldAccessor.ofField(I18N_PARENT))
176                 .make()
177                 .load(getClass().getClassLoader(), WRAPPER).getLoaded();
178     }
179 
180     private boolean isProxied(Class<?> clazz) {
181         return MagnoliaProxy.class.isAssignableFrom(clazz);
182     }
183 
184     private Object getUndecoratedValue(Method method, Object target) {
185         try {
186             return method.invoke(target);
187         } catch (IllegalAccessException | InvocationTargetException e) {
188             log.warn("Undecorated value couldn't be retrieved {}", e.getMessage());
189             return null;
190         }
191     }
192 
193     @SuppressWarnings("unchecked")
194     private <C> Class<C> getOriginalType(C instance) {
195         Class<C> clazz = (Class<C>) instance.getClass();
196         if (instance instanceof MagnoliaProxy || Enhancer.isEnhanced(clazz)) {
197             clazz = (Class<C>) clazz.getSuperclass();
198         }
199 
200         return clazz;
201     }
202 
203     private <C> void setFieldValue(C target, String propertyName, Object value) {
204         try {
205             final Field delegateField = target.getClass().getDeclaredField(propertyName);
206             delegateField.setAccessible(true);
207             delegateField.set(target, value);
208         } catch (IllegalAccessException | NoSuchFieldException e) {
209             log.error("Failed to set property [{}], i18n enhancement process failed", propertyName, e);
210         }
211     }
212 
213     /**
214      * Mixin interface which makes it easier for the i18n proxy to expose
215      * the object that it wraps.
216      *
217      * @param <T>
218      *     type of the wrapped object
219      */
220     public interface DelegateResolver<T> {
221         T getProxyDelegate();
222     }
223 
224     /**
225      * Catch-all interceptor which merely dispatches all the calls to the wrapped object.
226      * Note that the annotated getters will be handled by other intercepters accordingly.
227      *
228      * @param <T>
229      *     type of the wrapped object
230      */
231     public class DefaultInterceptor<T> {
232 
233         @RuntimeType
234         public Object intercept(@This DelegateResolver<T> that, @Origin Method method, @AllArguments Object[] args) {
235             try {
236                 return method.invoke(that.getProxyDelegate(), args);
237             } catch (IllegalAccessException | InvocationTargetException e) {
238                 log.error("Failed to intercept method [{}]", method.getName(), e);
239                 return null;
240             }
241         }
242     }
243 
244     /**
245      * Handles the getters which return i18n-annotated types.
246      * Automatically recursively i18n-izes them.
247      *
248      * @param <T>
249      *     type of the getter owner
250      */
251     public class I18NableGetterInterceptor<T> {
252 
253         private final BiFunction<Object, T, Object> decorator;
254 
255         public I18NableGetterInterceptor(BiFunction<Object, T, Object> decorator) {
256             this.decorator = decorator;
257         }
258 
259         @SuppressWarnings("unchecked")
260         @RuntimeType
261         public Object decorate(@Origin Method method, @This DelegateResolver<T> delegateResolver, @This T parent) {
262             Object value = getUndecoratedValue(method, delegateResolver.getProxyDelegate());
263             if (value instanceof Map) {
264                 value = ((Map<?, ?>) value).entrySet().stream()
265                         .collect(toMap(Map.Entry::getKey,
266                                 el -> decorator.apply(el.getValue(), parent),
267                                 (u, v) -> {
268                                     throw new IllegalStateException(String.format("Duplicate key %s", u));
269                                 },
270                                 LinkedHashMap::new)
271                         );
272             } else if (value instanceof Collection) {
273                 // We can't use Collections2.transform() or Lists.transform() as these create new immutable collections
274                 if (value instanceof List) {
275                     //this is wrapped in newArrayList() to make it modifiable since info.magnolia.ui.contentapp.movedialog.MoveDialogPresenterImpl#prepareWorkbenchDefinition relies on it (just for compatibility although it should be fixed there)
276                     value = ((List<?>) value).stream().map(el -> decorator.apply(el, parent)).collect(toList());
277                 } else {
278                     value = Collections2.transform((Collection) value, el -> decorator.apply(el, parent));
279                 }
280             } else {
281                 value = decorator.apply(value, parent);
282             }
283             return value;
284         }
285     }
286 
287     /**
288      * Handles the getters annotated with {@link I18nText}.
289      * Delegates the calls to the translation service.
290      *
291      * @param <T>
292      *     type of the getter owner
293      */
294     public class I18NTextAnnotatedGetterInterceptor<T> {
295 
296         private final TranslationService translationService;
297         private final I18nKeyGenerator<T> keyGenerator;
298 
299         public I18NTextAnnotatedGetterInterceptor(TranslationService translationService, I18nKeyGenerator<T> keyGenerator) {
300             this.translationService = translationService;
301             this.keyGenerator = keyGenerator;
302         }
303 
304         @RuntimeType
305         public String i18nTextMethod(@Origin Method method, @This DelegateResolver<T> delegateResolver, @This T that) {
306             String undecoratedValue = (String) getUndecoratedValue(method, delegateResolver.getProxyDelegate());
307             String[] keys;
308             String fallback;
309             if (undecoratedValue != null) {
310                 keys = new String[]{undecoratedValue};
311                 fallback = undecoratedValue;
312             } else {
313                 keys = keyGenerator.keysFor(null, that, method);
314                 fallback = getFallback(method);
315             }
316             final ContextLocaleProvider#ContextLocaleProvider">ContextLocaleProvider localeProvider = new ContextLocaleProvider(contextProvider.get());
317             return translationService.translate(localeProvider, keys, fallback);
318         }
319 
320         private String getFallback(@Origin Method method) {
321             String fallback = i18nTextFallbackCache.get(method);
322             if (fallback == null) {
323                 fallback = Optional.ofNullable(method.getAnnotation(I18nText.class)).map(I18nText::fallback).orElseGet(() ->
324                         Stream.concat(
325                                 Arrays.stream(method.getDeclaringClass().getInterfaces()).flatMap(aClass -> Arrays.stream(aClass.getMethods())),
326                                 Arrays.stream(Optional.ofNullable(method.getDeclaringClass().getSuperclass()).map(Class::getMethods).orElse(new Method[]{})))
327                                 .filter(interfaceMethod -> interfaceMethod.getName().equals(method.getName()))
328                                 .findFirst()
329                                 .map(this::getFallback)
330                                 .orElse(I18nText.NO_FALLBACK)
331                 );
332                 i18nTextFallbackCache.put(method, fallback);
333             }
334             return fallback;
335         }
336     }
337 }