All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.twitter.finatra.http.internal.marshalling.MessageInjectableValues.scala Maven / Gradle / Ivy

The newest version!
package com.twitter.finatra.http.internal.marshalling

import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.core.{JsonParser, JsonToken, ObjectCodec}
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.databind.exc.InvalidDefinitionException
import com.fasterxml.jackson.databind.introspect.AnnotatedMember
import com.fasterxml.jackson.databind.node.TreeTraversingParser
import com.fasterxml.jackson.databind.{
  BeanProperty,
  DeserializationContext,
  JavaType,
  JsonDeserializer,
  JsonMappingException,
  JsonNode
}
import com.google.inject.Injector
import com.twitter.finagle.http.{Message, Request, Response}
import com.twitter.finatra.jackson.ScalaObjectMapper
import com.twitter.finatra.jackson.caseclass.exceptions.{
  CaseClassFieldMappingException,
  InjectableValuesException
}
import com.twitter.finatra.jackson.caseclass.{
  DefaultInjectableValues,
  Types,
  isNotAssignableFrom,
  newBeanProperty,
  resolveSubType
}
import com.twitter.finatra.json.annotations.InjectableValue
import com.twitter.finatra.http.annotations.{FormParam, Header, QueryParam, RouteParam}
import com.twitter.finatra.validation.ErrorCode
import com.twitter.finatra.validation.ValidationResult.Invalid
import com.twitter.inject.utils.AnnotationUtils
import java.lang.annotation.Annotation
import scala.collection.JavaConverters._

private object MessageInjectableValues {
  val SeqWithSingleEmptyString: Seq[String] = Seq("")

  val requestParamsAnnotations: Seq[Class[_ <: Annotation]] =
    Seq(classOf[RouteParam], classOf[QueryParam], classOf[FormParam])

  val annotations: Seq[Class[_ <: Annotation]] =
    Seq(classOf[RouteParam], classOf[QueryParam], classOf[FormParam], classOf[Header])

  private[http] class RepeatedCommaSeparatedQueryParameterException(fieldName: String)
      extends CaseClassFieldMappingException(
        null,
        Invalid(
          s"Repeating $fieldName is not allowed. Pass multiple values as a single comma-separated string.",
          ErrorCode.RepeatedCommaSeparatedCollection)
      )
}

private[http] class MessageInjectableValues(
  injector: Injector,
  objectMapper: ScalaObjectMapper,
  message: Message)
    extends DefaultInjectableValues(injector) {
  import MessageInjectableValues._

  /**
   * Lookup the key using the data in the Request object or objects in the object graph.
   *
   * @note this class uses nulls extensively as it is an integration with the Jackson
   *       java library.
   *
   * @param valueId Key for looking up the value
   * @param context DeserializationContext
   * @param forProperty BeanProperty
   * @param beanInstance Bean instance
   * @return the injected value
   */
  override final def findInjectableValue(
    valueId: Object,
    context: DeserializationContext,
    forProperty: BeanProperty,
    beanInstance: Object
  ): Object = message match {
    case request: Request if injector != null =>
      handle(
        valueId,
        context,
        forProperty,
        beanInstance,
        fieldNameForAnnotation(forProperty),
        request
      )
    case response: Response if injector != null =>
      handle(
        valueId,
        context,
        forProperty,
        beanInstance,
        fieldNameForAnnotation(forProperty),
        response
      )
    case _ =>
      null
  }

  /** Handle [[Request]] types */
  private[this] def handle(
    valueId: Object,
    context: DeserializationContext,
    forProperty: BeanProperty,
    beanInstance: Object,
    fieldName: String,
    request: Request
  ): Object = {
    try {
      if (isRequest(forProperty)) {
        request
      } else if (hasAnnotation(forProperty, requestParamsAnnotations)) {
        if (forProperty.getType.isCollectionLikeType) {
          request.params.getAll(fieldName) match {
            case propertyValue: Seq[String]
                if propertyValue.nonEmpty || request.params.contains(fieldName) =>
              val separatedValues = handleCommaSeparatedLists(forProperty, fieldName, propertyValue)
              val value = handleEmptySeq(forProperty, separatedValues)
              val modifiedParamsValue = handleExtendedBooleans(forProperty, value)
              convert(valueId, context, forProperty, modifiedParamsValue)
            case _ => null
          }
        } else {
          request.params.get(fieldName) match {
            case Some(value) =>
              val modifiedParamsValue = handleExtendedBooleans(forProperty, value)
              convert(valueId, context, forProperty, modifiedParamsValue)
            case _ => null
          }
        }
      } else if (forProperty.getContextAnnotation(classOf[Header]) != null) {
        getHeader(valueId, context, forProperty, fieldName, request)
      } else {
        // handle if the field is annotated with an injection annotation
        super.findInjectableValue(valueId, context, forProperty, beanInstance)
      }
    } catch {
      // Only translate `InvalidDefinitionException` to InjectableValuesException,
      // all others escape to be handled elsewhere.
      case ex: InvalidDefinitionException =>
        debug(ex.getMessage, ex)
        throw InjectableValuesException(
          forProperty.getMember.getDeclaringClass,
          forProperty.getName)
    }
  }

  /** Handle [[Response]] types */
  private[this] def handle(
    valueId: Object,
    context: DeserializationContext,
    forProperty: BeanProperty,
    beanInstance: Object,
    fieldName: String,
    response: Response
  ): Object = {
    try {
      if (isResponse(forProperty)) {
        response
      } else if (forProperty.getContextAnnotation(classOf[Header]) != null) {
        getHeader(valueId, context, forProperty, fieldName, response)
      } else if (hasAnnotation(forProperty, requestParamsAnnotations)) {
        // request annotations are not supported for parsing a response
        val message =
          s"Unable to inject field '$fieldName'. ${classOf[Request].getSimpleName}-specific " +
            s"annotations: [${requestParamsAnnotations.map(a => s"@${a.getSimpleName}").mkString(", ")}] " +
            s"are not supported with a ${classOf[Response].getName}."
        throw new InjectableValuesException(message)
      } else {
        // handle if the field is annotated with an injection annotation
        super.findInjectableValue(valueId, context, forProperty, beanInstance)
      }
    } catch {
      // Only translate `InvalidDefinitionException` to InjectableValuesException,
      // all others escape to be handled elsewhere.
      case ex: InvalidDefinitionException =>
        debug(ex.getMessage, ex)
        throw InjectableValuesException(
          forProperty.getMember.getDeclaringClass,
          forProperty.getName)
    }
  }

  /** Retrieve a Header value */
  private[this] def getHeader(
    valueId: Object,
    context: DeserializationContext,
    forProperty: BeanProperty,
    name: String,
    message: Message
  ): Object = {
    message.headerMap.get(name) match {
      case Some(p) => convert(valueId, context, forProperty, p)
      case None => null
    }
  }

  /** Try to convert */
  private[this] def convert(
    valueId: Object,
    context: DeserializationContext,
    forProperty: BeanProperty,
    propertyValue: Any
  ): Object = {
    if (forProperty.getType.hasRawClass(classOf[Option[_]])) {
      if (propertyValue == "") {
        None
      } else {
        Option(
          convert(
            valueId,
            context,
            forProperty,
            forProperty.getType.containedType(0),
            propertyValue))
      }
    } else if (forProperty.getType.hasRawClass(classOf[Boolean]) && propertyValue == "") {
      // for backwards compatibility: injected booleans with no value should
      // return null and not attempt conversion
      null
    } else {
      convert(valueId, context, forProperty, forProperty.getType, propertyValue)
    }
  }

  /** Convert based on a given [[JavaType]] */
  private[this] def convert(
    valueId: Object,
    context: DeserializationContext,
    forProperty: BeanProperty,
    javaType: JavaType,
    propertyValue: Any
  ): Object = {
    val withAnnotations = newBeanProperty(
      valueId,
      context,
      javaType = javaType,
      optionalJavaType = None,
      annotatedParameter = null,
      annotations = getAllAnnotations(context, forProperty.getMember, javaType),
      name = forProperty.getName,
      index = 0 // only one value
    )
    convertWithContext(context, withAnnotations, withAnnotations.getType, propertyValue)
  }

  /** Try to convert with context */
  private[this] def convertWithContext(
    context: DeserializationContext,
    forProperty: BeanProperty,
    javaType: JavaType,
    propertyValue: Any
  ): Object = {
    // not a primitive, not a string and starts with a START_OBJECT token
    def shouldParseAsObjectType(javaType: JavaType, json: String): Boolean = {
      !javaType.isPrimitive && !javaType.hasRawClass(classOf[String]) &&
      json.startsWith(JsonToken.START_OBJECT.asString)
    }

    val jsonNode: JsonNode = propertyValue match {
      case json: String if shouldParseAsObjectType(javaType, json) =>
        // only parse into a tree node if the incoming java type is not primitive or String type
        objectMapper.underlying.readTree(objectMapper.underlying.getFactory.createParser(json))
      case _ =>
        objectMapper.underlying.valueToTree[JsonNode](propertyValue)
    }
    val treeTraversingParser = new TreeTraversingParser(jsonNode, objectMapper.underlying)
    try {
      // advance the parser to the next token for deserialization via deserializer
      treeTraversingParser.nextToken
      Option(forProperty.getAnnotation(classOf[JsonDeserialize])) match {
        case Some(annotation: JsonDeserialize)
            if isNotAssignableFrom(annotation.using, classOf[JsonDeserializer.None]) =>
          // Jackson doesn't seem to properly find deserializers specified with `@JsonDeserialize`
          // unless they are contextual, so we manually lookup and instantiate.
          Option(context.deserializerInstance(forProperty.getMember, annotation.using)) match {
            case Some(deserializer) =>
              deserializer.deserialize(treeTraversingParser, context)
            case _ =>
              context.handleInstantiationProblem(
                javaType.getRawClass,
                annotation.using.toString,
                JsonMappingException.from(
                  context,
                  "Unable to locate/create deserializer specified by: " +
                    s"${annotation.getClass.getName}(using = ${annotation.using()})")
              )
          }
        case Some(annotation: JsonDeserialize)
            if isNotAssignableFrom(annotation.contentAs, classOf[java.lang.Void]) =>
          readPropertyValue(
            context,
            treeTraversingParser,
            objectMapper.underlying,
            forProperty,
            propertyValue,
            Some(annotation.contentAs))
        case _ =>
          readPropertyValue(
            context,
            treeTraversingParser,
            objectMapper.underlying,
            forProperty,
            propertyValue,
            None)
      }
    } finally {
      treeTraversingParser.close()
    }
  }

  /** Finally, read the property value */
  private[this] def readPropertyValue(
    context: DeserializationContext,
    jsonParser: JsonParser,
    fieldCodec: ObjectCodec,
    forProperty: BeanProperty,
    propertyValue: Any,
    subTypeClazz: Option[Class[_]]
  ): Object = {
    val resolvedType = resolveSubType(context, forProperty.getType, subTypeClazz)
    if (resolvedType.isPrimitive || resolvedType.hasRawClass(classOf[String])) {
      // for backwards compatibility we use convert which ignores mangled primitives (returns null)
      objectMapper.convert(
        propertyValue,
        context.constructType(Types.wrapperType(resolvedType.getRawClass)))
    } else {
      // need to check the bean property and potentially the raw class:
      // in the case where the `@JsonTypeInfo` is on a field in the case class
      // being marshalled here, the annotation will not occur in the bean property
      // so we must also scan the raw class
      Option(forProperty.getAnnotation(classOf[JsonTypeInfo]))
        .orElse(Option(resolvedType.getRawClass.getAnnotation(classOf[JsonTypeInfo]))) match {
        case Some(_) =>
          // for polymorphic types we cannot contextualize
          context.readValue(jsonParser, resolvedType)
        case _ =>
          // this will properly resolve Jackson Annotations on the BeanProperty handling contextualization
          context.readPropertyValue(jsonParser, forProperty, resolvedType)
      }
    }
  }

  /** Finds all annotations from the [[AnnotatedMember]] and on the mix-in class */
  private[this] def getAllAnnotations(
    context: DeserializationContext,
    annotatedMember: AnnotatedMember,
    javaType: JavaType
  ): Iterable[Annotation] = {
    annotatedMember.getAllAnnotations.annotations.asScala ++
      findMixInAnnotations(context, javaType)
  }

  /** We only read the class-level annotations as we do not handle anything per-field of the injectable type in this class */
  private[this] def findMixInAnnotations(
    context: DeserializationContext,
    javaType: JavaType
  ): Iterable[Annotation] = {
    Option(context.getConfig.findMixInClassFor(javaType.getRawClass)) match {
      case Some(mixinClazz) if !mixinClazz.isPrimitive =>
        // we explicitly do not read a mix-in for a primitive type
        mixinClazz.getAnnotations.toIterable
      case _ =>
        Iterable.empty[Annotation]
    }
  }

  /** We do not try to also support any `@JsonProperty` annotation on the field */
  private[this] def fieldNameForAnnotation(forProperty: BeanProperty): String = {
    findAnnotation(forProperty, annotations) match {
      case Some(annotation) =>
        AnnotationUtils.getValueIfAnnotatedWith[InjectableValue](annotation) match {
          case Some(value) if value != null && value.nonEmpty => value
          case _ => forProperty.getName
        }
      case _ =>
        forProperty.getName
    }
  }

  private[this] def handleCommaSeparatedLists(
    forProperty: BeanProperty,
    fieldName: String,
    propertyValue: Seq[String]
  ): Seq[String] = {
    Option(forProperty.getContextAnnotation(classOf[QueryParam])) match {
      case Some(queryParam) if queryParam.commaSeparatedList =>
        if (propertyValue.size > 1) {
          throw new RepeatedCommaSeparatedQueryParameterException(fieldName)
        } else {
          propertyValue.flatMap(_.split(','))
        }
      case _ =>
        propertyValue
    }
  }

  private[this] def handleEmptySeq(forProperty: BeanProperty, propertyValue: Any): Any = {
    if (propertyValue == SeqWithSingleEmptyString &&
      forProperty.getType.containedType(0).getRawClass != classOf[String]) {
      // if a query param is set with an empty value we will get an empty seq of
      // string, yet the property type may not be string
      Seq.empty
    } else {
      propertyValue
    }
  }

  private[this] def handleExtendedBooleans(forProperty: BeanProperty, propertyValue: Any): Any = {
    if (forProperty.getContextAnnotation(classOf[QueryParam]) != null) {
      val forType = forProperty.getType
      if (isBoolean(forType.getRawClass)) {
        matchExtendedBooleans(propertyValue.asInstanceOf[String])
      } else if (isSeqOfBooleans(forType)) {
        propertyValue.asInstanceOf[Seq[String]].map(matchExtendedBooleans)
      } else {
        propertyValue
      }
    } else {
      propertyValue
    }
  }

  private[this] def matchExtendedBooleans(value: String): String = value match {
    case "t" | "T" | "1" => "true"
    case "f" | "F" | "0" => "false"
    case _ => value
  }

  // Note that the InjectableValue annotation information for the field is carried in
  // the beanProperty.getContextAnnotation field, not the beanProperty.getAnnotation
  // field, because the underlying AnnotatedMember does not carry non-Jackson annotations
  // thus we pass all annotation information as context annotations.
  // See: com.fasterxml.jackson.databind.introspect.AnnotationMap
  private[this] def findAnnotation(
    beanProperty: BeanProperty,
    annotations: Seq[Class[_ <: Annotation]]
  ): Option[Annotation] =
    annotations
      .find(beanProperty.getContextAnnotation(_) != null)
      .map(beanProperty.getContextAnnotation(_))

  private[this] lazy val hasAnnotation: (BeanProperty, Seq[Class[_ <: Annotation]]) => Boolean = {
    (beanProperty, annotations) =>
      annotations.exists(beanProperty.getContextAnnotation(_) != null)
  }

  private[this] lazy val isSeqOfBooleans: JavaType => Boolean = { forType =>
    forType.hasRawClass(classOf[Seq[_]]) && isBoolean(forType.containedType(0).getRawClass)
  }

  private[this] lazy val isRequest: BeanProperty => Boolean = { forProperty =>
    forProperty.getType.hasRawClass(classOf[Request])
  }

  private[this] lazy val isResponse: BeanProperty => Boolean = { forProperty =>
    forProperty.getType.hasRawClass(classOf[Response])
  }

  private[this] def isBoolean(clazz: Class[_]): Boolean = {
    // handle both java.lang.Boolean class and boolean primitive
    clazz == classOf[java.lang.Boolean] || clazz.getName == "boolean"
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy