Write your own CDI extension for bean mapping


CDI descriptive bean mapping: how to write a CDI extension to map beans

The idea of this post is to show you how to end up with a CDI extension allowing you to get injected a mapper defined only doing this:

@Mapper
public interface MyMapper {
    @Mapping(source = "inputId", target = "id")
    @Mapping(source = "employeeId")
    Output1 toOutput1(final Input2 input);

    @Mapping(source = "id")
    @Mapping(source = "name", target = "firstName")
    Output2 toOutput2(final Input1 input);
}

Of course the API is very (very) close to mapstruct one and this post doesn’t intend to go that far but the difference is that the extension will all be built for runtime analysis using CDI. Said otherwise it is more dynamic and usable in real projects when you want a declarative API.

First define the API

The API is pretty straight forward:

  • @Mapper is marking an interface as a mapper – this could be optional but makes code cleaner IMO
  • @Mapping is a repeatable annotation defining which field – source – is read in the input (parameter) and which field – target – is set in the output (returned type). Small sugar there, if source and target are equals, target is optional.

Since this is just defining three annotations I’ll just paste the code there:

@Target(TYPE)
@Retention(RUNTIME)
public @interface Mapper {
}

@Repeatable(Mappings.class)
@Target(METHOD)
@Retention(RUNTIME)
public @interface Mapping {
    String source();
    String target() default "";
}

@Target(METHOD)
@Retention(RUNTIME)
public @interface Mappings {
    Mapping[] value();
}

Creating instances from the interfaces

So how to create an instance of a bean if we have such an interface? Just reading all metadata and creating a proxy!

Creaying a proxy is as simple as calling:

final MyMapper mapper = (MyMapper) Proxy.newProxyInstance(contextClassLoader, new Class<?>{} { MyMapper.class }, handler);

So the obvious thing is we need a handler able to do the conversion on each method invocation.

It is done implementing java.lang.reflect.InvocationHandler. For this post implementation, the MapperHandler will read from an AnnotatedType the metadata (annotations) to build its runtime model (used to actually do the mapping) and an AtomicReference since our implementation will just abstract the coercing of types to not make this post too long.

The idea is to build a model with a map of reader/writer pairs which will get used to map input to the output:

public class MapperHandler implements InvocationHandler {
    private final Map<Method, MappingMethod> mapping;
    private final AtomicReference<Converter> converter;

    public <T> MapperHandler(final AnnotatedType<Object> type, final AtomicReference<Converter> converter) {
        this.mapping = type.getMethods().stream()
            .filter(m -> m.isAnnotationPresent(Mappings.class) && m.getParameters().size() == 1)
            .collect(toMap(AnnotatedMethod::getJavaMember, MappingMethod::new));
        this.converter = converter;
    }

    @Override
    public Object invoke(final Object proxy, final Method method, final Object[] args) throws Throwable {
        if (method.getDeclaringClass() == Object.class) {
            try {
                return method.invoke(this, args);
            } catch (final InvocationTargetException ite) {
                throw ite.getCause();
            }
        }
        return mapping.get(method).map(args[0]);
    }

    private class MappingMethod {
        private final Class<?> from;
        private final Class<?> to;
        private final Map<Reader, Writer> mapping;

        public MappingMethod(final AnnotatedMethod<?> annotatedMethod) {
            if (annotatedMethod.getParameters().size() != 1) {
                throw new IllegalArgumentException("Mapping method needs to have one parameter.");
            }

            from = Class.class.cast(annotatedMethod.getParameters().iterator().next().getBaseType());
            to = annotatedMethod.getJavaMember().getReturnType();

            mapping = Stream.of(annotatedMethod.getAnnotation(Mappings.class).value())
                // can be extended to support field access
                .collect(toMap(m -> new Reader() {
                    private final Method method = findMethod(from, mtd -> mtd.getName().equals("get" + toUppercase(m.source())) && mtd.getParameterCount() == 0, m.source());

                    @Override
                    public Object get(final Object instance) {
                        try {
                            return method.invoke(instance);
                        } catch (final IllegalAccessException e) {
                            throw new IllegalStateException(e);
                        } catch (final InvocationTargetException e) {
                            throw new IllegalStateException(e.getCause());
                        }
                    }
                }, m -> new Writer() {
                    private final Method method = findMethod(to, mtd -> mtd.getName().equals("set" + toUppercase(targetField())) && mtd.getParameterCount() == 1, targetField());

                    @Override
                    public void set(final Object instance, final Object value) {
                        try {
                            final Converter converter = MapperHandler.this.converter.get();
                            final boolean convert = !(converter == null || method.getParameterTypes()[0].isInstance(value));
                            method.invoke(instance, convert ? converter.to(value, method.getParameterTypes()[0]) : value);
                        } catch (final IllegalAccessException e) {
                            throw new IllegalStateException("error invoking " + method, e);
                        } catch (final InvocationTargetException e) {
                            throw new IllegalStateException("error invoking " + method, e.getCause());
                        }
                    }

                    private String targetField() {
                        return m.target().isEmpty() ? m.source() : m.target();
                    }
                }));
        }

        public Object map(final Object args) {
            if (!from.isInstance(args)) {
                throw new IllegalArgumentException(args + " not an instance of " + from);
            }

            try {
                final Object newInstance = to.newInstance();
                mapping.forEach((r, w) -> ofNullable(r.get(args)).ifPresent(v -> w.set(newInstance, v)));
                return newInstance;
            } catch (final IllegalAccessException | InstantiationException e) {
                throw new IllegalStateException(e);
            }
        }
    }

    private static String toUppercase(final String m) {
        return Character.toUpperCase(m.charAt(0)) + (m.length() == 1 ? "" : m.substring(1));
    }

    private static Method findMethod(final Class<?> type, final Predicate<Method> matcher, final String name) {
        for (final Method m : type.getMethods()) {
            if (matcher.test(m)) {
                return m;
            }
        }
        throw new IllegalArgumentException("Missing " + name);
    }

    @FunctionalInterface
    private interface Reader {
        Object get(Object instance);
    }

    @FunctionalInterface
    private interface Writer {
        void set(Object instance, Object value);
    }
}

Be able to register our proxy as a CDI Bean

To be able to add an “implementation” to CDI context we need to wrap our proxy in a javax.enterprise.inject.spi.Bean.

The implementation is straight forward and starts from the same input parameter as our handler:

public class MapperBean<T> implements Bean<T> {
    private final Set<Type> types;
    private final Set<Annotation> qualifiers;
    private final Class<T> clazz;
    private final Class<?>[] proxyTypes;
    private final MapperHandler handler;

    public MapperBean(final AnnotatedType at, final AtomicReference<Converter> converter) {
        clazz = at.getJavaClass();
        types = new HashSet<>(asList(clazz, Object.class));
        qualifiers = new HashSet<>(asList(DefaultLiteral.INSTANCE, AnyLiteral.INSTANCE));
        proxyTypes = new Class<?>[] { clazz };
        handler = new MapperHandler(at, converter);
    }

    @Override
    public Set<Type> getTypes() {
        return types;
    }

    @Override
    public Set<Annotation> getQualifiers() {
        return qualifiers;
    }

    @Override
    public Class<? extends Annotation> getScope() {
        return ApplicationScoped.class;
    }

    @Override
    public String getName() {
        return null;
    }

    @Override
    public boolean isNullable() {
        return false;
    }

    @Override
    public Set<InjectionPoint> getInjectionPoints() {
        return emptySet();
    }

    @Override
    public Class<?> getBeanClass() {
        return clazz;
    }

    @Override
    public Set<Class<? extends Annotation>> getStereotypes() {
        return emptySet();
    }

    @Override
    public boolean isAlternative() {
        return false;
    }

    @Override
    public T create(final CreationalContext<T> context) {
        final ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        return (T) Proxy.newProxyInstance(
            contextClassLoader == null ? ClassLoader.getSystemClassLoader() : contextClassLoader,
            proxyTypes, handler);
    }

    @Override
    public void destroy(final T instance, final CreationalContext<T> context) {
        // no-op
    }
}

Things to note are:

  • We scoped our implementation @ApplicationScoped since the proxy is stateless
  • Most of methods are using default values since our proxy doesn’t need any injection or specific model
  • We set the @Default and @Any qualifiers to be able to retrieve our implementation without any specific qualifiers

Wire it all in an extension

Now all our implementation is ready we just need to make it real in a CDI extension (dont forget to register it in META-INF/services/javax.enterprise.inject.spi.Extension).
This extension will be responsible to capture mapper interface types and register the MapperBean to make them available in CDI context.

This sample implementation of this extension handles the retrieval of an optional Converter if you want to plug some advanced coercing for type conversion:

public class MapperExtension implements Extension {
    private final Collection<AnnotatedType<?>> detectedMappers = new ArrayList<>();
    private final AtomicReference<Converter> converterRef = new AtomicReference<>();

    void captureMapper(@Observes final ProcessAnnotatedType<?> potentialMapper) {
        final AnnotatedType<?> annotatedType = potentialMapper.getAnnotatedType();
        if (annotatedType.isAnnotationPresent(Mapper.class)) {
            detectedMappers.add(annotatedType);
        }
    }

    void addMapperBeans(@Observes final AfterBeanDiscovery abd) {
        detectedMappers.stream().forEach(at -> abd.addBean(new MapperBean(at, converterRef)));
        detectedMappers.clear();
    }

    void findConverter(@Observes final AfterDeploymentValidation adv, final BeanManager beanManager) {
        final Set<Bean<?>> beans = beanManager.getBeans(Converter.class);
        final Bean<?> bean = beanManager.resolve(beans);
        // converter should be normal-scoped otherwise we need to release the creational context when shutdown event is fired
        ofNullable(bean).ifPresent(b -> converterRef.set(Converter.class.cast(beanManager.getReference(bean, Converter.class, null))));
    }
}

Use your CDI Mapper extension!

Now suppose you deploy your extension with the initial sample of this post, then you can simply use it as in this example:

@Path("test")
@ApplicationScoped
public class MyEndpoint {
  @Inject
  private MyMapper mapper;

  @Inject
  private MyService service;

  @GET
  @Path("{id}")
  public Output1 findOutput(@PathParam("id) String id) {
      return mapper.toOutput1(service.findInput2(id));
  }
}

What is nice about such a solution – this includes mapstruct 🙂 – is you define your mapping in a well defined place. This means the behavior is well defined and dedicated to the mapping which avoid a lot of boilerplate code on one side and makes it easy to understand and maintain on the other side. The awesome CDI feature is thanks to AnnotatedType you can change the mapping dynamically and programmatically if you need without hanging the mapper (if they don’t belong to your own codebase for instance).

Happy mapping!

1 thought on “Write your own CDI extension for bean mapping

  1. Pingback: CDI Mapper: get rid of the proxy layer! | new RManniBucau().blog()

Leave a comment