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

plugins.izanami.scala Maven / Gradle / Ivy

package otoroshi.plugins.izanami

import java.util.concurrent.atomic.AtomicBoolean
import akka.http.scaladsl.model.Uri
import akka.stream.Materializer
import akka.stream.scaladsl.{Sink, Source}
import akka.util.ByteString
import com.github.blemale.scaffeine.{Cache, Scaffeine}
import otoroshi.env.Env
import otoroshi.next.plugins.api.{NgPluginCategory, NgPluginVisibility, NgStep}
import otoroshi.script.{
  AfterRequestContext,
  BeforeRequestContext,
  HttpRequest,
  HttpResponse,
  RequestTransformer,
  TransformerRequestBodyContext,
  TransformerRequestContext,
  TransformerResponseContext
}
import otoroshi.utils.{RegexPool, TypedMap}
import play.api.libs.json.{JsNull, JsObject, JsValue, Json}
import play.api.mvc.{Cookie, RequestHeader, Result, Results}
import otoroshi.utils.syntax.implicits._
import play.api.libs.ws.{DefaultWSCookie, WSAuthScheme, WSCookie}
import otoroshi.security.IdGenerator
import otoroshi.utils.cache.types.UnboundedTrieMap
import otoroshi.utils.http.RequestImplicits._
import otoroshi.utils.http.{MtlsConfig, WSCookieWithSameSite}
import otoroshi.utils.http.WSCookieWithSameSite

import scala.collection.concurrent.TrieMap
import scala.concurrent.duration.{DurationLong, FiniteDuration}
import scala.concurrent.{ExecutionContext, Future, Promise}
import scala.util.Success

case class IzanamiProxyConfig(
    path: String,
    featurePattern: String,
    configPattern: String,
    autoContext: Boolean,
    featuresEnabled: Boolean,
    featuresWithContextEnabled: Boolean,
    configurationEnabled: Boolean,
    mtls: MtlsConfig,
    izanamiUrl: String,
    izanamiClientId: String,
    izanamiClientSecret: String,
    timeout: FiniteDuration
)

case class IzanamiCanaryConfig(
    experimentId: String,
    configId: String,
    izanamiUrl: String,
    mtls: MtlsConfig,
    izanamiClientId: String,
    izanamiClientSecret: String,
    timeout: FiniteDuration,
    routeConfig: Option[JsObject]
)

// MIGRATED
class IzanamiProxy extends RequestTransformer {

  override def name: String = "Izanami APIs Proxy"

  override def defaultConfig: Option[JsObject] =
    Some(
      Json.obj(
        "IzanamiProxy" -> Json.obj(
          "path"                       -> "/api/izanami",
          "featurePattern"             -> "*",
          "configPattern"              -> "*",
          "autoContext"                -> false,
          "featuresEnabled"            -> true,
          "featuresWithContextEnabled" -> true,
          "configurationEnabled"       -> false,
          "izanamiUrl"                 -> "https://izanami.foo.bar",
          "izanamiClientId"            -> "client",
          "izanamiClientSecret"        -> "secret",
          "timeout"                    -> 5000
        )
      )
    )

  override def configFlow: Seq[String] =
    Seq(
      "featuresEnabled",
      "featuresWithContextEnabled",
      "configurationEnabled",
      "autoContext",
      "---",
      "path",
      "featurePattern",
      "configPattern",
      "---",
      "izanamiUrl",
      "izanamiClientId",
      "izanamiClientSecret",
      "timeout"
    )

  override def description: Option[String] =
    Some(
      s"""This plugin exposes routes to proxy Izanami configuration and features tree APIs.
        |
        |This plugin can accept the following configuration
        |
        |```json
        |${Json.prettyPrint(defaultConfig.get)}
        |```
    """.stripMargin
    )

  def readConfig(ctx: TransformerRequestContext): IzanamiProxyConfig = {
    val rawConfig = ctx.configFor("IzanamiProxy")
    IzanamiProxyConfig(
      path = (rawConfig \ "path").asOpt[String].getOrElse("/api/izanami"),
      featurePattern = (rawConfig \ "featurePattern").asOpt[String].getOrElse("*"),
      configPattern = (rawConfig \ "configPattern").asOpt[String].getOrElse("*"),
      autoContext = (rawConfig \ "autoContext").asOpt[Boolean].getOrElse(false),
      featuresEnabled = (rawConfig \ "featuresEnabled").asOpt[Boolean].getOrElse(true),
      featuresWithContextEnabled = (rawConfig \ "featuresWithContextEnabled").asOpt[Boolean].getOrElse(true),
      configurationEnabled = (rawConfig \ "configurationEnabled").asOpt[Boolean].getOrElse(false),
      izanamiUrl = (rawConfig \ "izanamiUrl").asOpt[String].getOrElse("https://izanami.foo.bar"),
      izanamiClientId = (rawConfig \ "izanamiClientId").asOpt[String].getOrElse("client"),
      izanamiClientSecret = (rawConfig \ "izanamiClientSecret").asOpt[String].getOrElse("secret"),
      timeout = (rawConfig \ "timeout").asOpt[Long].map(_.millis).getOrElse(5000.millis),
      mtls = MtlsConfig.format.reads(rawConfig.select("mtls").as[JsValue]).getOrElse(MtlsConfig())
    )
  }

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

  private val awaitingRequests = new UnboundedTrieMap[String, Promise[Source[ByteString, _]]]()

  override def beforeRequest(
      ctx: BeforeRequestContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Unit] = {
    awaitingRequests.putIfAbsent(ctx.snowflake, Promise[Source[ByteString, _]])
    funit
  }

  override def afterRequest(
      ctx: AfterRequestContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Unit] = {
    awaitingRequests.remove(ctx.snowflake)
    funit
  }

  def getFeatures(ctx: TransformerRequestContext, config: IzanamiProxyConfig)(implicit
      env: Env,
      ec: ExecutionContext,
      mat: Materializer
  ): Future[Either[Result, HttpRequest]] = {
    if (config.autoContext) {
      env.Ws
        .url(s"${config.izanamiUrl}/api/tree/features?pattern=${config.featurePattern}")
        .withRequestTimeout(config.timeout)
        .withHttpHeaders(
          "Izanami-Client-Id"     -> config.izanamiClientId,
          "Izanami-Client-Secret" -> config.izanamiClientSecret
        )
        .withAuth(config.izanamiClientId, config.izanamiClientSecret, WSAuthScheme.BASIC)
        .post(
          ByteString(
            Json.stringify(
              Json.obj(
                "user"   -> ctx.user.map(_.asJsonCleaned).getOrElse(JsNull).as[JsValue],
                "apikey" -> ctx.apikey.map(_.lightJson).getOrElse(JsNull).as[JsValue]
              )
            )
          )
        )
        .map { resp =>
          Results
            .Status(resp.status)(resp.json)
            .withHeaders(
              resp.headers
                .mapValues(_.last)
                .filterNot(v => v._1.toLowerCase == "content-type" || v._1.toLowerCase == "content-length")
                .toSeq: _*
            )
            .as(resp.header("Content-Type").getOrElse("application/json"))
            .left
        }
    } else {
      env.Ws
        .url(s"${config.izanamiUrl}/api/tree/features?pattern=${config.featurePattern}")
        .withRequestTimeout(config.timeout)
        .withHttpHeaders(
          "Izanami-Client-Id"     -> config.izanamiClientId,
          "Izanami-Client-Secret" -> config.izanamiClientSecret
        )
        .withAuth(config.izanamiClientId, config.izanamiClientSecret, WSAuthScheme.BASIC)
        .get()
        .map { resp =>
          Results
            .Status(resp.status)(resp.json)
            .withHeaders(
              resp.headers
                .mapValues(_.last)
                .filterNot(v => v._1.toLowerCase == "content-type" || v._1.toLowerCase == "content-length")
                .toSeq: _*
            )
            .as(resp.header("Content-Type").getOrElse("application/json"))
            .left
        }
    }
  }

  def getFeaturesWithBody(ctx: TransformerRequestContext, config: IzanamiProxyConfig)(implicit
      env: Env,
      ec: ExecutionContext,
      mat: Materializer
  ): Future[Either[Result, HttpRequest]] = {
    awaitingRequests
      .get(ctx.snowflake)
      .map { promise =>
        val bodySource: Source[ByteString, _] = Source
          .future(promise.future)
          .flatMapConcat(s => s)

        bodySource.runFold(ByteString.empty)(_ ++ _).flatMap { bodyRaw =>
          env.Ws
            .url(s"${config.izanamiUrl}/api/tree/features?pattern=${config.featurePattern}")
            .withRequestTimeout(config.timeout)
            .withHttpHeaders(
              "Content-Type"          -> "application/json",
              "Izanami-Client-Id"     -> config.izanamiClientId,
              "Izanami-Client-Secret" -> config.izanamiClientSecret
            )
            .withAuth(config.izanamiClientId, config.izanamiClientSecret, WSAuthScheme.BASIC)
            .post(bodyRaw)
            .map { resp =>
              Results
                .Status(resp.status)(resp.json)
                .withHeaders(
                  resp.headers
                    .mapValues(_.last)
                    .filterNot(v => v._1.toLowerCase == "content-type" || v._1.toLowerCase == "content-length")
                    .toSeq: _*
                )
                .as(resp.header("Content-Type").getOrElse("application/json"))
                .left
            }
        }
      }
      .getOrElse {
        Results.BadRequest(Json.obj("error" -> "bad body")).left.future
      }
  }

  def getConfig(ctx: TransformerRequestContext, config: IzanamiProxyConfig)(implicit
      env: Env,
      ec: ExecutionContext,
      mat: Materializer
  ): Future[Either[Result, HttpRequest]] = {
    env.Ws
      .url(s"${config.izanamiUrl}/api/tree/configs?pattern=${config.configPattern}")
      .withRequestTimeout(config.timeout)
      .withHttpHeaders(
        "Izanami-Client-Id"     -> config.izanamiClientId,
        "Izanami-Client-Secret" -> config.izanamiClientSecret
      )
      .withAuth(config.izanamiClientId, config.izanamiClientSecret, WSAuthScheme.BASIC)
      .get()
      .map { resp =>
        Results
          .Status(resp.status)(resp.json)
          .withHeaders(
            resp.headers
              .mapValues(_.last)
              .filterNot(v => v._1.toLowerCase == "content-type" || v._1.toLowerCase == "content-length")
              .toSeq: _*
          )
          .as(resp.header("Content-Type").getOrElse("application/json"))
          .left
      }
  }

  override def transformRequestWithCtx(
      ctx: TransformerRequestContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Either[Result, HttpRequest]] = {
    val config = readConfig(ctx)
    (ctx.request.method.toLowerCase, ctx.request.path) match {
      case ("get", path) if path == config.path + "/features" && config.featuresEnabled             => getFeatures(ctx, config)
      case ("post", path) if path == config.path + "/features" && config.featuresWithContextEnabled =>
        getFeaturesWithBody(ctx, config)
      case ("get", path) if path == config.path + "/configs" && config.configurationEnabled         => getConfig(ctx, config)
      case _                                                                                        => ctx.otoroshiRequest.right.future
    }
  }

  override def transformRequestBodyWithCtx(
      ctx: TransformerRequestBodyContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Source[ByteString, _] = {
    awaitingRequests.get(ctx.snowflake).map(_.trySuccess(ctx.body))
    ctx.body
  }
}

case class IzanamiCanaryRoutingConfigRoute(
    route: String,
    variants: Map[String, String],
    default: String,
    wildcard: Boolean,
    exact: Boolean,
    regex: Boolean
)
case class IzanamiCanaryRoutingConfig(
    routes: Seq[IzanamiCanaryRoutingConfigRoute]
)

object IzanamiCanaryRoutingConfig {
  def fromJson(json: JsValue): IzanamiCanaryRoutingConfig = {
    IzanamiCanaryRoutingConfig(
      routes = json.select("routes").asArray.value.map { item =>
        IzanamiCanaryRoutingConfigRoute(
          route = item.select("route").asString,
          default = item.select("default").asString,
          variants = item.select("variants").asOpt[Map[String, String]].getOrElse(Map.empty),
          wildcard = item.select("wildcard").asOpt[Boolean].getOrElse(false),
          exact = item.select("exact").asOpt[Boolean].getOrElse(false),
          regex = item.select("regex").asOpt[Boolean].getOrElse(false)
        )
      }
    )
  }
}

// MIGRATED
class IzanamiCanary extends RequestTransformer {

  private val cookieJar = new UnboundedTrieMap[String, WSCookie]()

  private val cache: Cache[String, JsValue] = Scaffeine()
    .recordStats()
    .expireAfterWrite(10.minutes)
    .maximumSize(1000)
    .build()

  override def name: String = "Izanami Canary Campaign"

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

  override def defaultConfig: Option[JsObject] = {
    Some(
      Json.obj(
        "IzanamiCanary" -> Json.obj(
          "experimentId"        -> "foo:bar:qix",
          "configId"            -> "foo:bar:qix:config",
          "izanamiUrl"          -> "https://izanami.foo.bar",
          "izanamiClientId"     -> "client",
          "izanamiClientSecret" -> "secret",
          "timeout"             -> 5000,
          "mtls"                -> MtlsConfig().json
        )
      )
    )
  }

  override def configFlow: Seq[String] =
    Seq(
      "experimentId",
      "configId",
      "---",
      "izanamiUrl",
      "izanamiClientId",
      "izanamiClientSecret",
      "timeout",
      "---",
      "mtls.certs",
      "mtls.trustedCerts",
      "mtls.mtls",
      "mtls.loose",
      "mtls.trustAll"
    )

  override def description: Option[String] = {
    Some(
      s"""This plugin allow you to perform canary testing based on an izanami experiment campaign (A/B test).
         |
         |This plugin can accept the following configuration
         |
         |```json
         |${Json.prettyPrint(defaultConfig.get)}
         |```
    """.stripMargin
    )
  }

  def readConfig(ctx: TransformerRequestContext): IzanamiCanaryConfig = {
    val rawConfig = ctx.configFor("IzanamiCanary")
    IzanamiCanaryConfig(
      experimentId = (rawConfig \ "experimentId").as[String],
      configId =
        (rawConfig \ "configId").asOpt[String].getOrElse((rawConfig \ "experimentId").as[String] + ":route_config"),
      izanamiUrl = (rawConfig \ "izanamiUrl").asOpt[String].getOrElse("https://izanami.foo.bar"),
      izanamiClientId = (rawConfig \ "izanamiClientId").asOpt[String].getOrElse("client"),
      izanamiClientSecret = (rawConfig \ "izanamiClientSecret").asOpt[String].getOrElse("secret"),
      timeout = (rawConfig \ "timeout").asOpt[Long].map(_.millis).getOrElse(5000.millis),
      mtls = MtlsConfig.format.reads(rawConfig.select("mtls").as[JsValue]).getOrElse(MtlsConfig()),
      routeConfig = rawConfig.select("routeConfig").asOpt[JsObject]
    )
  }

  def canaryId(ctx: TransformerRequestContext)(implicit env: Env): String = {
    val attrs: TypedMap               = ctx.attrs
    val reqNumber: Option[Int]        = attrs.get(otoroshi.plugins.Keys.RequestNumberKey)
    val maybeCanaryId: Option[String] = attrs.get(otoroshi.plugins.Keys.RequestCanaryIdKey)
    val canaryId: String              = maybeCanaryId.getOrElse(IdGenerator.uuid + "-" + reqNumber.get)
    canaryId
  }

  def canaryCookie(cid: String, ctx: TransformerRequestContext)(implicit env: Env): WSCookie = {
    ctx.request.cookies.get("otoroshi-canary").map { cookie =>
      WSCookieWithSameSite(
        name = cookie.name,
        value = cookie.value,
        domain = cookie.domain,
        path = cookie.path.some,
        maxAge = cookie.maxAge.map(_.toLong),
        secure = cookie.secure,
        httpOnly = cookie.httpOnly,
        sameSite = cookie.sameSite
      )
    } getOrElse {
      WSCookieWithSameSite(
        name = "otoroshi-canary",
        value = s"${env.sign(cid)}::$cid",
        domain = ctx.request.theDomain.some,
        path = "/".some,
        maxAge = Some(2592000),
        secure = false,
        httpOnly = false,
        sameSite = Cookie.SameSite.Lax.some
      )
    }
  }

  def withCache(key: String)(f: String => Future[JsValue])(implicit ec: ExecutionContext): Future[JsValue] = {
    cache.getIfPresent(key).map(_.future).getOrElse {
      f(key).andThen { case Success(v) =>
        cache.put(key, v)
      }
    }
  }

  def fetchIzanamiVariant(cid: String, config: IzanamiCanaryConfig, ctx: TransformerRequestContext)(implicit
      env: Env,
      ec: ExecutionContext
  ): Future[String] = {
    withCache(s"${config.izanamiUrl}/api/experiments/${config.experimentId}/displayed?clientId=$cid") { url =>
      env.MtlsWs
        .url(url, config.mtls)
        .withRequestTimeout(config.timeout)
        .withHttpHeaders(
          "Content-Type"          -> "application/json",
          "Izanami-Client-Id"     -> config.izanamiClientId,
          "Izanami-Client-Secret" -> config.izanamiClientSecret
        )
        .withAuth(config.izanamiClientId, config.izanamiClientSecret, WSAuthScheme.BASIC)
        .post("")
        .map(_.json)
    }.map(r => r.asObject.select("variant").select("id").asOpt[String].getOrElse(IdGenerator.uuid))
  }

  def fetchIzanamiRoutingConfig(config: IzanamiCanaryConfig, ctx: TransformerRequestContext)(implicit
      env: Env,
      ec: ExecutionContext
  ): Future[IzanamiCanaryRoutingConfig] = {
    withCache(s"${config.izanamiUrl}/api/configs/${config.configId}") { url =>
      config.routeConfig match {
        case Some(c) => c.future
        case None    => {
          env.MtlsWs
            .url(url, config.mtls)
            .withRequestTimeout(config.timeout)
            .withHttpHeaders(
              "Izanami-Client-Id"     -> config.izanamiClientId,
              "Izanami-Client-Secret" -> config.izanamiClientSecret
            )
            .withAuth(config.izanamiClientId, config.izanamiClientSecret, WSAuthScheme.BASIC)
            .get()
            .map(_.json)
        }
      }
    }.map { json =>
      val routing = json.asObject.select("value").as[JsObject]
      IzanamiCanaryRoutingConfig.fromJson(routing)
    }
  }

  override def transformRequestWithCtx(
      ctx: TransformerRequestContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Either[Result, HttpRequest]] = {
    val config = readConfig(ctx)
    val cid    = canaryId(ctx)
    val cookie = canaryCookie(cid, ctx)
    for {
      routing <- fetchIzanamiRoutingConfig(config, ctx)
      variant <- fetchIzanamiVariant(cid, config, ctx)
    } yield {
      val uri               = ctx.otoroshiRequest.uri
      val path: Uri.Path    = uri.path
      val pathStr           = path.toString()
      val newPath: Uri.Path = routing.routes.find {
        case r if r.wildcard => RegexPool(r.route).matches(pathStr)
        case r if r.regex    => RegexPool.regex(r.route).matches(pathStr)
        case r if r.exact    => pathStr == r.route
        case r               => pathStr.startsWith(r.route)
      } match {
        case Some(route) if route.wildcard || route.regex || route.exact =>
          route.variants.get(variant) match {
            case Some(variantPath) => Uri.Path(variantPath)
            case None              => Uri.Path(route.default)
          }
        case Some(route)                                                 =>
          val strippedPath = pathStr.replaceFirst(route.route, "")
          route.variants.get(variant) match {
            case Some(variantPathBeginning) => Uri.Path(variantPathBeginning + strippedPath)
            case None                       => Uri.Path(route.default + strippedPath)
          }
        case None                                                        => path
      }
      val newUri            = uri.copy(path = newPath)
      val newUriStr         = newUri.toString()
      cookieJar.put(ctx.snowflake, cookie)
      Right(ctx.otoroshiRequest.copy(url = newUriStr))
    }
  }

  override def transformResponseWithCtx(
      ctx: TransformerResponseContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Either[Result, HttpResponse]] = {
    cookieJar.get(ctx.snowflake).map { cookie =>
      val allCookies = ctx.otoroshiResponse.cookies :+ cookie
      val cookies    = allCookies.distinct
      cookieJar.remove(ctx.snowflake)
      ctx.otoroshiResponse.copy(cookies = cookies).rightf
    } getOrElse {
      ctx.otoroshiResponse.rightf
    }
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy