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

kalix.springsdk.impl.ViewDescriptorFactory.scala Maven / Gradle / Ivy

/*
 * 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.springsdk.impl

import java.lang.reflect.Method
import java.lang.reflect.ParameterizedType

import kalix.Eventing
import kalix.MethodOptions
import kalix.springsdk.annotations.Query
import kalix.springsdk.annotations.Subscribe
import kalix.springsdk.annotations.Table
import kalix.springsdk.impl.ComponentDescriptorFactory.buildJWTOptions
import kalix.springsdk.impl.ComponentDescriptorFactory.combineByES
import kalix.springsdk.impl.ComponentDescriptorFactory.eventingInForEventSourcedEntity
import kalix.springsdk.impl.ComponentDescriptorFactory.eventingInForEventSourcedEntityServiceLevel
import kalix.springsdk.impl.ComponentDescriptorFactory.eventingInForValueEntity
import kalix.springsdk.impl.ComponentDescriptorFactory.findEventSourcedEntityType
import kalix.springsdk.impl.ComponentDescriptorFactory.findHandleDeletes
import kalix.springsdk.impl.ComponentDescriptorFactory.findValueEntityType
import kalix.springsdk.impl.ComponentDescriptorFactory.hasEventSourcedEntitySubscription
import kalix.springsdk.impl.ComponentDescriptorFactory.hasHandleDeletes
import kalix.springsdk.impl.ComponentDescriptorFactory.hasUpdateEffectOutput
import kalix.springsdk.impl.ComponentDescriptorFactory.hasValueEntitySubscription
import kalix.springsdk.impl.ComponentDescriptorFactory.subscribeToEventStream
import kalix.springsdk.impl.DescriptorValidationCommon.validateHandleDeletesMethodArity
import kalix.springsdk.impl.DescriptorValidationCommon.validateHandleDeletesTrueOnMethodLevel
import kalix.springsdk.impl.DescriptorValidationCommon.validateIfHandleDeletesMethodsMatchesSubscriptions
import kalix.springsdk.impl.reflection.HandleDeletesServiceMethod
import kalix.springsdk.impl.reflection.KalixMethod
import kalix.springsdk.impl.reflection.NameGenerator
import kalix.springsdk.impl.reflection.SubscriptionServiceMethod
import kalix.springsdk.impl.reflection.ReflectionUtils
import kalix.springsdk.impl.reflection.RestServiceIntrospector
import kalix.springsdk.impl.reflection.RestServiceIntrospector.BodyParameter
import kalix.springsdk.impl.reflection.ServiceIntrospectionException
import kalix.springsdk.impl.reflection.SyntheticRequestServiceMethod
import kalix.springsdk.impl.reflection.VirtualDeleteServiceMethod
import kalix.springsdk.impl.reflection.VirtualServiceMethod
import reactor.core.publisher.Flux

private[impl] object ViewDescriptorFactory extends ComponentDescriptorFactory {

  override def buildDescriptorFor(
      component: Class[_],
      messageCodec: SpringSdkMessageCodec,
      nameGenerator: NameGenerator): ComponentDescriptor = {

    // View class type parameter declares table type
    val tableType: Class[_] =
      component.getGenericSuperclass.asInstanceOf[ParameterizedType].getActualTypeArguments.head.asInstanceOf[Class[_]]

    val tableName: String = component.getAnnotation(classOf[Table]).value()
    if (tableName == null || tableName.trim.isEmpty) {
      throw InvalidComponentException(s"Table name for [${component.getName}] is empty, must be a non-empty string.")
    }
    val tableTypeDescriptor = ProtoMessageDescriptors.generateMessageDescriptors(tableType)
    val tableProtoMessageName = tableTypeDescriptor.mainMessageDescriptor.getName

    val hasMethodLevelEventSourcedEntitySubs = component.getMethods.exists(hasEventSourcedEntitySubscription)
    val hasTypeLevelEventSourcedEntitySubs = hasEventSourcedEntitySubscription(component)
    val hasTypeLevelValueEntitySubs = component.getAnnotation(classOf[Subscribe.ValueEntity]) != null
    val hasMethodLevelValueEntitySubs = component.getMethods.exists(hasValueEntitySubscription)
    val hasStreamSubscription = component.getAnnotation(classOf[Subscribe.Stream]) != null

    val updateMethods = {
      if (hasTypeLevelValueEntitySubs && hasMethodLevelValueEntitySubs)
        throw InvalidComponentException(
          "Mixed usage of @Subscribe.ValueEntity annotations. " +
          "You should either use it at type level or at method level, not both.")
      if (hasTypeLevelValueEntitySubs)
        subscriptionForTypeLevelValueEntity(component, tableType, tableName, tableProtoMessageName)
      else if (hasMethodLevelValueEntitySubs)
        subscriptionForMethodLevelValueEntity(component, tableType, tableName, tableProtoMessageName)
      else if (hasMethodLevelEventSourcedEntitySubs || hasTypeLevelEventSourcedEntitySubs) {
        if (hasMethodLevelEventSourcedEntitySubs && hasTypeLevelEventSourcedEntitySubs)
          throw InvalidComponentException(
            s"You cannot use Subscribe annotation in both the methods and the class. You can do either one or the other.")

        if (hasTypeLevelEventSourcedEntitySubs) {
          val kalixSubscriptionMethods =
            methodsForTypeLevelESSubscriptions(component, tableName, tableProtoMessageName)
          combineByES(kalixSubscriptionMethods, messageCodec, component)

        } else {
          val methodsForMethodLevelESSubscriptions =
            subscriptionEventSourcedEntityMethodLevel(component, tableType, tableName, tableProtoMessageName)
          combineByES(methodsForMethodLevelESSubscriptions, messageCodec, component)
        }

      } else if (hasStreamSubscription) {
        val kalixSubscriptionMethods =
          methodsForTypeLevelStreamSubscriptions(component, tableName, tableProtoMessageName)
        combineByES(kalixSubscriptionMethods, messageCodec, component)
      } else
        Seq.empty

    }

    // we only take methods with Query annotations and Spring REST annotations
    val (
      queryMethod: KalixMethod,
      queryInputSchemaDescriptor: Option[ProtoMessageDescriptors],
      queryOutputSchemaDescriptor: ProtoMessageDescriptors) = {

      val annotatedQueryMethods = RestServiceIntrospector
        .inspectService(component)
        .methods
        .filter(_.javaMethod.getAnnotation(classOf[Query]) != null)

      if (annotatedQueryMethods.isEmpty)
        throw ServiceIntrospectionException(
          component,
          s"No valid query method found. " +
          "Views should have a method annotated with @Query and exposed by a REST annotation")

      if (annotatedQueryMethods.size > 1)
        throw ServiceIntrospectionException(
          component,
          "Views can have only one method annotated with @Query, " +
          s"found ${annotatedQueryMethods.size}.")

      val queryMethod: SyntheticRequestServiceMethod = annotatedQueryMethods.head

      val queryOutputType = {
        val returnType = queryMethod.javaMethod.getReturnType
        if (returnType == classOf[Flux[_]]) {
          queryMethod.javaMethod.getGenericReturnType
            .asInstanceOf[ParameterizedType] // Flux will be a ParameterizedType
            .getActualTypeArguments
            .head // only one type parameter, safe to pick the head
            .asInstanceOf[Class[_]]
        } else returnType
      }

      val queryOutputSchemaDescriptor =
        if (queryOutputType == tableType) tableTypeDescriptor
        else ProtoMessageDescriptors.generateMessageDescriptors(queryOutputType)

      val queryInputSchemaDescriptor =
        queryMethod.params.find(_.isInstanceOf[BodyParameter]).map { case BodyParameter(param, _) =>
          ProtoMessageDescriptors.generateMessageDescriptors(param.getParameterType)
        }

      val queryAnnotation = queryMethod.javaMethod.getAnnotation(classOf[Query])
      val queryStr = queryAnnotation.value()

      if (queryAnnotation.streamUpdates() && !queryMethod.streamOut)
        throw ServiceIntrospectionException(
          queryMethod.javaMethod,
          "Query.streamUpdates can only be enabled in stream methods returning Flux")

      val query = kalix.View.Query
        .newBuilder()
        .setQuery(queryStr)
        .setStreamUpdates(queryAnnotation.streamUpdates())
        .build()

      val jsonSchema = {
        val builder = kalix.JsonSchema
          .newBuilder()
          .setOutput(queryOutputSchemaDescriptor.mainMessageDescriptor.getName)

        queryInputSchemaDescriptor.foreach { inputSchema =>
          builder
            .setInput(inputSchema.mainMessageDescriptor.getName)
            .setJsonBodyInputField("json_body")

        }
        builder.build()
      }

      val view = kalix.View
        .newBuilder()
        .setJsonSchema(jsonSchema)
        .setQuery(query)
        .build()

      val builder = kalix.MethodOptions.newBuilder()
      builder.setView(view)
      val methodOptions = builder.build()

      // since it is a query, we don't actually ever want to handle any request in the SDK
      // the proxy does the work for us, mark the method as non-callable
      (
        KalixMethod(queryMethod.copy(callable = false), methodOptions = Some(methodOptions))
          .withKalixOptions(buildJWTOptions(queryMethod.javaMethod)),
        queryInputSchemaDescriptor,
        queryOutputSchemaDescriptor)
    }

    val kalixMethods: Seq[KalixMethod] = queryMethod +: updateMethods
    val serviceName = nameGenerator.getName(component.getSimpleName)
    val additionalMessages = Set(tableTypeDescriptor, queryOutputSchemaDescriptor) ++ queryInputSchemaDescriptor.toSet

    val serviceLevelOptions = {

      val allOptions =
        AclDescriptorFactory.serviceLevelAclAnnotation(component) ::
        eventingInForEventSourcedEntityServiceLevel(component) ::
        subscribeToEventStream(component) ::
        Nil

      val mergedOptions =
        allOptions.flatten
          .foldLeft(kalix.ServiceOptions.newBuilder()) { case (builder, serviceOptions) =>
            builder.mergeFrom(serviceOptions)
          }
          .build()

      // if builder produces the default one, we can returns a None
      if (mergedOptions == kalix.ServiceOptions.getDefaultInstance) None
      else Some(mergedOptions)
    }

    ComponentDescriptor(
      nameGenerator,
      messageCodec,
      serviceName,
      serviceOptions = serviceLevelOptions,
      component.getPackageName,
      kalixMethods,
      additionalMessages.toSeq)
  }

  private def validateSameType(methods: Seq[Method], tableType: Class[_]): Seq[Method] = {

    def invalidComponentException(method: Method, extraMessage: String) = {
      throw InvalidComponentException(
        s"Method [${method.getName}] annotated with '@Subscribe.EventSourcedEntity' should either receive " +
        "a single parameter of one of the event types or " +
        s"two ordered parameters  of type [${tableType.getName}] and an event type. $extraMessage")
    }

    import ReflectionUtils.methodOrdering
    var previousEntityClass: Option[Class[_]] = None

    methods.sorted // make sure we get the methods in deterministic order
      .map { method =>
        // validate that all updates return the same type
        val entityClass = method.getAnnotation(classOf[Subscribe.EventSourcedEntity]).value().asInstanceOf[Class[_]]

        previousEntityClass match {
          case Some(`entityClass`) => // ok
          case Some(other) =>
            throw InvalidComponentException(
              s"All update methods must return the same type, but [${method.getName}] returns [${entityClass.getName}] while a previous update method returns [${other.getName}]")
          case None => previousEntityClass = Some(entityClass)
        }

        method.getParameterTypes.toList match {
          case params if params.size > 2 =>
            invalidComponentException(
              method,
              s"Subscription method should not have more than 2 parameters, found ${params.size}")
          case _ => // happy days, dev did good with the signature
        }
      }
    methods
  }

  private def methodsForTypeLevelStreamSubscriptions(
      component: Class[_],
      tableName: String,
      tableProtoMessageName: String): Map[String, Seq[KalixMethod]] = {
    val methods = eligibleSubscriptionMethods(component, tableName, tableProtoMessageName)
    val ann = component.getAnnotation(classOf[Subscribe.Stream])
    val key = ann.id().capitalize
    Map(key -> methods)
  }

  private def methodsForTypeLevelESSubscriptions(
      component: Class[_],
      tableName: String,
      tableProtoMessageName: String): Map[String, Seq[KalixMethod]] = {

    val methods = eligibleSubscriptionMethods(component, tableName, tableProtoMessageName)
    val entityType = findEventSourcedEntityType(component)
    Map(entityType -> methods)
  }

  private def eligibleSubscriptionMethods(component: Class[_], tableName: String, tableProtoMessageName: String) =
    component.getMethods.filter(hasUpdateEffectOutput).map { method =>
      // event sourced or topic subscription updates
      val methodOptionsBuilder = kalix.MethodOptions.newBuilder()

      addTableOptionsToUpdateMethod(tableName, tableProtoMessageName, methodOptionsBuilder, true)

      KalixMethod(SubscriptionServiceMethod(method))
        .withKalixOptions(methodOptionsBuilder.build())
    }

  private def subscriptionEventSourcedEntityMethodLevel(
      component: Class[_],
      tableType: Class[_],
      tableName: String,
      tableProtoMessageName: String): Seq[KalixMethod] = {

    def getMethodsWithSubscription(component: Class[_]): Seq[Method] = {
      val methodsMethodLevel =
        component.getMethods
          .filter(hasEventSourcedEntitySubscription)
          .toIndexedSeq
      (validateSameType(methodsMethodLevel, tableType)).toSeq // this means one or the other
    }

    def getEventing(method: Method, component: Class[_]): Eventing =
      if (hasEventSourcedEntitySubscription(component)) eventingInForEventSourcedEntity(component)
      else eventingInForEventSourcedEntity(method)

    getMethodsWithSubscription(component).map { method =>
      // event sourced or topic subscription updates
      val methodOptionsBuilder = kalix.MethodOptions.newBuilder()

      if (hasEventSourcedEntitySubscription(method))
        methodOptionsBuilder.setEventing(getEventing(method, component))

      addTableOptionsToUpdateMethod(tableName, tableProtoMessageName, methodOptionsBuilder, true)

      KalixMethod(SubscriptionServiceMethod(method))
        .withKalixOptions(methodOptionsBuilder.build())
    }
  }

  private def subscriptionForMethodLevelValueEntity(
      component: Class[_],
      tableType: Class[_],
      tableName: String,
      tableProtoMessageName: String): Seq[KalixMethod] = {
    var previousEntityClass: Option[Class[_]] = None

    import ReflectionUtils.methodOrdering

    def invalidParametersException(method: Method, stateClass: Class[_], extraMessage: String) = {
      throw InvalidComponentException(
        s"Method [${method.getName}] annotated with '@Subscribe.ValueEntity' should either receive " +
        s"a single parameter of type [${stateClass.getName}] or " +
        s"two ordered parameters of type [${tableType.getName}, ${stateClass.getName}]. $extraMessage")
    }

    validateHandleDeletesTrueOnMethodLevel(component)
    validateIfHandleDeletesMethodsMatchesSubscriptions(component)

    val handleDeletesMethods = component.getMethods
      .filter(hasHandleDeletes)
      .sorted
      .map { method =>
        validateHandleDeletesMethodArity(method)

        val methodOptionsBuilder = kalix.MethodOptions.newBuilder()
        methodOptionsBuilder.setEventing(eventingInForValueEntity(method))
        addTableOptionsToUpdateMethod(tableName, tableProtoMessageName, methodOptionsBuilder, transform = true)

        KalixMethod(HandleDeletesServiceMethod(method))
          .withKalixOptions(methodOptionsBuilder.build())
          .withKalixOptions(buildJWTOptions(method))
      }

    val valueEntitySubscriptionMethods = component.getMethods
      .filterNot(hasHandleDeletes)
      .filter(hasValueEntitySubscription)
      .sorted // make sure we get the methods in deterministic order
      .map { method =>
        // validate that all updates return the same type
        val entityClass = method.getAnnotation(classOf[Subscribe.ValueEntity]).value().asInstanceOf[Class[_]]
        val stateClass = entityClass.getGenericSuperclass
          .asInstanceOf[ParameterizedType]
          .getActualTypeArguments
          .head
          .asInstanceOf[Class[_]]
        previousEntityClass match {
          case Some(`entityClass`) => // ok
          case Some(other) =>
            throw InvalidComponentException(
              s"All update methods must subscribe to the same type, but [${method.getName}] subscribes to [${entityClass.getName}] while a previous update method subscribes to [${other.getName}]")
          case None => previousEntityClass = Some(entityClass)
        }

        method.getParameterTypes.toList match {
          case params if params.size > 2 =>
            invalidParametersException(
              method,
              stateClass,
              s"Subscription method should have only one parameter, found ${params.size}")
          case _ => // happy days, dev did good with the signature
        }

        val methodOptionsBuilder = kalix.MethodOptions.newBuilder()
        methodOptionsBuilder.setEventing(eventingInForValueEntity(method))
        addTableOptionsToUpdateMethod(tableName, tableProtoMessageName, methodOptionsBuilder, transform = true)

        KalixMethod(SubscriptionServiceMethod(method))
          .withKalixOptions(methodOptionsBuilder.build())
          .withKalixOptions(buildJWTOptions(method))
      }

    (handleDeletesMethods ++ valueEntitySubscriptionMethods).toSeq
  }

  private def subscriptionForTypeLevelValueEntity(
      component: Class[_],
      tableType: Class[_],
      tableName: String,
      tableProtoMessageName: String) = {
    // create a virtual method
    val methodOptionsBuilder = kalix.MethodOptions.newBuilder()

    // validate
    val valueEntityClass: Class[_] =
      component.getAnnotation(classOf[Subscribe.ValueEntity]).value().asInstanceOf[Class[_]]
    val entityStateClass = valueEntityStateClassOf(valueEntityClass)
    if (entityStateClass != tableType)
      throw InvalidComponentException(
        s"View subscribes to ValueEntity [${valueEntityClass.getName}] and subscribes to state changes " +
        s"which will be of type [${entityStateClass.getName}] but view type parameter is [${tableType.getName}] which does not match, " +
        "the types of the entity and the subscribing must be the same.")

    val entityType = findValueEntityType(component)
    methodOptionsBuilder.setEventing(eventingInForValueEntity(entityType, handleDeletes = false))

    addTableOptionsToUpdateMethod(tableName, tableProtoMessageName, methodOptionsBuilder, transform = false)
    val kalixOptions = methodOptionsBuilder.build()

    if (findHandleDeletes(component)) {
      val deleteMethodOptionsBuilder = kalix.MethodOptions.newBuilder()
      deleteMethodOptionsBuilder.setEventing(eventingInForValueEntity(entityType, handleDeletes = true))
      addTableOptionsToUpdateMethod(tableName, tableProtoMessageName, deleteMethodOptionsBuilder, transform = false)
      Seq(
        KalixMethod(VirtualServiceMethod(component, "OnChange", tableType)).withKalixOptions(kalixOptions),
        KalixMethod(VirtualDeleteServiceMethod(component, "OnDelete")).withKalixOptions(
          deleteMethodOptionsBuilder.build()))
    } else {
      Seq(KalixMethod(VirtualServiceMethod(component, "OnChange", tableType)).withKalixOptions(kalixOptions))
    }
  }

  private def addTableOptionsToUpdateMethod(
      tableName: String,
      tableProtoMessage: String,
      builder: MethodOptions.Builder,
      transform: Boolean) = {
    val update = kalix.View.Update
      .newBuilder()
      .setTable(tableName)
      .setTransformUpdates(transform)

    val jsonSchema = kalix.JsonSchema
      .newBuilder()
      .setOutput(tableProtoMessage)
      .build()

    val view = kalix.View
      .newBuilder()
      .setUpdate(update)
      .setJsonSchema(jsonSchema)
      .build()
    builder.setView(view)
  }

  private def valueEntityStateClassOf(valueEntityClass: Class[_]): Class[_] = {
    valueEntityClass.getGenericSuperclass
      .asInstanceOf[ParameterizedType]
      .getActualTypeArguments
      .head
      .asInstanceOf[Class[_]]
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy