Java tutorial
/******************************************************************************* * Copyright (c) 2016 Red Hat. All rights reserved. This program and the accompanying materials are * made available under the terms of the Eclipse Public License v1.0 which accompanies this * distribution, and is available at http://www.eclipse.org/legal/epl-v10.html * * Contributors: Red Hat - Initial Contribution *******************************************************************************/ package org.lambdamatic.internal.elasticsearch.codec; import java.beans.BeanInfo; import java.beans.IntrospectionException; import java.beans.Introspector; import java.beans.PropertyDescriptor; import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; import org.lambdamatic.elasticsearch.annotations.Document; import org.lambdamatic.elasticsearch.annotations.DocumentIdField; import org.lambdamatic.elasticsearch.annotations.EmbeddedDocument; import org.lambdamatic.elasticsearch.exceptions.CodecException; import org.lambdamatic.elasticsearch.exceptions.DomainTypeException; import org.lambdamatic.internal.elasticsearch.MappingException; import org.lambdamatic.internal.elasticsearch.clientdsl.responses.SearchResponse.SearchHit; import org.lambdamatic.internal.elasticsearch.utils.Pair; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; /** * Converter utility to map elements contained in a returned {@link SearchHit} into domain objects, * or to convert domain objects into source maps. * * @param <T> the type to encode/decode */ public class DocumentCodec<T> { /** * The name of the keyword field in the Elasticsearch document that contains the fully qualified * name of the corresponding domain type. */ public static final String DOMAIN_TYPE = "_className"; /** The domain type to encode/decode. */ final Class<T> domainType; /** * The {@link ObjectMapper} to encode/decode documents. */ private final ObjectMapper objectMapper; /** * The {@link BeanInfo} associated with the domainType. */ final BeanInfo domainTypeBeanInfo; /** * Constructor. * * @param domainType The domain type to encode/decode. Must be annotated with {@link Document} or * {@link EmbeddedDocument} * @param objectMapper the {@link ObjectMapper} to encode/decode documents * @throws DomainTypeException if an exception occurs during introspection of the given domain * type. */ public DocumentCodec(final Class<T> domainType, final ObjectMapper objectMapper) throws DomainTypeException { this.domainType = domainType; this.objectMapper = objectMapper; try { this.domainTypeBeanInfo = Introspector.getBeanInfo(domainType); } catch (IntrospectionException e) { throw new DomainTypeException("Failed to analyse domain type '" + domainType.getName() + "'", e); } } /** * Converts the given {@code document} into a source map, to be sent to Elasticsearch. * <p> * <strong>Note</strong>: the resulting source map does not include the {@code id} field, which * should be separately retrieved using the {@link DocumentCodec#getDomainObjectId(Object)} * </p> * * @param domainObject the document to convert * @return the corresponding JSON document as a {@link String}, or <code>null</code> if the given * {@code document} was <code>null</code>, too. * */ public String encode(final Object domainObject) { if (domainObject == null) { return null; } try { return this.objectMapper.writeValueAsString(domainObject); } catch (JsonProcessingException e) { throw new CodecException("Failed to convert domain object of type '" + this.domainType.getName() + "' into a document source", e); } } /** * Converts the elements contained in the given {@code searchHit} into a Domain instance. * * @param documentId the id of the document retrieved in Elasticsearch * @param documentSourceAsMap the source of the document retrieved in Elasticsearch * @return the generated instance of DomainType * @throws CodecException if the conversion of the given {@code searchHit} into an instance of the * given {@code domainType} failed. */ public T decode(final String documentId, final JsonNode documentSourceAsMap) { final T domainObject; try { //FIXME: this needs to be improved: we should not have to convert the Map back into a String to parse it again ! final String documentSource = this.objectMapper.writeValueAsString(documentSourceAsMap); domainObject = this.objectMapper.readValue(documentSource, this.domainType); } catch (IOException e) { throw new CodecException("Failed to convert a document source into a domain object of type '" + this.domainType.getName() + "'", e); } setDomainObjectId(domainObject, documentId); return domainObject; } /** * Gets the {@code documentId} value for the given {@code domainObject} using the getter for the * property annotated with the {@link DocumentIdField} annotation. * * @param domainObject the instance of DomainType on which to set the {@code id} property * @return the {@code documentId} converted as a String, or <code>null</code> if none was found * @throws IntrospectionException if introspection of the given DomainType failed. */ public String getDomainObjectId(final Object domainObject) { final Field idField = getIdField(domainObject.getClass()); final PropertyDescriptor idPropertyDescriptor = getIdPropertyDescriptor(domainObject, idField); try { final Object documentId = idPropertyDescriptor.getReadMethod().invoke(domainObject); return documentId != null ? documentId.toString() : null; } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { throw new CodecException( "Failed to get Id value for document of type '" + domainObject.getClass().getName() + ") using method '" + idPropertyDescriptor.getReadMethod().toString() + "'"); } } /** * Sets the given {@code documentId} value to the given {@code domainObject} using the setter for * the property annotated with the {@link DocumentIdField} annotation. * * @param domainObject the instance of DomainType on which to set the {@code id} property * @param documentId the value of the document id. */ public void setDomainObjectId(final T domainObject, final String documentId) { final Field idField = getIdField(domainObject.getClass()); final PropertyDescriptor idPropertyDescriptor = getIdPropertyDescriptor(domainObject, idField); try { idPropertyDescriptor.getWriteMethod().invoke(domainObject, convertValue(documentId, idField.getType())); } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { throw new CodecException("Failed to set value '" + documentId + "' (" + documentId.getClass().getName() + ") using method '" + idPropertyDescriptor.getWriteMethod().toString() + "'"); } } /** * Converts the given {@code value} into a value of type {@code targetType}. * <p> * <strong>Note:</strong>The conversion relies on the existence of a static * {@code valueOf(String)} method in the given {@code targetType} to convert the value. * </p> * * @param value the input value * @param targetType the target type of the value to return * @return the converted value (or the value itself if no conversion was necessary) */ protected static Object convertValue(final Object value, final Class<?> targetType) { if (targetType.isAssignableFrom(value.getClass())) { return value; } else if (targetType.isPrimitive()) { if (targetType == boolean.class) { return Boolean.parseBoolean(value.toString()); } else if (targetType == byte.class) { return Byte.parseByte(value.toString()); } else if (targetType == short.class) { return Short.parseShort(value.toString()); } else if (targetType == int.class) { return Integer.parseInt(value.toString()); } else if (targetType == long.class) { return Long.parseLong(value.toString()); } else if (targetType == double.class) { return Double.parseDouble(value.toString()); } else if (targetType == float.class) { return Float.parseFloat(value.toString()); } throw new CodecException("Failed to convert value '" + value.toString() + "' (" + value.getClass().getName() + ") into a " + targetType.getName() + ": no object to primitive conversion available."); } try { final Method convertMethod = getConvertMethod(targetType); if (convertMethod != null) { return convertMethod.invoke(null, value.toString()); } throw new CodecException( "Failed to convert value '" + value.toString() + "' (" + value.getClass().getName() + ") into a " + targetType.getName() + ": no conversion method available."); } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException | SecurityException e) { throw new CodecException("Failed to convert value '" + value.toString() + "' (" + value.getClass().getName() + ") into a " + targetType.getClass().getName(), e); } } /** * Attempts to look-up a {@code valueOf(String)} or {@code parse(CharSequence)} method in the * given {@code targetType} to allow for value setting with a conversion. * * @param targetType the type in which the convert method must be looked-up * @return the method to apply, or <code>null</code> if none was found. */ private static Method getConvertMethod(final Class<?> targetType) { try { return targetType.getMethod("valueOf", String.class); } catch (NoSuchMethodException | SecurityException e) { // ignore, the method just does not exist. } try { return targetType.getMethod("parse", CharSequence.class); } catch (NoSuchMethodException | SecurityException e) { // ignore, the method just does not exist. } return null; } /** * Analyze the given domain type and returns the name of its field to use as the document id, if * available. * * @param domainType the domain type to analyze * @return the <strong>single</strong> Java field annotated with {@link DocumentIdField}. If no field * matches the criteria or more than one field is matches these criteria, a * {@link MappingException} is thrown. */ public static Field getIdField(final Class<?> domainType) { final List<Pair<Field, DocumentIdField>> candidateFields = Stream.of(domainType.getDeclaredFields()) .map(field -> new Pair<>(field, field.getAnnotation(DocumentIdField.class))) .filter(pair -> pair.getRight() != null).collect(Collectors.toList()); if (candidateFields.isEmpty()) { throw new MappingException("No field is annotated with @{} in type {}", DocumentIdField.class.getName(), domainType); } else if (candidateFields.size() > 1) { final String fieldNames = candidateFields.stream().map(pair -> pair.getLeft().getName()) .collect(Collectors.joining(", ")); throw new MappingException("More than one field is annotated with @{} in type {}: {}", DocumentIdField.class.getName(), domainType, fieldNames); } return candidateFields.get(0).getLeft(); } private PropertyDescriptor getIdPropertyDescriptor(final Object domainObject, final Field idField) { return Stream.of(this.domainTypeBeanInfo.getPropertyDescriptors()) .filter(propertyDescriptor -> propertyDescriptor.getName().equals(idField.getName())).findFirst() .orElseThrow(() -> new CodecException("Unable to find property descriptor for field '" + idField.getName() + "' in type '" + domainObject.getClass().getName() + "'")); } }