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