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

next.plugins.response.scala Maven / Gradle / Ivy

package otoroshi.next.plugins

import akka.stream.Materializer
import akka.stream.scaladsl.Source
import akka.util.ByteString
import org.joda.time.DateTime
import otoroshi.el.GlobalExpressionLanguage
import otoroshi.el.GlobalExpressionLanguage.expressionReplacer
import otoroshi.env.Env
import otoroshi.events.AnalyticEvent
import otoroshi.next.models.{NgDomainAndPath, NgFrontend, NgTreeRouter, NgTreeRouter_Test}
import otoroshi.next.models.NgTreeRouter_Test.NgFakeRoute
import otoroshi.next.plugins.api._
import otoroshi.next.proxy.NgProxyEngineError
import otoroshi.utils.TypedMap
import otoroshi.utils.http.RequestImplicits.EnhancedRequestHeader
import otoroshi.utils.syntax.implicits._
import play.api.Logger
import play.api.libs.json._
import play.api.mvc.Result

import java.util.UUID
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Either, Failure, Success, Try}

case class StaticResponseConfig(status: Int = 200, headers: Map[String, String] = Map.empty, body: String = "")
    extends NgPluginConfig {
  def json: JsValue = StaticResponseConfig.format.writes(this)
}

object StaticResponseConfig {
  val format = new Format[StaticResponseConfig] {
    override def writes(o: StaticResponseConfig): JsValue             = Json.obj(
      "status"  -> o.status,
      "headers" -> o.headers,
      "body"    -> o.body
    )
    override def reads(json: JsValue): JsResult[StaticResponseConfig] = Try {
      StaticResponseConfig(
        status = json.select("status").asOpt[Int].getOrElse(200),
        headers = json.select("headers").asOpt[Map[String, String]].getOrElse(Map.empty),
        body = json.select("body").asOpt[String].getOrElse("")
      )
    } match {
      case Failure(ex)    => JsError(ex.getMessage())
      case Success(value) => JsSuccess(value)
    }
  }
}

class StaticResponse extends NgBackendCall {

  override def useDelegates: Boolean                       = false
  override def multiInstance: Boolean                      = true
  override def core: Boolean                               = true
  override def name: String                                = "Static Response"
  override def description: Option[String]                 = "This plugin returns static responses".some
  override def defaultConfigObject: Option[NgPluginConfig] = StaticResponseConfig().some

  override def visibility: NgPluginVisibility    = NgPluginVisibility.NgUserLand
  override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.TrafficControl)
  override def steps: Seq[NgStep]                = Seq(NgStep.CallBackend)

  override def callBackend(
      ctx: NgbBackendCallContext,
      delegates: () => Future[Either[NgProxyEngineError, BackendCallResponse]]
  )(implicit
      env: Env,
      ec: ExecutionContext,
      mat: Materializer
  ): Future[Either[NgProxyEngineError, BackendCallResponse]] = {
    val config           = ctx.cachedConfig(internalName)(StaticResponseConfig.format).getOrElse(StaticResponseConfig())
    val body: ByteString = config.body match {
      case str if str.startsWith("Base64(") => str.substring(7).init.byteString.decodeBase64
      case str                              => str.byteString
    }
    inMemoryBodyResponse(config.status, config.headers, body).future
  }
}

case class MockResponse(
    path: String = "/",
    method: String = "GET",
    status: Int = 200,
    headers: Map[String, String] = Map.empty,
    body: String = ""
) extends NgPluginConfig {
  def json: JsValue = MockResponse.format.writes(this)
}

object MockResponse {
  val format = new Format[MockResponse] {
    override def writes(o: MockResponse): JsValue             = Json.obj(
      "path"    -> o.path,
      "method"  -> o.method,
      "status"  -> o.status,
      "headers" -> o.headers,
      "body"    -> o.body
    )
    override def reads(json: JsValue): JsResult[MockResponse] = Try {
      MockResponse(
        path = json.select("path").asOpt[String].getOrElse("/"),
        method = json.select("method").asOpt[String].getOrElse("GET"),
        status = json.select("status").asOpt[Int].getOrElse(200),
        headers = json.select("headers").asOpt[Map[String, String]].getOrElse(Map.empty),
        body = json.select("body").asOpt[String].getOrElse("")
      )
    } match {
      case Failure(ex)    => JsError(ex.getMessage())
      case Success(value) => JsSuccess(value)
    }
  }
}

case class MockField(fieldName: String, fieldType: String, value: JsValue)
case class MockResource(name: String, schema: Seq[MockField] = Seq.empty, additionalData: Option[JsObject] = None)
case class MockEndpoint(
    method: String,
    path: String,
    status: Int,
    body: Option[JsObject] = None,
    resource: Option[String] = None,
    resourceList: Boolean = false,
    headers: Option[JsObject] = None,
    length: Option[Int] = None
)
case class MockFormData(resources: Seq[MockResource] = Seq.empty, endpoints: Seq[MockEndpoint] = Seq.empty)

object MockField {
  val format = new Format[MockField] {
    override def writes(o: MockField): JsValue = Json.obj(
      "field_name" -> o.fieldName,
      "field_type" -> o.fieldType,
      "value"      -> o.value
    )

    override def reads(json: JsValue): JsResult[MockField] = Try {
      MockField(
        fieldName = json.select("field_name").as[String],
        fieldType = json.select("field_type").as[String],
        value = json.select("value").as[JsValue]
      )
    } match {
      case Failure(ex)    => JsError(ex.getMessage)
      case Success(value) => JsSuccess(value)
    }
  }
}

object MockResource {
  val format = new Format[MockResource] {
    override def writes(o: MockResource): JsValue             = Json.obj(
      "name"            -> o.name,
      "schema"          -> JsArray(o.schema.map(MockField.format.writes)),
      "additional_data" -> o.additionalData
    )
    override def reads(json: JsValue): JsResult[MockResource] = Try {
      MockResource(
        name = json.select("name").as[String],
        schema = json
          .select("schema")
          .asOpt[Seq[JsValue]]
          .map(arr => arr.flatMap(v => MockField.format.reads(v).asOpt))
          .getOrElse(Seq.empty),
        additionalData = json.select("additional_data").asOpt[JsObject]
      )
    } match {
      case Failure(ex)    => JsError(ex.getMessage)
      case Success(value) => JsSuccess(value)
    }
  }
}

object MockEndpoint {
  val format = new Format[MockEndpoint] {
    override def writes(o: MockEndpoint): JsValue             = Json.obj(
      "method"        -> o.method,
      "path"          -> o.path,
      "status"        -> o.status,
      "body"          -> o.body,
      "resource"      -> o.resource,
      "resource_list" -> o.resourceList,
      "headers"       -> o.headers,
      "length"        -> o.length
    )
    override def reads(json: JsValue): JsResult[MockEndpoint] = Try {
      MockEndpoint(
        method = json.select("method").as[String],
        path = json.select("path").as[String],
        status = json.select("status").as[Int],
        body = json.select("body").asOpt[JsObject],
        resource = json.select("resource").asOpt[String],
        resourceList = json.select("resource_list").as[Boolean],
        headers = json.select("headers").asOpt[JsObject],
        length = json.select("length").asOpt[Int]
      )
    } match {
      case Failure(ex)    => JsError(ex.getMessage)
      case Success(value) => JsSuccess(value)
    }
  }
}

object MockFormData {
  val format = new Format[MockFormData] {
    override def writes(o: MockFormData): JsValue             = Json.obj(
      "resources" -> JsArray(o.resources.map(MockResource.format.writes)),
      "endpoints" -> JsArray(o.endpoints.map(MockEndpoint.format.writes))
    )
    override def reads(json: JsValue): JsResult[MockFormData] = Try {
      MockFormData(
        resources = json
          .select("resources")
          .asOpt[Seq[JsValue]]
          .map(arr => arr.flatMap(v => MockResource.format.reads(v).asOpt))
          .getOrElse(Seq.empty),
        endpoints = json
          .select("endpoints")
          .asOpt[Seq[JsValue]]
          .map(arr => arr.flatMap(v => MockEndpoint.format.reads(v).asOpt))
          .getOrElse(Seq.empty)
      )
    } match {
      case Failure(ex)    => JsError(ex.getMessage)
      case Success(value) => JsSuccess(value)
    }
  }
}

case class MockResponsesConfig(
    responses: Seq[MockResponse] = Seq.empty,
    passThrough: Boolean = true,
    formData: Option[MockFormData] = None
) extends NgPluginConfig {
  def json: JsValue = MockResponsesConfig.format.writes(this)
}

object MockResponsesConfig {
  val format = new Format[MockResponsesConfig] {
    override def writes(o: MockResponsesConfig): JsValue             = Json.obj(
      "responses"    -> JsArray(o.responses.map(_.json)),
      "pass_through" -> o.passThrough
    )
    override def reads(json: JsValue): JsResult[MockResponsesConfig] = Try {
      MockResponsesConfig(
        responses = json
          .select("responses")
          .asOpt[Seq[JsValue]]
          .map(arr => arr.flatMap(v => MockResponse.format.reads(v).asOpt))
          .getOrElse(Seq.empty),
        passThrough = json.select("pass_through").asOpt[Boolean].getOrElse(true),
        formData = json.select("form_data").asOpt[MockFormData](MockFormData.format.reads)
      )
    } match {
      case Failure(ex)    => JsError(ex.getMessage)
      case Success(value) => JsSuccess(value)
    }
  }
}

class MockResponses extends NgBackendCall {

  override def useDelegates: Boolean                       = true
  override def multiInstance: Boolean                      = true
  override def core: Boolean                               = true
  override def name: String                                = "Mock Responses"
  override def description: Option[String]                 = "This plugin returns mock responses".some
  override def defaultConfigObject: Option[NgPluginConfig] = MockResponsesConfig().some

  override def visibility: NgPluginVisibility    = NgPluginVisibility.NgUserLand
  override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.TrafficControl)
  override def steps: Seq[NgStep]                = Seq(NgStep.CallBackend)

  override def callBackend(
      ctx: NgbBackendCallContext,
      delegates: () => Future[Either[NgProxyEngineError, BackendCallResponse]]
  )(implicit
      env: Env,
      ec: ExecutionContext,
      mat: Materializer
  ): Future[Either[NgProxyEngineError, BackendCallResponse]] = {
    // println("MockResponses callBackend")
    val config = ctx.cachedConfig(internalName)(MockResponsesConfig.format).getOrElse(MockResponsesConfig())
    val paths  = config.responses
      .filter(r =>
        r.method.toLowerCase == ctx.request.method.toLowerCase || r.method.toLowerCase == ctx.rawRequest.method.toLowerCase
      )

    NgTreeRouter
      .build(
        paths.map(resp => {
          val r = NgFakeRoute.routeFromPath(s"oto.tools${resp.path}")
          r.copy(
            metadata = Map("mock" -> resp.json.stringify),
            frontend = r.frontend.copy(exact = true)
          )
        })
      )
      .find("oto.tools", ctx.request.path)
      .filter(_.noMoreSegments)
      .flatMap { c =>
        if (c.routes.headOption.nonEmpty)
          Some(c)
        else
          None
      }
      .map(r => {
        import kaleidoscope._

        val route    = r.routes.headOption.get
        val response = Json.parse(route.metadata("mock")).as[MockResponse](MockResponse.format)

        def replaceOn(value: String) = {
          val newValue = Try {
            expressionReplacer.replaceOn(value) {
              case r"req.pathparams.$field@(.*):$defaultValue@(.*)" => r.pathParams.getOrElse(field, defaultValue)
              case r"req.pathparams.$field@(.*)"                    => r.pathParams.getOrElse(field, s"no-path-param-$field")
              case r                                                => r
            }
          } recover { case _ => value } get

          GlobalExpressionLanguage.apply(
            newValue,
            req = ctx.rawRequest.some,
            service = ctx.route.legacy.some,
            route = ctx.route.some,
            apiKey = ctx.apikey,
            user = ctx.user,
            context = Map.empty,
            attrs = ctx.attrs,
            env = env
          )
        }

        val body: ByteString = response.body match {
          case str if str.startsWith("Base64(") => replaceOn(str.substring(7).init).byteString.decodeBase64
          case str                              => replaceOn(str).byteString
        }
        inMemoryBodyResponse(response.status, response.headers, body).future
      })
      .getOrElse {
        if (!config.passThrough)
          inMemoryBodyResponse(
            404,
            Map("Content-Type" -> "application/json"),
            Json.obj("error" -> "resource not found !").stringify.byteString
          ).future
        else
          delegates()
      }
  }
}

case class ResponseStatusRange(from: Int, to: Int) {
  def contains(status: Int): Boolean = status >= from && status <= to
  def json: JsValue                  = Json.obj(
    "from" -> from,
    "to"   -> to
  )
}

case class NgErrorRewriterConfig(
    ranges: Seq[ResponseStatusRange],
    templates: Map[String, String],
    log: Boolean,
    export: Boolean
) extends NgPluginConfig {
  def matching(status: Int): Boolean = ranges.exists(_.contains(status))
  def json: JsValue                  = NgErrorRewriterConfig.fmt.writes(this)
}

object NgErrorRewriterConfig {
  val default = NgErrorRewriterConfig(
    ranges = Seq(
      ResponseStatusRange(500, 599)
    ),
    templates = Map(
      "default" ->
      """
        |  
        |    

An error occurred with id: ${error_id}

|

please contact your administrator with this error id !

| |""".stripMargin ), log = true, export = true ) val fmt = new Format[NgErrorRewriterConfig] { override def reads(json: JsValue): JsResult[NgErrorRewriterConfig] = Try { NgErrorRewriterConfig( log = json.select("log").asOpt[Boolean].getOrElse(false), export = json.select("export").asOpt[Boolean].getOrElse(false), templates = json.select("templates").asOpt[Map[String, String]].getOrElse(Map.empty), ranges = json .select("ranges") .asOpt[JsArray] .map(arr => arr.value.map(item => ResponseStatusRange(item.select("from").asInt, item.select("to").asInt))) .getOrElse(Seq.empty) ) } match { case Failure(e) => JsError(e.getMessage) case Success(s) => JsSuccess(s) } override def writes(o: NgErrorRewriterConfig): JsValue = Json.obj( "ranges" -> JsArray(o.ranges.map(_.json)), "templates" -> o.templates, "log" -> o.log, "export" -> o.export ) } } class NgErrorRewriter extends NgRequestTransformer { private val logger = Logger("otoroshi-plugins-error-rewriter") override def multiInstance: Boolean = true override def defaultConfigObject: Option[NgPluginConfig] = NgErrorRewriterConfig.default.some override def steps: Seq[NgStep] = Seq(NgStep.TransformResponse) override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.Transformations) override def visibility: NgPluginVisibility = NgPluginVisibility.NgUserLand override def core: Boolean = true override def usesCallbacks: Boolean = false override def transformsRequest: Boolean = false override def transformsResponse: Boolean = true override def transformsError: Boolean = false override def isTransformRequestAsync: Boolean = false override def isTransformResponseAsync: Boolean = true override def name: String = "Error response rewrite" override def description: Option[String] = "This plugin catch http response with specific statuses and rewrite the response".some override def transformResponse( ctx: NgTransformerResponseContext )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Either[Result, NgPluginHttpResponse]] = { val config = ctx.cachedConfig(internalName)(NgErrorRewriterConfig.fmt).getOrElse(NgErrorRewriterConfig.default) if (config.matching(ctx.otoroshiResponse.status)) { val errorId = UUID.randomUUID().toString val (defaultCtype: String, defaultTemplate: String) = config.templates .get("default") .map(v => ("text/html", v)) .orElse(config.templates.headOption) .getOrElse(("text/plain", "error: ${error_id}")) val (ctype: String, template: String) = config.templates.keys .find(ct => ctx.request.accepts(ct)) .flatMap(key => config.templates.get(key).map(v => (key, v))) .getOrElse((defaultCtype, defaultTemplate)) ctx.otoroshiResponse.body.runFold(ByteString.empty)(_ ++ _).map { bodyRaw => val responseBody = template.replace("${error_id}", errorId) val response = ctx.otoroshiResponse.copy( status = ctx.otoroshiResponse.status, headers = Map( "content-type" -> ctype.applyOnWithPredicate(_ == "default")(_ => "text/html"), "content-length" -> responseBody.length.toString ), cookies = Seq.empty, body = responseBody.byteString.chunks(16 * 1024) ) val event = ErrorRewriteReport( errorId, NgPluginHttpRequest.fromRequest(ctx.request), ctx.otoroshiResponse, bodyRaw.utf8String, response, responseBody ) if (config.log) { logger.error(s"new error rewritten with id: ${errorId}, event: ${event.toJson(env).prettify}") } if (config.export) { event.toAnalytics() } response.right } } else { ctx.otoroshiResponse.rightf } } } case class ErrorRewriteReport( id: String, request: NgPluginHttpRequest, rawResponse: NgPluginHttpResponse, rawResponseBody: String, response: NgPluginHttpResponse, responseBody: String ) extends AnalyticEvent { private val timestamp = DateTime.now() override def `@type`: String = "ErrorRewriteReport" override def `@id`: String = id override def `@timestamp`: DateTime = timestamp override def `@service`: String = "Otoroshi" override def `@serviceId`: String = "--" override def fromOrigin: Option[String] = None override def fromUserAgent: Option[String] = None override def toJson(implicit _env: Env): JsValue = Json.obj( "@id" -> `@id`, "@timestamp" -> play.api.libs.json.JodaWrites.JodaDateTimeNumberWrites.writes(`@timestamp`), "@type" -> `@type`, "@product" -> _env.eventsName, "@serviceId" -> `@serviceId`, "@service" -> `@service`, "@env" -> "prod", "request" -> request.json, "original_response" -> (rawResponse.json.asObject ++ Json.obj("body" -> rawResponseBody)), "sent_response" -> (response.json.asObject ++ Json.obj("body" -> responseBody)) ) }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy