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

kalix.javasdk.impl.Validations.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.javasdk.impl

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

import scala.reflect.ClassTag

import kalix.javasdk.action.Action
import kalix.javasdk.annotations.Publish
import kalix.javasdk.annotations.Query
import kalix.javasdk.annotations.Subscribe
import kalix.javasdk.annotations.Table
import kalix.javasdk.impl.ComponentDescriptorFactory.MethodOps
import kalix.javasdk.impl.ComponentDescriptorFactory.eventSourcedEntitySubscription
import kalix.javasdk.impl.ComponentDescriptorFactory.findEventSourcedEntityClass
import kalix.javasdk.impl.ComponentDescriptorFactory.findEventSourcedEntityType
import kalix.javasdk.impl.ComponentDescriptorFactory.findPublicationTopicName
import kalix.javasdk.impl.ComponentDescriptorFactory.findSubscriptionConsumerGroup
import kalix.javasdk.impl.ComponentDescriptorFactory.findSubscriptionSourceName
import kalix.javasdk.impl.ComponentDescriptorFactory.findSubscriptionTopicName
import kalix.javasdk.impl.ComponentDescriptorFactory.findValueEntityType
import kalix.javasdk.impl.ComponentDescriptorFactory.hasAcl
import kalix.javasdk.impl.ComponentDescriptorFactory.hasActionOutput
import kalix.javasdk.impl.ComponentDescriptorFactory.hasEventSourcedEntitySubscription
import kalix.javasdk.impl.ComponentDescriptorFactory.hasHandleDeletes
import kalix.javasdk.impl.ComponentDescriptorFactory.hasRestAnnotation
import kalix.javasdk.impl.ComponentDescriptorFactory.hasStreamSubscription
import kalix.javasdk.impl.ComponentDescriptorFactory.hasSubscription
import kalix.javasdk.impl.ComponentDescriptorFactory.hasTopicPublication
import kalix.javasdk.impl.ComponentDescriptorFactory.hasTopicSubscription
import kalix.javasdk.impl.ComponentDescriptorFactory.hasUpdateEffectOutput
import kalix.javasdk.impl.ComponentDescriptorFactory.hasValueEntitySubscription
import kalix.javasdk.impl.ComponentDescriptorFactory.streamSubscription
import kalix.javasdk.impl.ComponentDescriptorFactory.topicSubscription
import kalix.javasdk.impl.reflection.ReflectionUtils
import kalix.javasdk.impl.reflection.ServiceMethod
import kalix.javasdk.view.View
import kalix.spring.impl.KalixSpringApplication

// TODO: abstract away spring and reactor dependencies
import org.springframework.web.bind.annotation.RequestBody
import reactor.core.publisher.Flux

object Validations {

  import ReflectionUtils.methodOrdering

  object Validation {

    def apply(messages: Array[String]): Validation = Validation(messages.toIndexedSeq)

    def apply(messages: Seq[String]): Validation =
      if (messages.isEmpty) Valid
      else Invalid(messages)

    def apply(message: String): Validation = Invalid(Seq(message))
  }

  sealed trait Validation {
    def isValid: Boolean

    final def isInvalid: Boolean = !isInvalid
    def ++(validation: Validation): Validation

    def failIfInvalid: Validation
  }

  case object Valid extends Validation {
    override def isValid: Boolean = true
    override def ++(validation: Validation): Validation = validation

    override def failIfInvalid: Validation = this
  }

  object Invalid {
    def apply(message: String): Invalid =
      Invalid(Seq(message))
  }

  case class Invalid(messages: Seq[String]) extends Validation {
    override def isValid: Boolean = false

    override def ++(validation: Validation): Validation =
      validation match {
        case Valid      => this
        case i: Invalid => Invalid(this.messages ++ i.messages)
      }

    override def failIfInvalid: Validation =
      throw InvalidComponentException(messages.mkString(", "))
  }

  private def when(cond: Boolean)(block: => Validation): Validation =
    if (cond) block else Valid

  private def when[T: ClassTag](component: Class[_])(block: => Validation): Validation =
    if (assignable[T](component)) block else Valid

  private def assignable[T: ClassTag](component: Class[_]): Boolean =
    implicitly[ClassTag[T]].runtimeClass.asInstanceOf[Class[T]].isAssignableFrom(component)

  private def commonValidation(component: Class[_]): Validation = {
    noRestStreamIn(component)
  }

  private def commonSubscriptionValidation(
      component: Class[_],
      updateMethodPredicate: Method => Boolean): Validation = {
    typeLevelSubscriptionValidation(component) ++
    eventSourcedEntitySubscriptionValidations(component) ++
    missingEventHandlerValidations(component, updateMethodPredicate) ++
    ambiguousHandlerValidations(component, updateMethodPredicate) ++
    valueEntitySubscriptionValidations(component, updateMethodPredicate) ++
    topicSubscriptionValidations(component) ++
    topicPublicationValidations(component, updateMethodPredicate) ++
    publishStreamIdMustBeFilled(component) ++
    noSubscriptionMethodWithAcl(component) ++
    noSubscriptionWithRestAnnotations(component) ++
    subscriptionMethodMustHaveOneParameter(component)
  }

  def validate(component: Class[_]): Validation =
    validateAction(component) ++
    validateView(component)

  private def validateAction(component: Class[_]): Validation = {
    when[Action](component) {
      commonValidation(component) ++
      commonSubscriptionValidation(component, hasActionOutput) ++
      actionValidation(component)
    }
  }

  private def actionValidation(component: Class[_]): Validation = {
    val anySubscription = hasSubscription(component) || component.getMethods.toIndexedSeq.exists(hasSubscription)
    val restMethods = component.getMethods.toIndexedSeq.filter(hasRestAnnotation)
    when(anySubscription && restMethods.nonEmpty) {
      Invalid(
        errorMessage(
          component,
          s"An Action that subscribes should not be mixed with REST annotations, please move methods [${restMethods.map(_.getName).mkString(", ")}] to a separate Action component."))
    }
  }

  private def validateView(component: Class[_]): Validation = {
    when[View[_]](component) {
      validateSingleView(component)
    } ++
    when(KalixSpringApplication.isMultiTableView(component)) {
      viewMustHaveOneQueryMethod(component)
      val viewClasses = component.getDeclaredClasses.toSeq.filter(KalixSpringApplication.isNestedViewTable)
      viewClasses.map(validateSingleView).reduce(_ ++ _)
    }
  }

  private def validateSingleView(component: Class[_]): Validation = {
    when(!KalixSpringApplication.isNestedViewTable(component)) {
      viewMustHaveOneQueryMethod(component)
    } ++
    commonValidation(component) ++
    commonSubscriptionValidation(component, hasUpdateEffectOutput) ++
    viewMustHaveTableName(component) ++
    viewMustHaveMethodLevelSubscriptionWhenTransformingUpdates(component) ++
    streamUpdatesQueryMustReturnFlux(component)
  }

  private def errorMessage(element: AnnotatedElement, message: String): String = {
    val elementStr =
      element match {
        case clz: Class[_] => clz.getName
        case meth: Method  => s"${meth.getDeclaringClass.getName}#${meth.getName}"
        case any           => any.toString
      }
    s"On '$elementStr': $message"
  }

  def typeLevelSubscriptionValidation(component: Class[_]): Validation = {
    val typeLevelSubs = List(
      hasValueEntitySubscription(component),
      hasEventSourcedEntitySubscription(component),
      hasStreamSubscription(component),
      hasTopicSubscription(component))

    when(typeLevelSubs.filter(identity).size > 1) {
      Validation(errorMessage(component, "Only one subscription type is allowed on a type level."))
    }
  }

  private def eventSourcedEntitySubscriptionValidations(component: Class[_]): Validation = {
    val methods = component.getMethods.toIndexedSeq
    when(
      hasEventSourcedEntitySubscription(component) &&
      methods.exists(hasEventSourcedEntitySubscription)) {
      // collect offending methods
      val messages = methods.filter(hasEventSourcedEntitySubscription).map { method =>
        errorMessage(
          method,
          "You cannot use @Subscribe.EventSourcedEntity annotation in both methods and class. You can do either one or the other.")
      }
      Validation(messages)
    }
  }

  private def getEventType(esEntity: Class[_]): Class[_] = {
    val genericTypeArguments = esEntity.getGenericSuperclass
      .asInstanceOf[ParameterizedType]
      .getActualTypeArguments
    genericTypeArguments(1).asInstanceOf[Class[_]]
  }

  private def ambiguousHandlerValidations(component: Class[_], updateMethodPredicate: Method => Boolean): Validation = {

    val methods = component.getMethods.toIndexedSeq

    if (hasSubscription(component)) {
      val effectMethodsByInputParams: Map[Option[Class[_]], IndexedSeq[Method]] = methods
        .filter(updateMethodPredicate)
        .groupBy(_.getParameterTypes.lastOption)

      Validation(ambiguousHandlersErrors(effectMethodsByInputParams, component))

    } else {
      val effectOutputMethodsGrouped = methods
        .filter(hasSubscription)
        .filter(updateMethodPredicate)
        .groupBy(findSubscriptionSourceName)

      effectOutputMethodsGrouped
        .map { case (_, methods) =>
          val effectMethodsByInputParams: Map[Option[Class[_]], IndexedSeq[Method]] =
            methods.groupBy(_.getParameterTypes.lastOption)
          Validation(ambiguousHandlersErrors(effectMethodsByInputParams, component))
        }
        .fold(Valid)(_ ++ _)
    }

  }

  private def ambiguousHandlersErrors(
      effectMethodsInputParams: Map[Option[Class[_]], IndexedSeq[Method]],
      component: Class[_]) = {
    val errors = effectMethodsInputParams
      .filter(_._2.size > 1)
      .map {
        case (Some(inputType), methods) =>
          errorMessage(
            component,
            s"Ambiguous handlers for ${inputType.getCanonicalName}, methods: [${methods.sorted.map(_.getName).mkString(", ")}] consume the same type.")
        case (None, methods) => //only delete handlers
          errorMessage(component, s"Ambiguous delete handlers: [${methods.sorted.map(_.getName).mkString(", ")}].")
      }
      .toSeq
    errors
  }

  private def missingEventHandlerValidations(
      component: Class[_],
      updateMethodPredicate: Method => Boolean): Validation = {
    val methods = component.getMethods.toIndexedSeq

    eventSourcedEntitySubscription(component) match {
      case Some(classLevel) =>
        val eventType = getEventType(classLevel.value())
        if (!classLevel.ignoreUnknown() && eventType.isSealed) {
          val effectMethodsInputParams: Seq[Class[_]] = methods
            .filter(updateMethodPredicate)
            .map(_.getParameterTypes.last) //last because it could be a view update methods with 2 params
          Validation(missingErrors(effectMethodsInputParams, eventType, component))
        } else {
          Valid
        }
      case None =>
        //method level
        val effectOutputMethodsGrouped = methods
          .filter(hasEventSourcedEntitySubscription)
          .filter(updateMethodPredicate)
          .groupBy(findEventSourcedEntityClass)

        val errors = effectOutputMethodsGrouped.flatMap { case (entityClass, methods) =>
          val eventType = getEventType(entityClass)
          if (eventType.isSealed) {
            missingErrors(methods.map(_.getParameterTypes.last), eventType, component)
          } else {
            List.empty
          }
        }
        Validation(errors.toSeq)
    }
  }

  private def missingErrors(effectOutputInputParams: Seq[Class[_]], eventType: Class[_], component: Class[_]) = {
    eventType.getPermittedSubclasses
      .filterNot(effectOutputInputParams.contains)
      .map(clazz => s"Component '${component.getSimpleName}' is missing an event handler for '${clazz.getName}'")
      .toList
  }

  private def topicSubscriptionValidations(component: Class[_]): Validation = {
    val methods = component.getMethods.toIndexedSeq
    val noMixedLevelSubs = when(hasTopicSubscription(component) && methods.exists(hasTopicSubscription)) {
      // collect offending methods
      val messages = methods.filter(hasTopicSubscription).map { method =>
        errorMessage(
          method,
          "You cannot use @Subscribe.Topic annotation in both methods and class. You can do either one or the other.")
      }
      Validation(messages)
    }

    val theSameConsumerGroupPerTopic = when(methods.exists(hasTopicSubscription)) {
      methods
        .filter(hasTopicSubscription)
        .sorted
        .groupBy(findSubscriptionTopicName)
        .map { case (topicName, methods) =>
          val consumerGroups = methods.map(findSubscriptionConsumerGroup).distinct.sorted
          when(consumerGroups.size > 1) {
            Validation(errorMessage(
              component,
              s"All subscription methods to topic [$topicName] must have the same consumer group, but found different consumer groups [${consumerGroups
                .mkString(", ")}]."))
          }
        }
        .fold(Valid)(_ ++ _)
    }

    noMixedLevelSubs ++ theSameConsumerGroupPerTopic
  }

  private def missingSourceForTopicPublication(component: Class[_]): Validation = {
    val methods = component.getMethods.toSeq
    if (hasSubscription(component)) {
      Valid
    } else {
      val messages = methods
        .filter(hasTopicPublication)
        .filterNot(method => hasSubscription(method) || hasRestAnnotation(method))
        .map { method =>
          errorMessage(
            method,
            "You must select a source for @Publish.Topic. Annotate this methods with one of @Subscribe or REST annotations.")
        }
      Validation(messages)
    }
  }

  private def topicPublicationForSourceValidation(
      sourceName: String,
      component: Class[_],
      methodsGroupedBySource: Map[String, Seq[Method]]): Validation = {
    methodsGroupedBySource
      .map { case (entityType, methods) =>
        val topicNames = methods
          .filter(hasTopicPublication)
          .map(findPublicationTopicName)

        if (topicNames.nonEmpty && topicNames.length != methods.size) {
          Validation(errorMessage(
            component,
            s"Add @Publish.Topic annotation to all subscription methods from $sourceName \"$entityType\". Or remove it from all methods."))
        } else if (topicNames.toSet.size > 1) {
          Validation(
            errorMessage(
              component,
              s"All @Publish.Topic annotation for the same subscription source $sourceName \"$entityType\" should point to the same topic name. " +
              s"Create a separate Action if you want to split messages to different topics from the same source."))
        } else {
          Valid
        }
      }
      .fold(Valid)(_ ++ _)
  }

  private def topicPublicationValidations(component: Class[_], updateMethodPredicate: Method => Boolean): Validation = {
    val methods = component.getMethods.toSeq

    //VE type level subscription is not checked since we expecting only a single method in this case
    val veSubscriptions: Map[String, Seq[Method]] = methods
      .filter(hasValueEntitySubscription)
      .groupBy(findValueEntityType)

    val esSubscriptions: Map[String, Seq[Method]] = eventSourcedEntitySubscription(component) match {
      case Some(esEntity) =>
        Map(ComponentDescriptorFactory.readTypeIdValue(esEntity.value()) -> methods.filter(updateMethodPredicate))
      case None =>
        methods
          .filter(hasEventSourcedEntitySubscription)
          .groupBy(findEventSourcedEntityType)
    }

    val topicSubscriptions: Map[String, Seq[Method]] = topicSubscription(component) match {
      case Some(topic) => Map(topic.value() -> methods.filter(updateMethodPredicate))
      case None =>
        methods
          .filter(hasTopicSubscription)
          .groupBy(findSubscriptionTopicName)
    }

    val streamSubscriptions: Map[String, Seq[Method]] = streamSubscription(component) match {
      case Some(stream) => Map(stream.id() -> methods.filter(updateMethodPredicate))
      case None         => Map.empty //only type level
    }

    missingSourceForTopicPublication(component) ++
    topicPublicationForSourceValidation("ValueEntity", component, veSubscriptions) ++
    topicPublicationForSourceValidation("EventSourcedEntity", component, esSubscriptions) ++
    topicPublicationForSourceValidation("Topic", component, topicSubscriptions) ++
    topicPublicationForSourceValidation("Stream", component, streamSubscriptions)
  }

  private def publishStreamIdMustBeFilled(component: Class[_]): Validation = {
    Option(component.getAnnotation(classOf[Publish.Stream]))
      .map { ann =>
        when(ann.id().trim.isEmpty) {
          Validation(Seq("@Publish.Stream id can not be an empty string"))
        }
      }
      .getOrElse(Valid)
  }

  private def noSubscriptionMethodWithAcl(component: Class[_]): Validation = {

    val hasSubscriptionAndAcl = (method: Method) => hasAcl(method) && hasSubscription(method)

    val messages =
      component.getMethods.toIndexedSeq.filter(hasSubscriptionAndAcl).map { method =>
        errorMessage(
          method,
          "Methods annotated with Kalix @Subscription annotations are for internal use only and cannot be annotated with ACL annotations.")
      }

    Validation(messages)
  }

  private def noSubscriptionWithRestAnnotations(component: Class[_]): Validation = {

    val hasSubscriptionAndRest = (method: Method) => hasRestAnnotation(method) && hasSubscription(method)

    val messages =
      component.getMethods.toIndexedSeq.filter(hasSubscriptionAndRest).map { method =>
        errorMessage(
          method,
          "Methods annotated with Kalix @Subscription annotations are for internal use only and cannot be annotated with REST annotations.")
      }

    Validation(messages)
  }

  private def noRestStreamIn(component: Class[_]): Validation = {

    // this is more for early validation. We don't support stream-in over http,
    // we block it before deploying anything
    def isStreamIn(method: Method): Boolean = {
      val paramWithRequestBody =
        method.getParameters.collect {
          case param if param.getAnnotation(classOf[RequestBody]) != null => param
        }
      paramWithRequestBody.exists(_.getType == classOf[Flux[_]])
    }

    val hasRestWithStreamIn = (method: Method) => hasRestAnnotation(method) && isStreamIn(method)

    val messages =
      component.getMethods.filter(hasRestWithStreamIn).map { method =>
        errorMessage(method, "Stream in calls are not supported.")
      }

    Validation(messages)
  }

  private def viewMustHaveTableName(component: Class[_]): Validation = {
    val ann = component.getAnnotation(classOf[Table])
    if (ann == null) {
      Invalid(errorMessage(component, "A View should be annotated with @Table."))
    } else {
      val tableName: String = ann.value()
      if (tableName == null || tableName.trim.isEmpty) {
        Invalid(errorMessage(component, "@Table name is empty, must be a non-empty string."))
      } else Valid
    }
  }

  private def viewMustHaveMethodLevelSubscriptionWhenTransformingUpdates(component: Class[_]): Validation = {
    if (hasValueEntitySubscription(component)) {
      val tableType: Class[_] = tableTypeOf(component)
      val valueEntityClass: Class[_] =
        component.getAnnotation(classOf[Subscribe.ValueEntity]).value().asInstanceOf[Class[_]]
      val entityStateClass = valueEntityStateClassOf(valueEntityClass)

      when(entityStateClass != tableType) {
        val message =
          s"You are using a type level annotation in this View and that requires the View type [${tableType.getName}] " +
          s"to match the ValueEntity type [${entityStateClass.getName}]. " +
          s"If your intention is to transform the type, you should instead add a method like " +
          s"`UpdateEffect<${tableType.getName}> onChange(${entityStateClass.getName} state)`" +
          " and move the @Subscribe.ValueEntity to it."

        Validation(Seq(errorMessage(component, message)))
      }
    } else {
      Valid
    }
  }

  private def viewMustHaveOneQueryMethod(component: Class[_]): Validation = {

    val annotatedQueryMethods =
      component.getMethods
        .filter(_.hasAnnotation[Query])
        .filter(hasRestAnnotation)
        .toList

    annotatedQueryMethods match {
      case Nil =>
        Invalid(
          errorMessage(
            component,
            "No valid query method found. Views should have a method annotated with @Query and exposed by a REST annotation."))
      case head :: Nil => Valid
      case _ =>
        val messages =
          annotatedQueryMethods.map { method =>
            errorMessage(method, "Views can have only one method annotated with @Query.")
          }
        Invalid(messages)
    }
  }

  private def streamUpdatesQueryMustReturnFlux(component: Class[_]): Validation = {

    val offendingMethods =
      component.getMethods
        .filter(hasRestAnnotation)
        .filter(_.hasAnnotation[Query])
        .filter { method =>
          method.getAnnotation(classOf[Query]).streamUpdates() && !ServiceMethod.isStreamOut(method)
        }

    val messages = offendingMethods.map { method =>
      errorMessage(method, "@Query.streamUpdates can only be enabled in stream methods returning Flux")
    }

    Validation(messages)
  }

  private def subscriptionMethodMustHaveOneParameter(component: Class[_]): Validation = {
    val offendingMethods = component.getMethods
      .filter(hasValueEntitySubscription)
      .filterNot(hasHandleDeletes)
      .filter(_.getParameterTypes.isEmpty) //maybe a delete handler

    val messages =
      offendingMethods.map { method =>
        errorMessage(method, "Subscription method must have one parameter, unless it's marked as handleDeletes.")
      }

    Validation(messages)
  }

  private def valueEntitySubscriptionValidations(
      component: Class[_],
      updateMethodPredicate: Method => Boolean): Validation = {

    val subscriptionMethods = component.getMethods.toIndexedSeq.filter(hasValueEntitySubscription).sorted
    val updatedMethods = if (hasValueEntitySubscription(component)) {
      component.getMethods.toIndexedSeq.filter(updateMethodPredicate).sorted
    } else {
      subscriptionMethods.filterNot(hasHandleDeletes).filter(updateMethodPredicate)
    }

    val (handleDeleteMethods, handleDeleteMethodsWithParam) =
      subscriptionMethods.filter(hasHandleDeletes).partition(_.getParameterTypes.isEmpty)

    val noMixedLevelValueEntitySubscription =
      when(hasValueEntitySubscription(component) && subscriptionMethods.nonEmpty) {
        // collect offending methods
        val messages = subscriptionMethods.map { method =>
          errorMessage(
            method,
            "You cannot use @Subscribe.ValueEntity annotation in both methods and class. You can do either one or the other.")
        }
        Validation(messages)
      }

    val handleDeletesMustHaveZeroArity = {
      val messages =
        handleDeleteMethodsWithParam.map { method =>
          val numParams = method.getParameters.length
          errorMessage(
            method,
            s"Method annotated with '@Subscribe.ValueEntity' and handleDeletes=true must not have parameters. Found ${numParams} method parameters.")
        }

      Validation(messages)
    }

    val onlyOneValueEntityUpdateIsAllowed = {
      if (updatedMethods.size >= 2) {
        val messages = errorMessage(
          component,
          s"Duplicated update methods [${updatedMethods.map(_.getName).mkString(", ")}]for ValueEntity subscription.")
        Validation(messages)
      } else Valid
    }

    val onlyOneHandlesDeleteIsAllowed = {
      val offendingMethods = handleDeleteMethods.filter(_.getParameterTypes.isEmpty)

      if (offendingMethods.size >= 2) {
        val messages =
          offendingMethods.map { method =>
            errorMessage(
              method,
              "Multiple methods annotated with @Subscription.ValueEntity(handleDeletes=true) is not allowed.")
          }
        Validation(messages)
      } else Valid
    }

    val standaloneMethodLevelHandleDeletesIsNotAllowed = {
      if (handleDeleteMethods.nonEmpty && updatedMethods.isEmpty) {
        val messages =
          handleDeleteMethods.map { method =>
            errorMessage(method, "Method annotated with handleDeletes=true has no matching update method.")
          }
        Validation(messages)
      } else Valid
    }

    noMixedLevelValueEntitySubscription ++
    handleDeletesMustHaveZeroArity ++
    onlyOneValueEntityUpdateIsAllowed ++
    onlyOneHandlesDeleteIsAllowed ++
    standaloneMethodLevelHandleDeletesIsNotAllowed
  }

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

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

}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy