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

kalix.javasdk.impl.http.HttpEndpointMethodDefinition.scala Maven / Gradle / Ivy

There is a newer version: 1.4.1
Show newest version
/*
 * Copyright 2021 Lightbend Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package kalix.javasdk.impl.http

import java.net.URLDecoder
import java.nio.charset.StandardCharsets.UTF_8
import java.util
import java.util.regex.Matcher

import scala.annotation.tailrec
import scala.jdk.CollectionConverters._
import scala.util.control.NonFatal

import akka.http.scaladsl.model.ErrorInfo
import akka.http.scaladsl.model.HttpMethod
import akka.http.scaladsl.model.HttpMethods
import akka.http.scaladsl.model.IllegalRequestException
import akka.http.scaladsl.model.ParsingException
import akka.http.scaladsl.model.RequestEntityAcceptance
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.model.Uri.Path
import com.google.api.HttpRule.PatternCase
import com.google.api.annotations.AnnotationsProto
import com.google.api.http.CustomHttpPattern
import com.google.api.http.HttpRule
import com.google.api.http.HttpRule.Pattern._
import com.google.api.{ AnnotationsProto => JavaAnnotationsProto }
import com.google.api.{ HttpRule => JavaHttpRule }
import com.google.protobuf.Descriptors
import com.google.protobuf.Descriptors.Descriptor
import com.google.protobuf.Descriptors.FieldDescriptor
import com.google.protobuf.Descriptors.MethodDescriptor
import com.google.protobuf.Descriptors.ServiceDescriptor
import com.google.protobuf.DynamicMessage
import com.google.protobuf.descriptor.{ MethodOptions => spbMethodOptions }
import com.google.protobuf.util.Durations
import com.google.protobuf.util.Timestamps
import kalix.javasdk.impl.http.HttpEndpointMethodDefinition.lookupFieldByName
import kalix.javasdk.impl.http.HttpEndpointMethodDefinition.parsingError
import kalix.javasdk.impl.path.PathPatternParseException
import org.slf4j.LoggerFactory

/**
 * INTERNAL API
 *
 * Declarative model of a HTTP endpoint, and the logic to parse and validate it from a descriptor. Each instance
 * corresponds to the HTTP/JSON representation of one gRPC method. Doesn't actually handle any requests, that is up to
 * the [[HttpEndpointMethod]]
 *
 * References: https://cloud.google.com/endpoints/docs/grpc-service-config/reference/rpc/google.api#httprule
 * https://github.com/googleapis/googleapis/blob/master/google/api/http.proto
 * https://github.com/googleapis/googleapis/blob/master/google/api/annotations.proto
 */
object HttpEndpointMethodDefinition {

  private val log = LoggerFactory.getLogger(classOf[HttpEndpointMethodDefinition])

  // For descriptive purposes so it's clear what these types do
  private type PathParameterEffect = (FieldDescriptor, Option[Any]) => Unit
  private type ExtractPathParameters = (Matcher, PathParameterEffect) => Unit

  // This is used to support the "*" custom pattern
  val ANY_METHOD = HttpMethod.custom(
    name = "ANY",
    safe = false,
    idempotent = false,
    requestEntityAcceptance = RequestEntityAcceptance.Tolerated)

  /**
   * INTERNAL API
   *
   * Extracts a HTTP endpoint definition, or throws a ReportableError if the definition is invalid
   */
  def extractForService(serviceDescriptor: ServiceDescriptor): Seq[HttpEndpointMethodDefinition] = {
    for {
      methodDescriptor <- serviceDescriptor.getMethods.asScala.toSeq
      ruleBinding <- ruleBindings(serviceDescriptor, methodDescriptor)
        .filter(_.pattern != HttpRule.Pattern.Empty)
    } yield {
      val (methodPattern, pathTemplate, pathExtractor, bodyDescriptor, responseBodyDescriptor) =
        extractAndValidate(methodDescriptor, ruleBinding)
      new HttpEndpointMethodDefinition(
        methodDescriptor,
        ruleBinding,
        methodPattern,
        pathTemplate,
        pathExtractor,
        bodyDescriptor,
        responseBodyDescriptor)
    }
  }

  private def ruleBindings(service: ServiceDescriptor, method: MethodDescriptor): Seq[HttpRule] = {

    def convertHttpRule(jHttpRule: JavaHttpRule): Option[HttpRule] = {
      try {
        val httpRule = HttpRule.of(
          selector = jHttpRule.getSelector,
          pattern = {
            jHttpRule.getPatternCase match {
              case PatternCase.GET             => HttpRule.Pattern.Get(jHttpRule.getGet)
              case PatternCase.POST            => HttpRule.Pattern.Post(jHttpRule.getPost)
              case PatternCase.PUT             => HttpRule.Pattern.Put(jHttpRule.getPut)
              case PatternCase.PATCH           => HttpRule.Pattern.Patch(jHttpRule.getPatch)
              case PatternCase.DELETE          => HttpRule.Pattern.Delete(jHttpRule.getDelete)
              case PatternCase.PATTERN_NOT_SET => HttpRule.Pattern.Empty
              case PatternCase.CUSTOM =>
                HttpRule.Pattern.Custom(
                  CustomHttpPattern(kind = jHttpRule.getCustom.getKind, path = jHttpRule.getCustom.getPath))
            }
          },
          body = jHttpRule.getBody,
          responseBody = jHttpRule.getResponseBody,
          additionalBindings = jHttpRule.getAdditionalBindingsList.asScala
            .flatMap(convertHttpRule)
            .toSeq // FIXME can we make this tailrec?
        )
        Some(httpRule)
      } catch {
        case NonFatal(e) =>
          log.error(s"Failed to convert $jHttpRule", e)
          None
      }
    }

    def convertMethodOptions(method: MethodDescriptor): spbMethodOptions =
      spbMethodOptions
        .fromJavaProto(method.getOptions)
        .withExtension(AnnotationsProto.http)(
          convertHttpRule(method.getOptions.getExtension(JavaAnnotationsProto.http)))

    val rule = convertMethodOptions(method).extension(AnnotationsProto.http) match {
      case Some(rule) =>
        if (rule.pattern != HttpRule.Pattern.Empty) {
          log.info(s"Using configured HTTP API endpoint using [$rule]")
        }
        rule
      case None =>
        val rule = HttpRule.of(
          selector = method.getFullName, // We know what thing we are proxying
          body = "*", // Parse all input
          responseBody = "", // Include all output
          additionalBindings = Nil, // No need for additional bindings
          pattern = HttpRule.Pattern.Post((Path / service.getFullName / method.getName).toString))
        log.info(s"Using generated HTTP API endpoint using [$rule]")
        rule
    }
    rule +: rule.additionalBindings
  }

  // This method validates the configuration and returns values obtained by parsing the configuration
  private final def extractAndValidate(methDesc: MethodDescriptor, rule: HttpRule)
      : (HttpMethod, PathTemplateParser.ParsedTemplate, ExtractPathParameters, Descriptor, Option[FieldDescriptor]) = {
    // Validate selector
    if (rule.selector != "" && rule.selector != methDesc.getFullName)
      parsingError(s"HTTP API selector [${rule.selector}] is not valid")

    // Validate pattern
    val (mp, pattern) = {
      import HttpMethods._

      rule.pattern match {
        case Empty           => parsingError(s"HTTP API option for [${methDesc.getFullName}] is missing a pattern")
        case Get(pattern)    => (GET, pattern)
        case Put(pattern)    => (PUT, pattern)
        case Post(pattern)   => (POST, pattern)
        case Delete(pattern) => (DELETE, pattern)
        case Patch(pattern)  => (PATCH, pattern)
        case Custom(chp) =>
          if (chp.kind == "*")
            (ANY_METHOD, chp.path) // FIXME is "path" the same as "pattern" for the other kinds? Is an empty kind valid?
          else
            parsingError(
              s"HHTTP API option for [${methDesc.getFullName}] has a custom pattern with an unsupported kind [${chp.kind}]")
      }
    }
    val (template, extractor) = parsePathExtractor(pattern, methDesc)

    // Validate body value
    val bd =
      rule.body match {
        case "" => methDesc.getInputType
        case "*" =>
          if (!mp.isEntityAccepted)
            parsingError(
              s"HTTP API option for [${methDesc.getFullName}] has a body [\"*\"] but [$mp] does not accept a request body")
          else
            methDesc.getInputType
        case fieldName =>
          val field = lookupFieldByName(methDesc.getInputType, fieldName)
          if (field == null)
            parsingError(
              s"HTTP API option for [${methDesc.getFullName}] has a body configured to [$fieldName] but that field does not exist")
          else if (field.isRepeated)
            parsingError(
              s"HTTP API option for [${methDesc.getFullName}] has a body configured to [$fieldName] but that is a repeated field")
          else if (!mp.isEntityAccepted)
            parsingError(
              s"HTTP API option for [${methDesc.getFullName}] has a body [$fieldName] but [$mp] does not accept a request body")
          else
            field.getMessageType
      }

    // Validate response body value
    val rd =
      rule.responseBody match {
        case "" => None
        case fieldName =>
          lookupFieldByName(methDesc.getOutputType, fieldName) match {
            case null =>
              parsingError(
                s"HTTP API option for [${methDesc.getFullName}] has a response body configured to [$fieldName] but that field does not exist")
            case field => Some(field)
          }
      }

    if (rule.additionalBindings.exists(_.additionalBindings.nonEmpty))
      parsingError(s"HTTP API option for [${methDesc.getFullName}] has nested additional bindings")

    (mp, template, extractor, bd, rd)
  }

  private def parsePathExtractor(
      pattern: String,
      methDesc: MethodDescriptor): (PathTemplateParser.ParsedTemplate, ExtractPathParameters) = {
    val template =
      try PathTemplateParser.parse(pattern)
      catch {
        case _: PathPatternParseException =>
          parsingError(s"HTTP API path template for [${methDesc.getFullName}] could not be parsed")
      }
    val pathFieldParsers = template.fields.iterator
      .map {
        case tv @ PathTemplateParser.TemplateVariable(fieldName :: Nil, _) =>
          lookupFieldByName(methDesc.getInputType, fieldName) match {
            case null =>
              parsingError(
                s"HTTP API path template for [${methDesc.getFullName}] references an unknown field named [$fieldName], methDesc)")
            case field =>
              if (field.isMapField)
                parsingError(
                  s"HTTP API path template for [${methDesc.getFullName}] references [$fieldName] but that is a map field")
              else if (field.isRepeated)
                parsingError(
                  s"HTTP API path template for [${methDesc.getFullName}] references [$fieldName] but that is a repeated field")
              else {
                val notSupported =
                  (message: String) => parsingError(s"HTTP API path for [${methDesc.getFullName}]: $message")
                (tv, field, HttpEndpointMethod.suitableParserFor(field)(notSupported))
              }
          }
        case multi =>
          // todo implement field paths properly
          parsingError(s"""HttpApi.notSupportedYet(
            s"HTTP API path template for [${methDesc.getFullName}] references a field path [${multi.fieldPath
            .mkString(".")}]",
            "Referencing sub-fields with field paths is not yet supported.",
            List(methDesc))""")
      }
      .zipWithIndex
      .toList

    (
      template,
      (matcher, effect) => {
        pathFieldParsers.foreach { case ((_, field, parser), idx) =>
          val rawValue = matcher.group(idx + 1)
          // When encoding, we need to be careful to only encode / if it's a single segment variable. But when
          // decoding, it doesn't matter, we decode %2F if it's there regardless.
          val decoded = URLDecoder.decode(rawValue, UTF_8)
          val value = parser(decoded)
          effect(field, value)
        }
      })
  }

  @tailrec private def lookupFieldByPath(desc: Descriptor, selector: String): FieldDescriptor = {
    def splitNext(name: String): (String, String) = {
      val dot = name.indexOf('.')
      if (dot >= 0) {
        (name.substring(0, dot), name.substring(dot + 1))
      } else {
        (name, "")
      }
    }

    splitNext(selector) match {
      case ("", "")        => null
      case (fieldName, "") => lookupFieldByName(desc, fieldName)
      case (fieldName, next) =>
        val field = lookupFieldByName(desc, fieldName)
        if (field == null) null
        else if (field.getMessageType == null) null
        else lookupFieldByPath(field.getMessageType, next)
    }
  }

  // Question: Do we need to handle conversion from JSON names?
  private def lookupFieldByName(desc: Descriptor, selector: String): FieldDescriptor =
    desc.findFieldByName(selector) // TODO potentially start supporting path-like selectors with maximum nesting level?

  private def parsingError(msg: String): Nothing = throw ParsingException(new ErrorInfo(msg))
}

final case class HttpEndpointMethodDefinition private (
    methodDescriptor: MethodDescriptor,
    rule: HttpRule,
    methodPattern: HttpMethod,
    pathTemplate: PathTemplateParser.ParsedTemplate,
    pathExtractor: HttpEndpointMethodDefinition.ExtractPathParameters,
    bodyDescriptor: Descriptor,
    responseBodyDescriptor: Option[FieldDescriptor]) {

  // Making this a method so we can ensure it's used the same way
  private def pathMatcher(path: String): Matcher =
    pathTemplate.regex.pattern
      .matcher(
        path
      ) // FIXME path.toString is costly, and using Regexes are too, switch to using a generated parser instead

  def matches(path: String): Boolean =
    pathMatcher(path).matches()

  private def lookupRequestFieldByPath(selector: String): Descriptors.FieldDescriptor =
    HttpEndpointMethodDefinition.lookupFieldByPath(methodDescriptor.getInputType, selector)

  def parseTypedPathParametersInto(pathVariables: Map[String, ?], inputBuilder: DynamicMessage.Builder): Unit = {

    //TODO fix exceptions msgs
    pathVariables.foreach { case (fieldName, value) =>
      val field = lookupFieldByName(methodDescriptor.getInputType, fieldName) match {
        case null =>
          parsingError(
            s"HTTP API path template for [${methodDescriptor.getFullName}] references an unknown field named [$fieldName], methDesc)")
        case field =>
          if (field.isMapField)
            parsingError(
              s"HTTP API path template for [${methodDescriptor.getFullName}] references [$fieldName] but that is a map field")
          else if (field.isRepeated)
            parsingError(
              s"HTTP API path template for [${methodDescriptor.getFullName}] references [$fieldName] but that is a repeated fieldfield")
          else {
            val notSupported =
              (message: String) => parsingError(s"HTTP API path for [${methodDescriptor.getFullName}]: $message")

            //we don't need a parser, just to check if the type is supported
            HttpEndpointMethod.suitableParserFor(field)(notSupported)
            field
          }
      }

      inputBuilder.setField(field, value)
    }
  }

  def parsePathParametersInto(path: String, inputBuilder: DynamicMessage.Builder): Unit = {
    val matcher = pathMatcher(path)
    matcher.find()
    pathExtractor(
      matcher,
      (field, value) =>
        inputBuilder.setField(
          field,
          value.getOrElse(
            throw new IllegalArgumentException(
              s"Path contains value of wrong type! Expected field of type ${field.getType}."))))
  }

  private val singleStringMessageParsers = Map[String, String => Any](
    "google.protobuf.Timestamp" -> Timestamps.parse,
    "google.protobuf.Duration" -> Durations.parse)

  // We use this to signal to the requester that there's something wrong with the request
  private def requestError(msg: String): Nothing =
    throw IllegalRequestException(StatusCodes.BadRequest, new ErrorInfo(msg))

  def parseTypedRequestParametersInto(
      queryParams: Map[String, util.List[scala.Any]],
      inputBuilder: DynamicMessage.Builder): Unit = {
    queryParams.foreach { case (name, values) =>
      if (!values.isEmpty) {
        lookupRequestFieldByPath(name) match {
          case null => requestError(s"Query parameter [$name] refers to a non-existent field.")
          case field if field.getJavaType == FieldDescriptor.JavaType.MESSAGE =>
            requestError(s"Query parameter [$name] refers to a message type. Only scalar value types are supported.")
          case field if !field.isRepeated && values.size() > 1 =>
            requestError(s"Query parameter [$name] has multiple values for a non-repeated field.")
          case field =>
            if (field.isRepeated) {
              values.forEach(v => {
                inputBuilder.addRepeatedField(field, v)
              })
            } else {
              inputBuilder.setField(field, values.get(0))
            }
        }
      }
    }
  }

  def parseRequestParametersInto(query: Map[String, List[String]], inputBuilder: DynamicMessage.Builder): Unit =
    query.foreach { case (selector, values) =>
      if (values.nonEmpty) {
        lookupRequestFieldByPath(selector) match {
          case null => requestError(s"Query parameter [$selector] refers to a non-existent field.")
          case field if field.getJavaType == FieldDescriptor.JavaType.MESSAGE =>
            //this is actually not supported at the moment: https://github.com/lightbend/kalix-jvm-sdk/issues/1434
            singleStringMessageParsers.get(field.getMessageType.getFullName) match {
              case Some(parser) =>
                try {
                  val parsed = parser(values.head)
                  inputBuilder.setField(field, parsed)
                } catch {
                  case ex: Exception =>
                    requestError(
                      s"Query parameter [$selector] refers to a field of message type [${field.getFullName}], but could not be parsed into that type. ${ex.getMessage}")
                }
              case None =>
                requestError(
                  s"Query parameter [$selector] refers to a message type. Only scalar value types and message types [${singleStringMessageParsers.keys
                    .mkString(", ")}] are supported.")
            }
          case field if !field.isRepeated && values.size > 1 =>
            requestError(s"Query parameter [$selector] has multiple values for a non-repeated field.")
          case field => // FIXME verify that we can set nested fields from the inputBuilder type
            val x = HttpEndpointMethod.suitableParserFor(field)(requestError)
            if (field.isRepeated) {
              values.foreach { v =>
                inputBuilder.addRepeatedField(
                  field,
                  x(v).getOrElse(requestError(s"Malformed query parameter [$selector].")))
              }
            } else
              inputBuilder.setField(
                field,
                x(values.head).getOrElse(
                  requestError(s"Malformed query parameter [$selector]. Expected field of type ${field.getType}.")))
        }
      } // Ignore empty values
    }
}

final case class HttpEndpointMethodParsingException(field: String, methodDesc: Descriptors.MethodDescriptor)
    extends RuntimeException(s"Parsing of field=$field failed for methodDesc=$methodDesc")




© 2015 - 2024 Weber Informatics LLC | Privacy Policy