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

pl.touk.nussknacker.ui.api.description.DeploymentApiEndpoints.scala Maven / Gradle / Ivy

There is a newer version: 1.18.1
Show newest version
package pl.touk.nussknacker.ui.api.description

import cats.data.NonEmptyList
import derevo.circe.{decoder, encoder}
import derevo.derive
import pl.touk.nussknacker.engine.api.NodeId
import pl.touk.nussknacker.engine.api.component.{NodeDeploymentData, NodesDeploymentData, SqlFilteringExpression}
import pl.touk.nussknacker.engine.api.context.ProcessCompilationError.{
  EmptyProcess,
  ExpressionParserCompilationError,
  MissingRequiredProperty
}
import pl.touk.nussknacker.engine.api.deployment.{DeploymentStatus, DeploymentStatusName}
import pl.touk.nussknacker.engine.api.parameter.ParameterName
import pl.touk.nussknacker.engine.api.process.ProcessName
import pl.touk.nussknacker.engine.newdeployment.DeploymentId
import pl.touk.nussknacker.restmodel.BaseEndpointDefinitions
import pl.touk.nussknacker.restmodel.BaseEndpointDefinitions.SecuredEndpoint
import pl.touk.nussknacker.restmodel.validation.PrettyValidationErrors
import pl.touk.nussknacker.restmodel.validation.ValidationResults.{UIGlobalError, ValidationErrors}
import pl.touk.nussknacker.security.AuthCredentials
import pl.touk.nussknacker.ui.api.BaseHttpService.CustomAuthorizationError
import pl.touk.nussknacker.engine.api.Comment
import sttp.model.StatusCode
import sttp.tapir.Codec.PlainCodec
import sttp.tapir.EndpointIO.{Example, Info}
import sttp.tapir._
import sttp.tapir.derevo.schema
import sttp.tapir.json.circe.jsonBody

import java.time.{Instant, LocalDateTime, ZoneOffset}
import java.util.UUID

class DeploymentApiEndpoints(auth: EndpointInput[AuthCredentials]) extends BaseEndpointDefinitions {

  import pl.touk.nussknacker.ui.api.description.DeploymentApiEndpoints.Dtos._

  lazy val runDeploymentEndpoint: SecuredEndpoint[(DeploymentId, RunDeploymentRequest), RunDeploymentError, Unit, Any] =
    baseNuApiEndpoint
      .summary("Run the deployment of a scenario")
      .tag("Deployments")
      .put
      .in(
        "deployments" / deploymentIdPathCapture
      )
      .in(
        jsonBody[RunDeploymentRequest]
          .example(
            RunDeploymentRequest(
              scenarioName = ProcessName("scenario1"),
              NodesDeploymentData(
                Map(NodeId("sourceNodeId1") -> SqlFilteringExpression("field1 = 'value'"))
              ),
              comment = None
            )
          )
      )
      .out(statusCode(StatusCode.Accepted))
      .errorOut(
        oneOf[RunDeploymentError](
          oneOfVariant[ConflictRunDeploymentError](
            StatusCode.Conflict,
            plainBody[ConflictRunDeploymentError]
              .examples(
                List(
                  Example.of(
                    summary = Some("Deployment with id {deploymentId} already exists"),
                    value = ConflictingDeploymentIdError(exampleDeploymentId)
                  )
                )
              )
          ),
          oneOfVariant[BadRequestRunDeploymentError](
            StatusCode.BadRequest,
            plainBody[BadRequestRunDeploymentError]
              .examples(
                List(
                  Example.of(
                    summary = Some("Scenario {scenarioName} not found"),
                    value = ScenarioNotFoundError(ProcessName("'example scenario'"))
                  ),
                  Example.of(
                    summary = Some("Comment is required"),
                    value = CommentValidationError("Comment is required.")
                  ),
                  Example.of(
                    summary = Some("Scenario validation error"),
                    value = ScenarioGraphValidationError(
                      ValidationErrors(
                        invalidNodes = Map(
                          "filter" -> List(
                            PrettyValidationErrors.formatErrorMessage(
                              ExpressionParserCompilationError(
                                message = "Bad expression",
                                paramName = None,
                                originalExpr = "",
                                details = None
                              )(NodeId("filter"))
                            )
                          )
                        ),
                        globalErrors =
                          List(UIGlobalError(PrettyValidationErrors.formatErrorMessage(EmptyProcess), List.empty)),
                        processPropertiesErrors = List(
                          PrettyValidationErrors.formatErrorMessage(
                            MissingRequiredProperty(ParameterName("parallelism"), None)(NodeId("properties"))
                          )
                        ),
                      )
                    )
                  ),
                  Example.of(
                    summary = Some("Deploy validation error"),
                    value = DeployValidationError("Not enough free slots on Flink cluster")
                  )
                )
              )
          )
        )
      )
      .withSecurity(auth)

  lazy val getDeploymentStatusEndpoint
      : SecuredEndpoint[DeploymentId, GetDeploymentStatusError, GetDeploymentStatusResponse, Any] =
    baseNuApiEndpoint
      .summary("Get status of a deployment")
      .tag("Deployments")
      .get
      .in(
        "deployments" / deploymentIdPathCapture / "status"
      )
      .out(
        statusCode(StatusCode.Ok).and(
          jsonBody[GetDeploymentStatusResponse].examples(
            List(
              Example.of(
                GetDeploymentStatusResponse(DeploymentStatus.Running.name, None, exampleInstant),
                Some("RUNNING status")
              ),
              Example.of(
                GetDeploymentStatusResponse(
                  DeploymentStatus.Problem.Failed.name,
                  Some(DeploymentStatus.Problem.Failed.description),
                  exampleInstant
                ),
                Some("PROBLEM status")
              )
            )
          )
        )
      )
      .errorOut(
        oneOf[GetDeploymentStatusError](
          oneOfVariantValueMatcher[DeploymentNotFoundError](
            StatusCode.NotFound,
            plainBody[DeploymentNotFoundError]
              .example(
                Example.of(
                  summary = Some("No deployment {deploymentId} found"),
                  value = DeploymentNotFoundError(exampleDeploymentId)
                )
              )
          ) {
            // MatchType macro used in oneOfVariantFromMatchType doesn't work for this case
            case DeploymentNotFoundError(_) => true
          }
        )
      )
      .withSecurity(auth)

  private lazy val exampleDeploymentId = DeploymentId(UUID.fromString("a9a1e269-0b71-4582-a948-603482d27298"))

  private lazy val exampleInstant = LocalDateTime.of(2024, 1, 1, 0, 0, 0).atZone(ZoneOffset.UTC).toInstant

  private lazy val deploymentIdPathCapture = path[DeploymentId]("deploymentId")
    .copy(info =
      Info
        .empty[DeploymentId]
        .description(
          "Identifier in the UUID format that will be used for the verification of deployment's status"
        )
        .example(exampleDeploymentId)
    )

}

object DeploymentApiEndpoints {

  object Dtos {

    implicit val scenarioNameSchema: Schema[ProcessName] = Schema.string[ProcessName]

    implicit val deploymentIdCodec: PlainCodec[DeploymentId] =
      Codec.uuid.map(DeploymentId(_))(_.value)

    implicit val commentSchema: Schema[Comment] =
      Schema.string

    // TODO: scenario graph version / the currently active version instead of the latest
    @derive(encoder, decoder, schema)
    final case class RunDeploymentRequest(
        scenarioName: ProcessName,
        nodesDeploymentData: NodesDeploymentData,
        comment: Option[String]
    )

    implicit val deploymentStatusNameCodec: Schema[DeploymentStatusName] = Schema.string[DeploymentStatusName]

    @derive(encoder, decoder, schema)
    final case class GetDeploymentStatusResponse(
        name: DeploymentStatusName,
        problemDescription: Option[String],
        modifiedAt: Instant
    )

    implicit val nodeDeploymentDataCodec: Schema[NodeDeploymentData] = Schema.string[SqlFilteringExpression].as

    implicit val nodesDeploymentDataCodec: Schema[NodesDeploymentData] = Schema
      .schemaForMap[NodeId, NodeDeploymentData](_.id)
      .map[NodesDeploymentData]((map: Map[NodeId, NodeDeploymentData]) => Some(NodesDeploymentData(map)))(
        _.dataByNodeId
      )

    sealed trait RunDeploymentError

    sealed trait BadRequestRunDeploymentError extends RunDeploymentError

    sealed trait ConflictRunDeploymentError extends RunDeploymentError

    final case class ConflictingDeploymentIdError(id: DeploymentId) extends ConflictRunDeploymentError

    final case class ConcurrentDeploymentsForScenarioArePerformedError(
        scenarioName: ProcessName,
        concurrentDeploymentsIds: NonEmptyList[DeploymentId]
    ) extends ConflictRunDeploymentError

    final case class ScenarioNotFoundError(scenarioName: ProcessName) extends BadRequestRunDeploymentError

    case object DeploymentOfFragmentError extends BadRequestRunDeploymentError

    case object DeploymentOfArchivedScenarioError extends BadRequestRunDeploymentError

    final case class CommentValidationError(message: String) extends BadRequestRunDeploymentError

    final case class ScenarioGraphValidationError(errors: ValidationErrors) extends BadRequestRunDeploymentError

    final case class DeployValidationError(message: String) extends BadRequestRunDeploymentError

    sealed trait GetDeploymentStatusError

    final case class DeploymentNotFoundError(id: DeploymentId) extends GetDeploymentStatusError

    case object NoPermissionError extends RunDeploymentError with GetDeploymentStatusError with CustomAuthorizationError

    implicit val badRequestRunDeploymentErrorCodec: Codec[String, BadRequestRunDeploymentError, CodecFormat.TextPlain] =
      BaseEndpointDefinitions.toTextPlainCodecSerializationOnly[BadRequestRunDeploymentError] {
        case ScenarioNotFoundError(scenarioName)  => s"Scenario $scenarioName not found"
        case DeploymentOfFragmentError            => s"Deployment of fragment is not allowed"
        case DeploymentOfArchivedScenarioError    => s"Deployment of archived scenario is not allowed"
        case CommentValidationError(message)      => message
        case ScenarioGraphValidationError(errors) => toHumanReadableMessage(errors)
        case DeployValidationError(message)       => message
      }

    implicit val conflictingDeploymentIdErrorCodec: Codec[String, ConflictRunDeploymentError, CodecFormat.TextPlain] =
      BaseEndpointDefinitions.toTextPlainCodecSerializationOnly[ConflictRunDeploymentError] {
        case ConflictingDeploymentIdError(id) => s"Deployment with id $id already exists"
        case ConcurrentDeploymentsForScenarioArePerformedError(scenarioName, concurrentDeploymentsIds) =>
          s"Deployment can't be run because only a single deployment per scenario can be run at a time. " +
            s"Currently the scenario [$scenarioName] has running deployments with ids: " +
            s"${concurrentDeploymentsIds.toList.sortBy(_.value).mkString(",")}".stripMargin
      }

    implicit val deploymentNotFoundErrorCodec: Codec[String, DeploymentNotFoundError, CodecFormat.TextPlain] =
      BaseEndpointDefinitions.toTextPlainCodecSerializationOnly[DeploymentNotFoundError](err =>
        s"Deployment ${err.id} not found"
      )

  }

  private def toHumanReadableMessage(errors: ValidationErrors) = {
    // TODO: Move to some details field
    s"Scenario is invalid.${Option(errors.invalidNodes)
        .filterNot(_.isEmpty)
        .map {
          _.map { case (nodeId, nodeErrors) =>
            s"\n  $nodeId: ${nodeErrors.map(_.message).mkString(", ")}"
          }.mkString("\nNode errors:", "", "")
        }
        .getOrElse("")}" +
      s"${Option(errors.globalErrors)
          .filterNot(_.isEmpty)
          .map {
            _.map(_.error.message).mkString("\nGlobal errors: ", ", ", "")
          }
          .getOrElse("")}" +
      s"${Option(errors.processPropertiesErrors)
          .filterNot(_.isEmpty)
          .map {
            _.map(_.message).mkString("\nProperties errors: ", ", ", "")
          }
          .getOrElse("")}"
  }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy