001/*
002 * Copyright (c) 2010-2024 Mark Allen, Norbert Bartels.
003 *
004 * Permission is hereby granted, free of charge, to any person obtaining a copy
005 * of this software and associated documentation files (the "Software"), to deal
006 * in the Software without restriction, including without limitation the rights
007 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
008 * copies of the Software, and to permit persons to whom the Software is
009 * furnished to do so, subject to the following conditions:
010 *
011 * The above copyright notice and this permission notice shall be included in
012 * all copies or substantial portions of the Software.
013 *
014 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
015 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
016 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
017 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
018 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
019 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
020 * THE SOFTWARE.
021 */
022package com.restfb;
023
024import static com.restfb.logging.RestFBLogger.MAPPER_LOGGER;
025import static com.restfb.util.ObjectUtil.isEmptyCollectionOrMap;
026import static com.restfb.util.ReflectionUtils.*;
027import static com.restfb.util.StringUtils.isBlank;
028import static com.restfb.util.StringUtils.trimToEmpty;
029import static java.lang.String.format;
030import static java.util.Collections.unmodifiableList;
031import static java.util.Collections.unmodifiableSet;
032
033import java.lang.reflect.Field;
034import java.lang.reflect.InvocationTargetException;
035import java.lang.reflect.Method;
036import java.math.BigDecimal;
037import java.math.BigInteger;
038import java.util.*;
039import java.util.Map.Entry;
040import java.util.stream.Collectors;
041
042import com.restfb.exception.FacebookJsonMappingException;
043import com.restfb.json.*;
044import com.restfb.types.AbstractFacebookType;
045import com.restfb.types.Comments;
046import com.restfb.util.DateUtils;
047import com.restfb.util.ObjectUtil;
048import com.restfb.util.ReflectionUtils;
049import com.restfb.util.StringJsonUtils;
050
051/**
052 * Default implementation of a JSON-to-Java mapper.
053 * 
054 * @author <a href="http://restfb.com">Mark Allen</a>
055 */
056public class DefaultJsonMapper implements JsonMapper {
057
058  private FacebookClient facebookClient;
059
060  /**
061   * Helper to convert {@link JsonValue} into a given type
062   */
063  private final JsonHelper jsonHelper;
064
065  /**
066   * Creates a JSON mapper which will throw {@link com.restfb.exception.FacebookJsonMappingException} whenever an error
067   * occurs when mapping JSON data to Java objects.
068   */
069  public DefaultJsonMapper() {
070    jsonHelper = new JsonHelper();
071  }
072
073  @Override
074  public void setFacebookClient(FacebookClient facebookClient) {
075    this.facebookClient = facebookClient;
076  }
077
078  @Override
079  public <T> List<T> toJavaList(String json, Class<T> type) {
080    ObjectUtil.requireNotNull(type,
081      () -> new FacebookJsonMappingException("You must specify the Java type to map to."));
082    json = trimToEmpty(json);
083
084    checkJsonNotBlank(json);
085
086    if (StringJsonUtils.isObject(json)) {
087      // Sometimes Facebook returns the empty object {} when it really should be
088      // returning an empty list [] (example: do an FQL query for a user's
089      // affiliations - it's a list except when there are none, then it turns
090      // into an object). Check for that special case here.
091      if (StringJsonUtils.isEmptyObject(json)) {
092        MAPPER_LOGGER
093          .trace("Encountered \\{} when we should've seen []. Mapping the \\{} as an empty list and moving on...");
094
095        return new ArrayList<>();
096      }
097
098      // Special case: if the only element of this object is an array called
099      // "data", then treat it as a list. The Graph API uses this convention for
100      // connections and in a few other places, e.g. comments on the Post
101      // object.
102      // Doing this simplifies mapping, so we don't have to worry about having a
103      // little placeholder object that only has a "data" value.
104      try {
105        JsonObject jsonObject = Json.parse(json).asObject();
106        List<String> fieldNames = jsonObject.names();
107
108        if (!fieldNames.isEmpty()) {
109          boolean hasSingleDataProperty = fieldNames.size() == 1;
110          Object jsonDataObject = jsonObject.get(fieldNames.get(0));
111
112          checkObjectIsMappedAsList(json, hasSingleDataProperty, jsonDataObject);
113
114          json = jsonDataObject.toString();
115        }
116      } catch (ParseException e) {
117        // Should never get here, but just in case...
118        throw new FacebookJsonMappingException("Unable to convert Facebook response JSON to a list of " + type.getName()
119            + " instances.  Offending JSON is '" + json + "'.",
120          e);
121      }
122    }
123
124    try {
125      JsonArray jsonArray = Json.parse(json).asArray();
126      List<T> list = new ArrayList<>(jsonArray.size());
127      for (JsonValue jsonValue : jsonArray) {
128        String innerJson = jsonHelper.getStringFrom(jsonValue);
129        innerJson = convertArrayToStringIfNecessary(jsonValue, innerJson);
130        list.add(toJavaObject(innerJson, type));
131      }
132      return unmodifiableList(list);
133    } catch (FacebookJsonMappingException e) {
134      throw e;
135    } catch (Exception e) {
136      throw new FacebookJsonMappingException(
137        "Unable to convert Facebook response JSON to a list of " + type.getName() + " instances", e);
138    }
139  }
140
141  private String convertArrayToStringIfNecessary(JsonValue jsonValue, String innerJson) {
142    // the inner JSON starts with square brackets but the parser don't think this is a JSON array
143    // so we think the parser is right and add quotes around the string
144    // solves Issue #719
145    if (jsonValue.isString() && innerJson.startsWith("[")) {
146      innerJson = '"' + innerJson + '"';
147    }
148    return innerJson;
149  }
150
151  private void checkObjectIsMappedAsList(String json, boolean hasSingleDataProperty, Object jsonDataObject) {
152    if (!hasSingleDataProperty && !(jsonDataObject instanceof JsonArray)) {
153      throw new FacebookJsonMappingException(
154        "JSON is an object but is being mapped as a list instead. Offending JSON is '" + json + "'.");
155    }
156  }
157
158  @Override
159  @SuppressWarnings("unchecked")
160  public <T> T toJavaObject(String json, Class<T> type) {
161    if (StringJsonUtils.isEmptyList(json)) {
162      return toJavaObject(StringJsonUtils.EMPTY_OBJECT, type);
163    }
164
165    checkJsonNotBlank(json);
166    checkJsonNotList(json);
167
168    try {
169      // Are we asked to map to JsonObject? If so, short-circuit right away.
170      if (type.equals(JsonObject.class)) {
171        return (T) Json.parse(json).asObject();
172      }
173
174      List<FieldWithAnnotation<Facebook>> listOfFieldsWithAnnotation = findFieldsWithAnnotation(type, Facebook.class);
175      Set<String> facebookFieldNamesWithMultipleMappings =
176          facebookFieldNamesWithMultipleMappings(listOfFieldsWithAnnotation);
177
178      // If there are no annotated fields, assume we're mapping to a built-in
179      // type. If this is actually the empty object, just return a new instance
180      // of the corresponding Java type.
181      if (listOfFieldsWithAnnotation.isEmpty()) {
182        if (StringJsonUtils.isEmptyObject(json)) {
183          T instance = createInstance(type);
184
185          // If there are any methods annotated with @JsonMappingCompleted,
186          // invoke them.
187          invokeJsonMappingCompletedMethods(instance);
188
189          return instance;
190        } else {
191          return toPrimitiveJavaType(json, type);
192        }
193      }
194
195      // Facebook will sometimes return the string "null".
196      // Check for that and bail early if we find it.
197      if (StringJsonUtils.isNull(json)) {
198        return null;
199      }
200
201      // Facebook will sometimes return the string "false" to mean null.
202      // Check for that and bail early if we find it.
203      if (StringJsonUtils.isFalse(json)) {
204        MAPPER_LOGGER.debug("Encountered 'false' from Facebook when trying to map to {} - mapping null instead.",
205          type.getSimpleName());
206        return null;
207      }
208
209      JsonValue jsonValue = Json.parse(json);
210      T instance = createInstance(type);
211
212      if (instance instanceof JsonObject) {
213        return (T) jsonValue.asObject();
214      }
215
216      if (!jsonValue.isObject()) {
217        return null;
218      }
219
220      JsonObject jsonObject = jsonValue.asObject();
221
222      handleAbstractFacebookType(json, instance);
223
224      // For each Facebook-annotated field on the current Java object, pull data
225      // out of the JSON object and put it in the Java object
226      for (FieldWithAnnotation<Facebook> fieldWithAnnotation : listOfFieldsWithAnnotation) {
227        String facebookFieldName = getFacebookFieldName(fieldWithAnnotation);
228
229        if (!jsonObject.contains(facebookFieldName)
230            && !fieldWithAnnotation.getField().getType().equals(Optional.class)) {
231          MAPPER_LOGGER.trace("No JSON value present for '{}', skipping. JSON is '{}'.", facebookFieldName, json);
232          continue;
233        }
234
235        fieldWithAnnotation.getField().setAccessible(true);
236
237        setJavaFileValue(json, facebookFieldNamesWithMultipleMappings, instance, jsonObject, fieldWithAnnotation,
238          facebookFieldName);
239      }
240
241      // If there are any methods annotated with @JsonMappingCompleted,
242      // invoke them.
243      invokeJsonMappingCompletedMethods(instance);
244
245      return instance;
246    } catch (FacebookJsonMappingException e) {
247      throw e;
248    } catch (Exception e) {
249      throw new FacebookJsonMappingException("Unable to map JSON to Java. Offending JSON is '" + json + "'.", e);
250    }
251  }
252
253  private <T> void setJavaFileValue(String json, Set<String> facebookFieldNamesWithMultipleMappings, T instance,
254      JsonObject jsonObject, FieldWithAnnotation<Facebook> fieldWithAnnotation, String facebookFieldName)
255      throws IllegalAccessException {
256    // Set the Java field's value.
257    //
258    // If we notice that this Facebook field name is mapped more than once,
259    // go into a special mode where we swallow any exceptions that occur
260    // when mapping to the Java field. This is because Facebook will
261    // sometimes return data in different formats for the same field name.
262    // See issues 56 and 90 for examples of this behavior and discussion.
263    try {
264      fieldWithAnnotation.getField().set(instance, toJavaType(fieldWithAnnotation, jsonObject, facebookFieldName));
265    } catch (FacebookJsonMappingException | ParseException | UnsupportedOperationException e) {
266      if (facebookFieldNamesWithMultipleMappings.contains(facebookFieldName)) {
267        logMultipleMappingFailedForField(facebookFieldName, fieldWithAnnotation, json);
268      } else {
269        throw e;
270      }
271    }
272  }
273
274  private <T> void handleAbstractFacebookType(String json, T instance) {
275    if (instance instanceof AbstractFacebookType) {
276      ReflectionUtils.setJson(instance, json);
277    }
278  }
279
280  private void checkJsonNotBlank(String json) {
281    if (isBlank(json)) {
282      throw new FacebookJsonMappingException("JSON is an empty string - can't map it.");
283    }
284  }
285
286  private void checkJsonNotList(String json) {
287    if (StringJsonUtils.isList(json)) {
288      throw new FacebookJsonMappingException("JSON is an array but is being mapped as an object "
289          + "- you should map it as a List instead. Offending JSON is '" + json + "'.");
290    }
291  }
292
293  /**
294   * Finds and invokes methods on {@code object} that are annotated with the {@code @JsonMappingCompleted} annotation.
295   * <p>
296   * This will even work on {@code private} methods.
297   * 
298   * @param object
299   *          The object on which to invoke the method.
300   * @throws IllegalAccessException
301   *           If unable to invoke the method.
302   * @throws InvocationTargetException
303   *           If unable to invoke the method.
304   */
305  protected void invokeJsonMappingCompletedMethods(Object object)
306      throws IllegalAccessException, InvocationTargetException {
307    for (Method method : findMethodsWithAnnotation(object.getClass(), JsonMappingCompleted.class)) {
308      method.setAccessible(true);
309
310      int methodParameterCount = method.getParameterTypes().length;
311
312      if (methodParameterCount == 0) {
313        method.invoke(object);
314      } else if (methodParameterCount == 1 && JsonMapper.class.equals(method.getParameterTypes()[0])) {
315        method.invoke(object, this);
316      } else {
317        throw new FacebookJsonMappingException(
318          format("Methods annotated with @%s must take 0 parameters or a single %s parameter. Your method was %s",
319            JsonMappingCompleted.class.getSimpleName(), JsonMapper.class.getSimpleName(), method));
320      }
321    }
322  }
323
324  /**
325   * Dumps out a log message when one of a multiple-mapped Facebook field name JSON-to-Java mapping operation fails.
326   * 
327   * @param facebookFieldName
328   *          The Facebook field name.
329   * @param fieldWithAnnotation
330   *          The Java field to map to and its annotation.
331   * @param json
332   *          The JSON that failed to map to the Java field.
333   */
334  protected void logMultipleMappingFailedForField(String facebookFieldName,
335      FieldWithAnnotation<Facebook> fieldWithAnnotation, String json) {
336    if (!MAPPER_LOGGER.isTraceEnabled()) {
337      return;
338    }
339
340    Field field = fieldWithAnnotation.getField();
341
342    MAPPER_LOGGER.trace(
343      "Could not map '{}' to {}. {}, but continuing on because '{}"
344          + "' is mapped to multiple fields in {}. JSON is {}",
345      facebookFieldName, field.getDeclaringClass().getSimpleName(), field.getName(), facebookFieldName,
346      field.getDeclaringClass().getSimpleName(), json);
347  }
348
349  /**
350   * For a Java field annotated with the {@code Facebook} annotation, figure out what the corresponding Facebook JSON
351   * field name to map to it is.
352   * 
353   * @param fieldWithAnnotation
354   *          A Java field annotated with the {@code Facebook} annotation.
355   * @return The Facebook JSON field name that should be mapped to this Java field.
356   */
357  protected String getFacebookFieldName(FieldWithAnnotation<Facebook> fieldWithAnnotation) {
358    String facebookFieldName = fieldWithAnnotation.getAnnotation().value();
359    Field field = fieldWithAnnotation.getField();
360
361    // If no Facebook field name was specified in the annotation, assume
362    // it's the same name as the Java field
363    if (isBlank(facebookFieldName)) {
364      MAPPER_LOGGER.trace("No explicit Facebook field name found for {}, so defaulting to the field name itself ({})",
365        field, field.getName());
366
367      facebookFieldName = field.getName();
368    }
369
370    return facebookFieldName;
371  }
372
373  /**
374   * Finds any Facebook JSON fields that are mapped to more than 1 Java field.
375   * 
376   * @param fieldsWithAnnotation
377   *          Java fields annotated with the {@code Facebook} annotation.
378   * @return Any Facebook JSON fields that are mapped to more than 1 Java field.
379   */
380  protected Set<String> facebookFieldNamesWithMultipleMappings(
381      List<FieldWithAnnotation<Facebook>> fieldsWithAnnotation) {
382    Map<String, Integer> facebookFieldsNamesWithOccurrenceCount = new HashMap<>();
383
384    // Get a count of Facebook field name occurrences for each
385    // @Facebook-annotated field
386    fieldsWithAnnotation.forEach(field -> occurrenceCounter(facebookFieldsNamesWithOccurrenceCount, field));
387
388    // Pull out only those field names with multiple mappings
389    Set<String> facebookFieldNamesWithMultipleMappings = facebookFieldsNamesWithOccurrenceCount.entrySet().stream()
390      .filter(entry -> entry.getValue() > 1).map(Entry::getKey).collect(Collectors.toSet());
391
392    return unmodifiableSet(facebookFieldNamesWithMultipleMappings);
393  }
394
395  private void occurrenceCounter(Map<String, Integer> facebookFieldsNamesWithOccurrenceCount,
396      FieldWithAnnotation<Facebook> field) {
397    String fieldName = getFacebookFieldName(field);
398    int occurrenceCount = facebookFieldsNamesWithOccurrenceCount.getOrDefault(fieldName, 0);
399    facebookFieldsNamesWithOccurrenceCount.put(fieldName, occurrenceCount + 1);
400  }
401
402  @Override
403  public String toJson(Object object) {
404    return toJson(object, false);
405  }
406
407  @Override
408  public String toJson(Object object, boolean ignoreNullValuedProperties) {
409    JsonValue jsonObj = toJsonInternal(object, ignoreNullValuedProperties);
410    return jsonHelper.getStringFrom(jsonObj);
411  }
412
413  /**
414   * Recursively marshal the given {@code object} to JSON.
415   * <p>
416   * Used by {@link #toJson(Object)}.
417   * 
418   * @param object
419   *          The object to marshal.
420   * @param ignoreNullValuedProperties
421   *          If this is {@code true}, no Javabean properties with {@code null} values will be included in the generated
422   *          JSON.
423   * @return JSON representation of the given {@code object}.
424   * @throws FacebookJsonMappingException
425   *           If an error occurs while marshaling to JSON.
426   */
427  protected JsonValue toJsonInternal(Object object, boolean ignoreNullValuedProperties) {
428    if (object == null) {
429      return Json.NULL;
430    }
431
432    if (object instanceof JsonValue) {
433      return (JsonValue) object;
434    }
435
436    if (object instanceof List<?>) {
437      return convertListToJsonArray((List<?>) object, ignoreNullValuedProperties);
438    }
439
440    if (object instanceof Map<?, ?>) {
441      return convertMapToJsonObject(object, ignoreNullValuedProperties);
442    }
443
444    if (isPrimitive(object)) {
445      return javaTypeToJsonValue(object);
446    }
447
448    if (object instanceof Optional) {
449      return convertOptionalToJsonValue((Optional) object, ignoreNullValuedProperties);
450    }
451
452    if (object instanceof BigInteger) {
453      return Json.value(((BigInteger) object).longValue());
454    }
455
456    if (object instanceof BigDecimal) {
457      return Json.value(((BigDecimal) object).doubleValue());
458    }
459
460    if (object instanceof Enum) {
461      Enum e = (Enum) object;
462      return Json.value(getAlternativeEnumValue(e).orElseGet(e::name));
463    }
464
465    if (object instanceof Date) {
466      return Json.value(DateUtils.toLongFormatFromDate((Date) object));
467    }
468
469    // We've passed the special-case bits, so let's try to marshal this as a
470    // plain old Javabean...
471
472    List<FieldWithAnnotation<Facebook>> fieldsWithAnnotation =
473        findFieldsWithAnnotation(object.getClass(), Facebook.class);
474
475    JsonObject jsonObject = new JsonObject();
476
477    // No longer throw an exception in this case. If there are multiple fields
478    // with the same @Facebook value, it's luck of the draw which is picked for
479    // JSON marshaling.
480    // TODO: A better implementation would query each duplicate-mapped field. If
481    // it has is a non-null value and the other duplicate values are null, use
482    // the non-null field.
483    Set<String> facebookFieldNamesWithMultipleMappings = facebookFieldNamesWithMultipleMappings(fieldsWithAnnotation);
484    if (!facebookFieldNamesWithMultipleMappings.isEmpty() && MAPPER_LOGGER.isDebugEnabled()) {
485      MAPPER_LOGGER.debug(
486        "Unable to convert to JSON because multiple @{} annotations for the same name are present: {}",
487        Facebook.class.getSimpleName(), facebookFieldNamesWithMultipleMappings);
488    }
489
490    for (FieldWithAnnotation<Facebook> fieldWithAnnotation : fieldsWithAnnotation) {
491      String facebookFieldName = getFacebookFieldName(fieldWithAnnotation);
492      fieldWithAnnotation.getField().setAccessible(true);
493
494      try {
495        Object fieldValue = fieldWithAnnotation.getField().get(object);
496
497        if (fieldValue instanceof Connection) {
498          continue;
499        }
500
501        if (!(ignoreNullValuedProperties
502            && (fieldValue == null || isEmptyOptional(fieldValue) || isEmptyCollectionOrMap(fieldValue)))) {
503          jsonObject.add(facebookFieldName, toJsonInternal(fieldValue, ignoreNullValuedProperties));
504        }
505      } catch (Exception e) {
506        throw new FacebookJsonMappingException(
507          "Unable to process field '" + facebookFieldName + "' for " + object.getClass(), e);
508      }
509    }
510
511    return jsonObject;
512  }
513
514  private boolean isEmptyOptional(Object fieldValue) {
515    return fieldValue instanceof Optional && !((Optional) fieldValue).isPresent();
516  }
517
518  private JsonArray convertListToJsonArray(List<?> objects, boolean ignoreNullValuedProperties) {
519    JsonArray jsonArray = new JsonArray();
520    objects.stream().map(o -> toJsonInternal(o, ignoreNullValuedProperties)).forEach(jsonArray::add);
521    return jsonArray;
522  }
523
524  private JsonObject convertMapToJsonObject(Object object, boolean ignoreNullValuedProperties) {
525    JsonObject jsonObject = new JsonObject();
526    for (Entry<?, ?> entry : ((Map<?, ?>) object).entrySet()) {
527      if (!(entry.getKey() instanceof String)) {
528        throw new FacebookJsonMappingException("Your Map keys must be of type " + String.class
529            + " in order to be converted to JSON.  Offending map is " + object);
530      }
531
532      try {
533        jsonObject.add((String) entry.getKey(), toJsonInternal(entry.getValue(), ignoreNullValuedProperties));
534      } catch (ParseException | IllegalArgumentException e) {
535        throw new FacebookJsonMappingException(
536          "Unable to process value '" + entry.getValue() + "' for key '" + entry.getKey() + "' in Map " + object, e);
537      }
538    }
539    return jsonObject;
540  }
541
542  private JsonValue convertOptionalToJsonValue(Optional<?> object, boolean ignoreNullValuedProperties) {
543    return toJsonInternal(object.orElse(null), ignoreNullValuedProperties);
544  }
545
546  /**
547   * Given a {@code json} value of something like {@code MyValue} or {@code 123} , return a representation of that value
548   * of type {@code type}.
549   * <p>
550   * This is to support non-legal JSON served up by Facebook for API calls like {@code Friends.get} (example result:
551   * {@code [222333,1240079]}).
552   * 
553   * @param <T>
554   *          The Java type to map to.
555   * @param json
556   *          The non-legal JSON to map to the Java type.
557   * @param type
558   *          Type token.
559   * @return Java representation of {@code json}.
560   * @throws FacebookJsonMappingException
561   *           If an error occurs while mapping JSON to Java.
562   */
563  @SuppressWarnings("unchecked")
564  protected <T> T toPrimitiveJavaType(String json, Class<T> type) {
565
566    json = jsonHelper.cleanString(json);
567
568    if (typeIsString(type)) {
569      return (T) json;
570    }
571    if (typeIsInteger(type)) {
572      return (T) Integer.valueOf(json);
573    }
574    if (typeIsBoolean(type)) {
575      return (T) Boolean.valueOf(json);
576    }
577    if (typeIsLong(type)) {
578      return (T) Long.valueOf(json);
579    }
580    if (typeIsDouble(type)) {
581      return (T) Double.valueOf(json);
582    }
583    if (typeIsFloat(type)) {
584      return (T) Float.valueOf(json);
585    }
586    if (typeIsBigInteger(type)) {
587      return (T) new BigInteger(json);
588    }
589    if (typeIsBigDecimal(type)) {
590      return (T) new BigDecimal(json);
591    }
592
593    throw new FacebookJsonMappingException("Don't know how to map JSON to " + type
594        + ". Are you sure you're mapping to the right class?\nOffending JSON is '" + json + "'.");
595  }
596
597  /**
598   * Extracts JSON data for a field according to its {@code Facebook} annotation and returns it converted to the proper
599   * Java type.
600   * 
601   * @param fieldWithAnnotation
602   *          The field/annotation pair which specifies what Java type to convert to.
603   * @param jsonObject
604   *          "Raw" JSON object to pull data from.
605   * @param facebookFieldName
606   *          Specifies what JSON field to pull "raw" data from.
607   * @return A
608   * @throws ParseException
609   *           If an error occurs while mapping JSON to Java.
610   * @throws FacebookJsonMappingException
611   *           If an error occurs while mapping JSON to Java.
612   */
613  protected Object toJavaType(FieldWithAnnotation<Facebook> fieldWithAnnotation, JsonObject jsonObject,
614      String facebookFieldName) {
615    Class<?> type = fieldWithAnnotation.getField().getType();
616    JsonValue rawValue = jsonObject.get(facebookFieldName);
617
618    // Short-circuit right off the bat if we've got a null value, but Optionals are created nevertheless.
619    if (jsonHelper.isNull(rawValue)) {
620      if (typeIsOptional(type)) {
621        return Optional.empty();
622      }
623      return null;
624    }
625
626    if (typeIsString(type)) {
627      /*
628       * Special handling here for better error checking.
629       *
630       * Since {@code JsonObject.getString()} will return literal JSON text even if it's _not_ a JSON string, we check
631       * the marshaled type and bail if needed. For example, calling {@code JsonObject.getString("results")} on the
632       * below JSON...
633       *
634       * <code> { "results":[ {"name":"Mark Allen"} ] } </code>
635       *
636       * ... would return the string {@code "[{"name":"Mark Allen"}]"} instead of throwing an error. So we throw the
637       * error ourselves.
638       *
639       * Per Antonello Naccarato, sometimes FB will return an empty JSON array instead of an empty string. Look for that
640       * here.
641       */
642      if (jsonHelper.isEmptyArray(rawValue)) {
643        MAPPER_LOGGER.trace("Coercing an empty JSON array to an empty string for {}", fieldWithAnnotation);
644
645        return "";
646      }
647
648      /*
649       * If the user wants a string, _always_ give her a string.
650       *
651       * This is useful if, for example, you've got a @Facebook-annotated string field that you'd like to have a numeric
652       * type shoved into.
653       *
654       * User beware: this will turn *anything* into a string, which might lead to results you don't expect.
655       */
656      return jsonHelper.getStringFrom(rawValue);
657    }
658
659    if (typeIsInteger(type)) {
660      return jsonHelper.getIntegerFrom(rawValue);
661    }
662    if (typeIsBoolean(type)) {
663      return jsonHelper.getBooleanFrom(rawValue);
664    }
665    if (typeIsLong(type)) {
666      return jsonHelper.getLongFrom(rawValue);
667    }
668    if (typeIsDouble(type)) {
669      return jsonHelper.getDoubleFrom(rawValue);
670    }
671    if (typeIsFloat(type)) {
672      return jsonHelper.getFloatFrom(rawValue);
673    }
674    if (typeIsBigInteger(type)) {
675      return jsonHelper.getBigIntegerFrom(rawValue);
676    }
677    if (typeIsBigDecimal(type)) {
678      return jsonHelper.getBigDecimalFrom(rawValue);
679    }
680    if (typeIsList(type)) {
681      return toJavaList(rawValue.toString(), getFirstParameterizedTypeArgument(fieldWithAnnotation.getField()));
682    }
683    if (typeIsMap(type)) {
684      return convertRawValueToMap(rawValue.toString(), fieldWithAnnotation.getField());
685    }
686
687    if (typeIsOptional(type)) {
688      return Optional.ofNullable(
689        toJavaObject(rawValue.toString(), getFirstParameterizedTypeArgument(fieldWithAnnotation.getField())));
690    }
691
692    if (type.isEnum()) {
693      Optional<Enum> enumTypeOpt = convertRawValueToEnumType(type, rawValue);
694      if (enumTypeOpt.isPresent()) {
695        return enumTypeOpt.get();
696      }
697    }
698
699    if (typeIsDate(type)) {
700      return DateUtils.toDateFromLongFormat(jsonHelper.getStringFrom(rawValue));
701    }
702
703    if (Connection.class.equals(type)) {
704      Optional<Connection> createdConnectionOpt = convertRawValueToConnection(fieldWithAnnotation, rawValue);
705      if (createdConnectionOpt.isPresent()) {
706        return createdConnectionOpt.get();
707      }
708    }
709
710    String rawValueAsString = jsonHelper.getStringFrom(rawValue);
711
712    // Hack for issue #76 where FB will sometimes return a Post's Comments as
713    // "[]" instead of an object type (wtf)F
714    if (Comments.class.isAssignableFrom(type) && rawValue instanceof JsonArray) {
715      MAPPER_LOGGER.debug(
716        "Encountered comment array '{}' but expected a {} object instead.  Working around that by coercing "
717            + "into an empty {} instance...",
718        rawValueAsString, Comments.class.getSimpleName(), Comments.class.getSimpleName());
719
720      JsonObject workaroundJsonObject = new JsonObject();
721      workaroundJsonObject.add("total_count", 0);
722      workaroundJsonObject.add("data", new JsonArray());
723      rawValueAsString = workaroundJsonObject.toString();
724    }
725
726    // Some other type - recurse into it
727    return toJavaObject(rawValueAsString, type);
728  }
729
730  private Optional<Connection> convertRawValueToConnection(FieldWithAnnotation<Facebook> fieldWithAnnotation,
731      JsonValue rawValue) {
732    if (null != facebookClient) {
733      return Optional.of(new Connection(facebookClient, jsonHelper.getStringFrom(rawValue),
734        getFirstParameterizedTypeArgument(fieldWithAnnotation.getField())));
735    } else {
736      MAPPER_LOGGER.warn(
737        "Skipping java field {}, because it has the type Connection, but the given facebook client is null",
738        fieldWithAnnotation.getField().getName());
739    }
740    return Optional.empty();
741  }
742
743  private Optional<Enum> convertRawValueToEnumType(Class<?> type, JsonValue rawValue) {
744    Class<? extends Enum> enumType = type.asSubclass(Enum.class);
745    Map<String, Enum> annotatedEnumMapping = new HashMap<>();
746    if (enumType.getEnumConstants() != null) {
747      for (Enum e : enumType.getEnumConstants()) {
748        getAlternativeEnumValue(e).ifPresent(s -> annotatedEnumMapping.put(s, e));
749      }
750    }
751
752    Enum e = annotatedEnumMapping.get(rawValue.asString());
753    if (e != null) {
754      return Optional.of(e);
755    } else {
756      MAPPER_LOGGER.debug(
757        "No suitable annotated enum constant found for string {} and enum {}, use default enum detection.",
758        rawValue.asString(), enumType.getName());
759    }
760
761    try {
762      return Optional.of(Enum.valueOf(enumType, rawValue.asString()));
763    } catch (IllegalArgumentException iae) {
764      MAPPER_LOGGER.debug("Cannot map string {} to enum {}, try fallback toUpperString next...", rawValue.asString(),
765        enumType.getName());
766    }
767    try {
768      return Optional.of(Enum.valueOf(enumType, rawValue.asString().toUpperCase()));
769    } catch (IllegalArgumentException iae) {
770      MAPPER_LOGGER.debug("Mapping string {} to enum {} not possible", rawValue.asString(), enumType.getName());
771    }
772    return Optional.empty();
773  }
774
775  private Optional<String> getAlternativeEnumValue(Enum e) {
776    try {
777      Field f = e.getClass().getField(e.toString());
778      Facebook a = f.getAnnotation(Facebook.class);
779      if (a != null && !a.value().isEmpty()) {
780        return Optional.of(a.value());
781      }
782    } catch (NoSuchFieldException ex) {
783      MAPPER_LOGGER.debug("Enum constant without annotation, skip annotation value detection for {}", e);
784    }
785    return Optional.empty();
786  }
787
788  private Map convertRawValueToMap(String json, Field field) {
789    Class<?> firstParam = getFirstParameterizedTypeArgument(field);
790    if (!typeIsString(firstParam)) {
791      throw new FacebookJsonMappingException("The java type map needs to have a 'String' key, but is " + firstParam);
792    }
793
794    Class<?> secondParam = getSecondParameterizedTypeArgument(field);
795
796    if (StringJsonUtils.isObject(json)) {
797      JsonObject jsonObject = Json.parse(json).asObject();
798      Map<String, Object> map = new HashMap<>();
799      for (String key : jsonObject.names()) {
800        String value = jsonHelper.getStringFrom(jsonObject.get(key));
801        map.put(key, toJavaObject(value, secondParam));
802      }
803      return map;
804    }
805
806    // @TODO: return emptyMap here, to allow the devs to go on without null check (v2024)
807    return null;
808  }
809
810  private JsonValue javaTypeToJsonValue(Object object) {
811    if (object == null) {
812      return Json.NULL;
813    }
814
815    Class<?> type = object.getClass();
816
817    if (typeIsString(type)) {
818      return Json.value((String) object);
819    }
820
821    if (typeIsInteger(type)) {
822      return Json.value((Integer) object);
823    }
824
825    if (typeIsBoolean(type)) {
826      return Json.value((Boolean) object);
827    }
828
829    if (typeIsLong(type)) {
830      return Json.value((Long) object);
831    }
832
833    if (typeIsDouble(type)) {
834      return Json.value((Double) object);
835    }
836
837    if (typeIsFloat(type)) {
838      return Json.value((Float) object);
839    }
840
841    if (typeIsByte(type)) {
842      return Json.value((Byte) object);
843    }
844
845    if (typeIsShort(type)) {
846      return Json.value((Short) object);
847    }
848
849    if (typeIsCharacter(type)) {
850      return Json.value(Character.toString((Character) object));
851    }
852
853    return Json.NULL;
854
855  }
856
857  private boolean typeIsCharacter(Class<?> type) {
858    return Character.class.equals(type) || Character.TYPE.equals(type);
859  }
860
861  private boolean typeIsShort(Class<?> type) {
862    return Short.class.equals(type) || Short.TYPE.equals(type);
863  }
864
865  private boolean typeIsByte(Class<?> type) {
866    return Byte.class.equals(type) || Byte.TYPE.equals(type);
867  }
868
869  private boolean typeIsFloat(Class<?> type) {
870    return Float.class.equals(type) || Float.TYPE.equals(type);
871  }
872
873  private boolean typeIsLong(Class<?> type) {
874    return Long.class.equals(type) || Long.TYPE.equals(type);
875  }
876
877  private boolean typeIsInteger(Class<?> type) {
878    return Integer.class.equals(type) || Integer.TYPE.equals(type);
879  }
880
881  private boolean typeIsBigDecimal(Class<?> type) {
882    return BigDecimal.class.equals(type);
883  }
884
885  private boolean typeIsBigInteger(Class<?> type) {
886    return BigInteger.class.equals(type);
887  }
888
889  private boolean typeIsDouble(Class<?> type) {
890    return Double.class.equals(type) || Double.TYPE.equals(type);
891  }
892
893  private boolean typeIsBoolean(Class<?> type) {
894    return Boolean.class.equals(type) || Boolean.TYPE.equals(type);
895  }
896
897  private boolean typeIsString(Class<?> type) {
898    return String.class.equals(type);
899  }
900
901  private boolean typeIsOptional(Class<?> type) {
902    return Optional.class.equals(type);
903  }
904
905  private boolean typeIsMap(Class<?> type) {
906    return Map.class.equals(type);
907  }
908
909  private boolean typeIsList(Class<?> type) {
910    return List.class.equals(type);
911  }
912
913  private boolean typeIsDate(Class<?> type) {
914    return Date.class.equals(type);
915  }
916}