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

models.descriptor.scala Maven / Gradle / Ivy

package otoroshi.models

import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.{AtomicInteger, AtomicLong, AtomicReference}
import akka.http.scaladsl.util.FastFuture
import akka.http.scaladsl.util.FastFuture._
import akka.stream.{Materializer, OverflowStrategy}
import akka.stream.scaladsl.{Flow, Keep, Sink, Source}
import otoroshi.auth._
import com.auth0.jwt.JWT
import com.comcast.ip4s.{Cidr, IpAddress}
import com.google.common.hash.Hashing
import otoroshi.env.Env
import otoroshi.gateway.Errors
import org.joda.time.DateTime
import otoroshi.el.RedirectionExpressionLanguage
import otoroshi.models.HttpProtocols.{HTTP_1_0, HTTP_1_1, HTTP_2_0, HTTP_3_0}
import otoroshi.next.models.{NgOverflowStrategy, NgTarget}
import otoroshi.plugins.oidc.{OIDCThirdPartyApiKeyConfig, ThirdPartyApiKeyConfig}
import play.api.Logger
import play.api.http.websocket.{Message => PlayWSMessage}
import play.api.libs.json._
import play.api.libs.ws.DefaultBodyWritables.writeableOf_urlEncodedSimpleForm
import play.api.libs.ws.{DefaultWSProxyServer, WSProxyServer}
import play.api.mvc.Results.{NotFound, TooManyRequests}
import play.api.mvc.{RequestHeader, Result, Results}
import otoroshi.script._
import otoroshi.script.plugins.Plugins
import otoroshi.security.{IdGenerator, OtoroshiClaim}
import otoroshi.storage.BasicStore
import otoroshi.storage.stores.KvServiceDescriptorDataStore
import otoroshi.utils.{RegexPool, TypedMap}
import otoroshi.utils.config.ConfigUtils
import otoroshi.utils.gzip.GzipConfig
import otoroshi.utils.ReplaceAllWith
import otoroshi.utils.cache.Caches
import otoroshi.utils.cache.types.{UnboundedConcurrentHashMap, UnboundedTrieMap}
import otoroshi.utils.http.{CacheConnectionSettings, MtlsConfig}

import scala.collection.concurrent.TrieMap
import scala.concurrent.duration.{FiniteDuration, _}
import scala.concurrent.{ExecutionContext, Future, Promise}
import scala.util.{Failure, Success, Try}
import otoroshi.utils.http.RequestImplicits._
import otoroshi.utils.syntax.implicits.{BetterJsReadable, BetterJsValue, BetterSyntax}
import otoroshi.utils.infotoken.InfoTokenHelper

case class ServiceDescriptorQuery(
    subdomain: String,
    line: String = "prod",
    domain: String,
    root: String = "/",
    matchingHeaders: Map[String, String] = Map.empty[String, String]
) {

  def asKey(implicit _env: Env): String = s"${_env.storageRoot}:desclookup:$line:$domain:$subdomain:$root"

  lazy val toHost: String =
    subdomain match {
      case s if s.isEmpty && line == "prod" => s"$domain"
      case s if s.isEmpty                   => s"$line.$domain"
      case s if line == "prod"              => s"$subdomain.$domain"
      case s                                => s"$subdomain.$line.$domain"
    }

  private val existsCache     = new UnboundedConcurrentHashMap[String, Boolean]
  private val serviceIdsCache = new UnboundedConcurrentHashMap[String, Seq[String]]
  private val servicesCache   = new UnboundedConcurrentHashMap[String, Seq[ServiceDescriptor]]

  def exists()(implicit ec: ExecutionContext, env: Env): Future[Boolean] = {
    val key = this.asKey
    if (!existsCache.containsKey(key)) {
      env.datastores.serviceDescriptorDataStore.fastLookupExists(this).andThen { case scala.util.Success(ex) =>
        existsCache.put(key, ex)
      }
    } else {
      env.datastores.serviceDescriptorDataStore.fastLookupExists(this).andThen { case scala.util.Success(ex) =>
        existsCache.put(key, ex)
      }
      FastFuture.successful(existsCache.get(key))
    }
  }

  def get()(implicit ec: ExecutionContext, env: Env): Future[Seq[String]] = {
    val key = this.asKey
    if (!serviceIdsCache.containsKey(key)) {
      env.datastores.serviceDescriptorDataStore.getFastLookups(this).andThen { case scala.util.Success(ex) =>
        serviceIdsCache.put(key, ex)
      }
    } else {
      env.datastores.serviceDescriptorDataStore.getFastLookups(this).andThen { case scala.util.Success(ex) =>
        serviceIdsCache.put(key, ex)
      }
      FastFuture.successful(serviceIdsCache.get(key))
    }
  }

  def getServices(force: Boolean = false)(implicit ec: ExecutionContext, env: Env): Future[Seq[ServiceDescriptor]] = {
    val key = this.asKey
    get().flatMap { ids =>
      if (!servicesCache.containsKey(key)) {
        env.datastores.serviceDescriptorDataStore.findAllById(ids, force).andThen { case scala.util.Success(ex) =>
          servicesCache.put(key, ex)
        }
      } else {
        env.datastores.serviceDescriptorDataStore.findAllById(ids, force).andThen { case scala.util.Success(ex) =>
          servicesCache.put(key, ex)
        }
        FastFuture.successful(servicesCache.get(key))
      }
    }
  }

  def addServices(services: Seq[ServiceDescriptor])(implicit ec: ExecutionContext, env: Env): Future[Boolean] = {
    if (services.isEmpty) {
      FastFuture.successful(true)
    } else {
      val key = this.asKey
      existsCache.put(key, true)
      serviceIdsCache.put(key, services.map(_.id))
      servicesCache.put(key, services)
      env.datastores.serviceDescriptorDataStore.addFastLookups(this, services)
    }
  }

  def remServices(services: Seq[ServiceDescriptor])(implicit ec: ExecutionContext, env: Env): Future[Boolean] = {
    val key        = this.asKey
    val servicesId = services.map(_.id)
    val resulting  =
      if (servicesCache.containsKey(key)) servicesCache.get(key).filterNot(s => servicesId.contains(s.id))
      else Seq.empty[ServiceDescriptor]
    if (resulting.isEmpty) {
      existsCache.put(key, false)
      servicesCache.remove(key)
      serviceIdsCache.remove(key)
    } else {
      existsCache.put(key, true)
      serviceIdsCache.put(key, resulting.map(_.id))
      servicesCache.put(key, resulting)
    }
    env.datastores.serviceDescriptorDataStore.removeFastLookups(this, services)
  }
}

case class ServiceLocation(domain: String, env: String, subdomain: String)

object ServiceLocation {

  def fullQuery(host: String, config: GlobalConfig): Option[ServiceLocation] = {
    val hostName = if (host.contains(":")) host.split(":")(0) else host
    hostName.split("\\.").toSeq.reverse match {
      case Seq(tld, domain, env, tail @ _*) if tail.nonEmpty && config.lines.contains(env) =>
        Some(ServiceLocation(s"$domain.$tld", env, tail.reverse.mkString(".")))
      case Seq(tld, domain, tail @ _*) if tail.nonEmpty                                    =>
        Some(ServiceLocation(s"$domain.$tld", "prod", tail.reverse.mkString(".")))
      case Seq(domain, subdomain)                                                          => Some(ServiceLocation(s"$domain", "prod", subdomain))
      case Seq(domain)                                                                     => Some(ServiceLocation(s"$domain", "prod", ""))
      case _                                                                               => None
    }
  }

  def apply(host: String, config: GlobalConfig): Option[ServiceLocation] = fullQuery(host, config)
}

case class ApiDescriptor(exposeApi: Boolean = false, openApiDescriptorUrl: Option[String] = None) {
  def toJson = ApiDescriptor.format.writes(this)
}

object ApiDescriptor {
  implicit val format = Json.format[ApiDescriptor]
}

case class BaseQuotas(
    throttlingQuota: Long = BaseQuotas.MaxValue,
    dailyQuota: Long = BaseQuotas.MaxValue,
    monthlyQuota: Long = BaseQuotas.MaxValue
) {
  def toJson = BaseQuotas.format.writes(this)
}

object BaseQuotas {
  implicit val format = Json.format[BaseQuotas]
  val MaxValue: Long  = RemainingQuotas.MaxValue
}

trait LoadBalancing {
  def needTrackingCookie: Boolean
  def toJson: JsValue
  def select(
      reqId: String,
      trackingId: String,
      requestHeader: RequestHeader,
      targets: Seq[Target],
      descId: String
  )(implicit env: Env): Target
}

object LoadBalancing {
  val format: Format[LoadBalancing] = new Format[LoadBalancing] {
    override def writes(o: LoadBalancing): JsValue             = o.toJson
    override def reads(json: JsValue): JsResult[LoadBalancing] =
      (json \ "type").asOpt[String] match {
        case Some("RoundRobin")               => JsSuccess(RoundRobin)
        case Some("Random")                   => JsSuccess(Random)
        case Some("Sticky")                   => JsSuccess(Sticky)
        case Some("IpAddressHash")            => JsSuccess(IpAddressHash)
        case Some("BestResponseTime")         => JsSuccess(BestResponseTime)
        case Some("WeightedBestResponseTime") =>
          JsSuccess(WeightedBestResponseTime((json \ "ratio").asOpt[Double].getOrElse(0.5)))
        case _                                => JsSuccess(RoundRobin)
      }
  }
}

object RoundRobin extends LoadBalancing {
  private val reqCounter                   = new AtomicInteger(0)
  override def needTrackingCookie: Boolean = false
  override def toJson: JsValue             = Json.obj("type" -> "RoundRobin")
  override def select(
      reqId: String,
      trackingId: String,
      req: RequestHeader,
      targets: Seq[Target],
      descId: String
  )(implicit env: Env): Target = {
    val index: Int = reqCounter.incrementAndGet() % (if (targets.nonEmpty) targets.size else 1)
    targets.apply(index)
  }

}

object Random extends LoadBalancing {
  private val random                       = new scala.util.Random
  override def needTrackingCookie: Boolean = false
  override def toJson: JsValue             = Json.obj("type" -> "Random")
  override def select(
      reqId: String,
      trackingId: String,
      req: RequestHeader,
      targets: Seq[Target],
      descId: String
  )(implicit env: Env): Target = {
    val index = random.nextInt(targets.length)
    targets.apply(index)
  }
}

object Sticky extends LoadBalancing {
  override def needTrackingCookie: Boolean = true
  override def toJson: JsValue             = Json.obj("type" -> "Sticky")
  override def select(
      reqId: String,
      trackingId: String,
      req: RequestHeader,
      targets: Seq[Target],
      descId: String
  )(implicit env: Env): Target = {
    val hash: Int  = Math.abs(scala.util.hashing.MurmurHash3.stringHash(trackingId))
    val index: Int = Hashing.consistentHash(hash, targets.size)
    targets.apply(index)
  }
}

object IpAddressHash extends LoadBalancing {
  override def needTrackingCookie: Boolean = false
  override def toJson: JsValue             = Json.obj("type" -> "IpAddressHash")
  override def select(
      reqId: String,
      trackingId: String,
      req: RequestHeader,
      targets: Seq[Target],
      descId: String
  )(implicit env: Env): Target = {
    val remoteAddress = req.theIpAddress
    val hash: Int     = Math.abs(scala.util.hashing.MurmurHash3.stringHash(remoteAddress))
    val index: Int    = Hashing.consistentHash(hash, targets.size)
    targets.apply(index)
  }
}

case class AtomicAverage(count: AtomicLong, sum: AtomicLong) {
  def incrBy(v: Long): Unit = {
    count.incrementAndGet()
    sum.addAndGet(v)
  }
  def average: Long = sum.get / count.get
}

object BestResponseTime extends LoadBalancing {

  private[models] val random        = new scala.util.Random
  private[models] val responseTimes = new UnboundedTrieMap[String, AtomicAverage]()

  def incrementAverage(desc: ServiceDescriptor, target: Target, responseTime: Long): Unit = {
    val key = s"${desc.id}-${target.asKey}"
    val avg = responseTimes.getOrElseUpdate(key, AtomicAverage(new AtomicLong(0), new AtomicLong(0)))
    avg.incrBy(responseTime)
  }

  def incrementAverage(id: String, target: Target, responseTime: Long): Unit = {
    val key = s"${id}-${target.asKey}"
    val avg = responseTimes.getOrElseUpdate(key, AtomicAverage(new AtomicLong(0), new AtomicLong(0)))
    avg.incrBy(responseTime)
  }

  override def needTrackingCookie: Boolean = false
  override def toJson: JsValue             = Json.obj("type" -> "BestResponseTime")
  override def select(
      reqId: String,
      trackingId: String,
      req: RequestHeader,
      targets: Seq[Target],
      descId: String
  )(implicit env: Env): Target = {
    val keys                     = targets.map(t => s"${descId}-${t.asKey}")
    val existing                 = responseTimes.toSeq.filter(t => keys.exists(k => t._1 == k))
    val nonExisting: Seq[String] = keys.filterNot(k => responseTimes.contains(k))
    if (existing.size != targets.size) {
      nonExisting.headOption.flatMap(h => targets.find(t => s"${descId}-${t.asKey}" == h)).getOrElse {
        val index = random.nextInt(targets.length)
        targets.apply(index)
      }
    } else {
      val possibleTargets: Seq[(String, Long)] = existing.map(t => (t._1, t._2.average))
      val (key, _)                             = possibleTargets.minBy(_._2)
      targets.find(t => s"${descId}-${t.asKey}" == key).getOrElse {
        val index = random.nextInt(targets.length)
        targets.apply(index)
      }
    }
  }
}

case class WeightedBestResponseTime(ratio: Double) extends LoadBalancing {
  override def needTrackingCookie: Boolean = false
  override def toJson: JsValue             = Json.obj("type" -> "WeightedBestResponseTime", "ratio" -> ratio)
  override def select(
      reqId: String,
      trackingId: String,
      req: RequestHeader,
      targets: Seq[Target],
      descId: String
  )(implicit env: Env): Target = {
    val keys                     = targets.map(t => s"${descId}-${t.asKey}")
    val existing                 = BestResponseTime.responseTimes.toSeq.filter(t => keys.exists(k => t._1 == k))
    val nonExisting: Seq[String] = keys.filterNot(k => BestResponseTime.responseTimes.contains(k))
    if (existing.size != targets.size) {
      nonExisting.headOption.flatMap(h => targets.find(t => s"${descId}-${t.asKey}" == h)).getOrElse {
        val index: Int = BestResponseTime.random.nextInt(targets.length)
        targets.apply(index)
      }
    } else {
      val possibleTargets: Seq[(String, Long)] = existing.map(t => (t._1, t._2.average))
      val (key, _)                             = possibleTargets.minBy(_._2)
      val cleanRatio: Double                   = if (ratio < 0.0) 0.0 else if (ratio > 0.99) 0.99 else ratio
      val times: Int                           = Math.round(targets.size / (1 - cleanRatio)).toInt - targets.size
      val bestTarget: Option[Target]           = targets.find(t => s"${descId}-${t.asKey}" == key)
      val fill: Seq[Target]                    = bestTarget.map(t => Seq.fill(times)(t)).getOrElse(Seq.empty[Target])
      val newTargets: Seq[Target]              = targets ++ fill
      val index: Int                           = BestResponseTime.random.nextInt(newTargets.length)
      newTargets.apply(index)
    }
  }
}

trait TargetPredicate {
  def matches(reqId: String, req: RequestHeader, attrs: TypedMap)(implicit env: Env): Boolean
  def toJson: JsValue
  def json: JsValue = toJson
}

object TargetPredicate {
  val AlwaysMatch                     = otoroshi.models.AlwaysMatch
  val GeolocationMatch                = otoroshi.models.GeolocationMatch
  val NetworkLocationMatch            = otoroshi.models.NetworkLocationMatch
  val format: Format[TargetPredicate] = new Format[TargetPredicate] {
    override def writes(o: TargetPredicate): JsValue = o.toJson
    override def reads(json: JsValue): JsResult[TargetPredicate] = {
      (json \ "type").as[String] match {
        // case "RegionMatch" => JsSuccess(RegionMatch(
        //   region = (json \ "region").asOpt[String].getOrElse("local")
        // ))
        // case "ZoneMatch" => JsSuccess(ZoneMatch(
        //   zone = (json \ "zone").asOpt[String].getOrElse("local")
        // ))
        // case "DataCenterMatch" => JsSuccess(ZoneMatch(
        //   zone = (json \ "dc").asOpt[String].getOrElse("local")
        // ))
        // case "InfraMatch" => JsSuccess(ZoneMatch(
        //   zone = (json \ "provider").asOpt[String].getOrElse("local")
        // ))
        // case "RackMatch" => JsSuccess(ZoneMatch(
        //   zone = (json \ "rack").asOpt[String].getOrElse("local")
        // ))
        case "AlwaysMatch"          => JsSuccess(AlwaysMatch)
        case "GeolocationMatch"     =>
          JsSuccess(
            GeolocationMatch(
              positions = (json \ "positions")
                .asOpt[Seq[String]]
                .map(_.map(_.split(";").toList.map(_.trim)).collect { case lat :: lng :: radius :: Nil =>
                  GeoPositionRadius(lat.toDouble, lng.toDouble, radius.toDouble)
                })
                .getOrElse(Seq.empty)
            )
          )
        case "NetworkLocationMatch" =>
          JsSuccess(
            NetworkLocationMatch(
              provider = (json \ "provider").asOpt[String],
              region = (json \ "region").asOpt[String],
              zone = (json \ "zone").asOpt[String],
              dataCenter = (json \ "dc").asOpt[String],
              rack = (json \ "rack").asOpt[String]
            )
          )
        case _                      => JsSuccess(AlwaysMatch)
      }
    }
  }
}

case class GeoPositionRadius(latitude: Double, longitude: Double, radius: Double) {
  def toJson: JsValue                         = JsString(s"$latitude:$longitude:$radius")
  def near(lat: Double, lng: Double): Boolean =
    Math.acos(
      Math.sin(latitude) * Math.sin(lat) + Math.cos(latitude) * Math.cos(lat) * Math.cos(lng - longitude)
    ) * 6371 <= radius
}

case class GeolocationMatch(positions: Seq[GeoPositionRadius]) extends TargetPredicate {
  def toJson: JsValue = Json.obj("type" -> "GeolocationMatch", "positions" -> JsArray(positions.map(_.toJson)))
  override def matches(reqId: String, req: RequestHeader, attrs: TypedMap)(implicit env: Env): Boolean = {
    attrs.get(otoroshi.plugins.Keys.GeolocationInfoKey) match {
      case None         => true
      case Some(geoloc) => {
        val lat = ((geoloc \ "latitude").as[Double] * Math.PI) / 180.0
        val lng = ((geoloc \ "longitude").as[Double] * Math.PI) / 180.0
        positions.exists(_.near(lat, lng))
      }
    }
  }
}

object AlwaysMatch extends TargetPredicate {
  def toJson: JsValue                                                                                  = Json.obj("type" -> "AlwaysMatch")
  override def matches(reqId: String, req: RequestHeader, attrs: TypedMap)(implicit env: Env): Boolean = true
}

case class RegionMatch(region: String) extends TargetPredicate {
  def toJson: JsValue = Json.obj("type" -> "RegionMatch", "region" -> region)
  override def matches(reqId: String, req: RequestHeader, attrs: TypedMap)(implicit env: Env): Boolean = {
    env.clusterConfig.relay.location.region.trim.toLowerCase == region.trim.toLowerCase
  }
}

case class ZoneMatch(zone: String) extends TargetPredicate {
  def toJson: JsValue = Json.obj("type" -> "ZoneMatch", "zone" -> zone)
  override def matches(reqId: String, req: RequestHeader, attrs: TypedMap)(implicit env: Env): Boolean = {
    env.clusterConfig.relay.location.zone.trim.toLowerCase == zone.trim.toLowerCase
  }
}

case class DataCenterMatch(dc: String) extends TargetPredicate {
  def toJson: JsValue = Json.obj("type" -> "DataCenterMatch", "dc" -> dc)
  override def matches(reqId: String, req: RequestHeader, attrs: TypedMap)(implicit env: Env): Boolean = {
    env.clusterConfig.relay.location.datacenter.trim.toLowerCase == dc.trim.toLowerCase
  }
}

case class InfraProviderMatch(provider: String) extends TargetPredicate {
  def toJson: JsValue = Json.obj("type" -> "InfraProviderMatch", "provider" -> provider)
  override def matches(reqId: String, req: RequestHeader, attrs: TypedMap)(implicit env: Env): Boolean = {
    env.clusterConfig.relay.location.provider.trim.toLowerCase == provider.trim.toLowerCase
  }
}

case class RackMatch(rack: String) extends TargetPredicate {
  def toJson: JsValue = Json.obj("type" -> "RackMatch", "rack" -> rack)
  override def matches(reqId: String, req: RequestHeader, attrs: TypedMap)(implicit env: Env): Boolean = {
    env.clusterConfig.relay.location.rack.trim.toLowerCase == rack.trim.toLowerCase
  }
}

case class NetworkLocationMatch(
    provider: Option[String] = None,
    region: Option[String] = None,
    zone: Option[String] = None,
    dataCenter: Option[String] = None,
    rack: Option[String] = None
) extends TargetPredicate {
  def toJson: JsValue =
    Json.obj(
      "type"     -> "NetworkLocationMatch",
      "provider" -> provider.map(JsString.apply).getOrElse(JsNull).as[JsValue],
      "region"   -> region.map(JsString.apply).getOrElse(JsNull).as[JsValue],
      "zone"     -> zone.map(JsString.apply).getOrElse(JsNull).as[JsValue],
      "dc"       -> dataCenter.map(JsString.apply).getOrElse(JsNull).as[JsValue],
      "rack"     -> rack.map(JsString.apply).getOrElse(JsNull).as[JsValue]
    )
  override def matches(reqId: String, req: RequestHeader, attrs: TypedMap)(implicit env: Env): Boolean = {
    provider.forall(p =>
      otoroshi.utils
        .RegexPool(p.trim.toLowerCase)
        .matches(env.clusterConfig.relay.location.provider.trim.toLowerCase)
    ) &&
    region.forall(r =>
      otoroshi.utils
        .RegexPool(r.trim.toLowerCase)
        .matches(env.clusterConfig.relay.location.region.trim.toLowerCase)
    ) &&
    zone.forall(z =>
      otoroshi.utils.RegexPool(z.trim.toLowerCase).matches(env.clusterConfig.relay.location.zone.trim.toLowerCase)
    ) &&
    dataCenter.forall(d =>
      otoroshi.utils
        .RegexPool(d.trim.toLowerCase)
        .matches(env.clusterConfig.relay.location.datacenter.trim.toLowerCase)
    ) &&
    rack.forall(r =>
      otoroshi.utils.RegexPool(r.trim.toLowerCase).matches(env.clusterConfig.relay.location.rack.trim.toLowerCase)
    )
  }
}

case class HttpProtocol(value: String) {
  def isHttp1: Boolean                              = value.toLowerCase().startsWith("http/1")
  def isHttp2: Boolean                              = value.toLowerCase().startsWith("http/2")
  def isHttp3: Boolean                              = value.toLowerCase().startsWith("http/3")
  def isHttp2OrHttp3: Boolean                       = isHttp2 || isHttp3
  def json: JsValue                                 = JsString(value)
  def asAkka: akka.http.scaladsl.model.HttpProtocol = value.toLowerCase().trim() match {
    case "http/1.0" => akka.http.scaladsl.model.HttpProtocols.`HTTP/1.0`
    case "http/1.1" => akka.http.scaladsl.model.HttpProtocols.`HTTP/1.1`
    case "http/2.0" => akka.http.scaladsl.model.HttpProtocols.`HTTP/2.0`
    case "http/3.0" => akka.http.scaladsl.model.HttpProtocols.`HTTP/2.0`
    case _          => akka.http.scaladsl.model.HttpProtocols.`HTTP/1.1`
  }
}

object HttpProtocols {
  val HTTP_1_0                                       = HttpProtocol("HTTP/1.0")
  val HTTP_1_1                                       = HttpProtocol("HTTP/1.1")
  val HTTP_2_0                                       = HttpProtocol("HTTP/2.0")
  val HTTP_3_0                                       = HttpProtocol("HTTP/3.0")
  def parse(value: String): HttpProtocol             = parseSafe(value).getOrElse(HTTP_1_1)
  def parseSafe(value: String): Option[HttpProtocol] = value.toLowerCase().trim() match {
    case "http/1.0" => HTTP_1_0.some
    case "http/1.1" => HTTP_1_1.some
    case "http/2.0" => HTTP_2_0.some
    case "http/3.0" => HTTP_3_0.some
    case _          => None
  }
}

case class Target(
    host: String,
    scheme: String = "https",
    weight: Int = 1,
    protocol: HttpProtocol = HttpProtocols.HTTP_1_1,
    predicate: TargetPredicate = AlwaysMatch,
    ipAddress: Option[String] = None,
    mtlsConfig: MtlsConfig = MtlsConfig(),
    tags: Seq[String] = Seq.empty,
    metadata: Map[String, String] = Map.empty
) {

  def toJson        = Target.format.writes(this)
  def json          = toJson
  def asUrl         = s"${scheme}://$host"
  def asKey         = s"${protocol.value}:$scheme://$host@${ipAddress.getOrElse(host)}"
  def asTargetStr   = s"$scheme://$host@${ipAddress.getOrElse(host)}"
  def asCleanTarget = s"$scheme://$host${ipAddress.map(v => s"@$v").getOrElse("")}"

  lazy val thePort: Int = if (host.contains(":")) {
    host.split(":").last.toInt
  } else
    scheme.toLowerCase() match {
      case "http"  => 80
      case "https" => 443
      case _       => 80
    }

  lazy val theHost: String = if (host.contains(":")) {
    host.split(":").init.mkString("")
  } else host
}

object Target {
  val format = new Format[Target] {
    override def writes(o: Target): JsValue             =
      Json.obj(
        "host"       -> o.host,
        "scheme"     -> o.scheme,
        "weight"     -> o.weight,
        "mtlsConfig" -> o.mtlsConfig.json,
        "tags"       -> JsArray(o.tags.map(JsString.apply)),
        "metadata"   -> JsObject(o.metadata.filter(_._1.nonEmpty).mapValues(JsString.apply)),
        // "loose"     -> o.loose,
        // "mtls"      -> o.mtls,
        // "certId"    -> o.certId.map(JsString.apply).getOrElse(JsNull).as[JsValue],
        "protocol"   -> o.protocol.value,
        "predicate"  -> o.predicate.toJson,
        "ipAddress"  -> o.ipAddress.map(JsString.apply).getOrElse(JsNull).as[JsValue]
      )
    override def reads(json: JsValue): JsResult[Target] =
      Try {
        Target(
          host = (json \ "host").as[String],
          scheme = (json \ "scheme").asOpt[String].filterNot(_.trim.isEmpty).getOrElse("https"),
          weight = (json \ "weight").asOpt[Int].getOrElse(1),
          mtlsConfig = MtlsConfig.read((json \ "mtlsConfig").asOpt[JsValue]),
          // loose = (json \ "loose").asOpt[Boolean].getOrElse(false),
          // mtls = (json \ "mtls").asOpt[Boolean].getOrElse(false),
          // certId = (json \ "certId").asOpt[String].filter(_.trim.nonEmpty),
          protocol = (json \ "protocol")
            .asOpt[String]
            .filterNot(_.trim.isEmpty)
            .map(s => HttpProtocols.parse(s))
            .getOrElse(HttpProtocols.HTTP_1_1),
          predicate = (json \ "predicate").asOpt(TargetPredicate.format).getOrElse(AlwaysMatch),
          ipAddress = (json \ "ipAddress").asOpt[String].filterNot(_.trim.isEmpty),
          tags = (json \ "tags").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
          metadata = (json \ "metadata")
            .asOpt[Map[String, String]]
            .map(m => m.filter(_._1.nonEmpty))
            .getOrElse(Map.empty[String, String])
        )
      } map { case sd =>
        JsSuccess(sd)
      } recover { case t =>
        JsError(t.getMessage)
      } get
  }
}

case class IpFiltering(whitelist: Seq[String] = Seq.empty[String], blacklist: Seq[String] = Seq.empty[String]) {
  def toJson = IpFiltering.format.writes(this)
  def matchesWhitelist(ipAddress: String): Boolean = {
    if (whitelist.nonEmpty) {
      whitelist.exists { ip =>
        if (ip.contains("/")) {
          IpFiltering.cidr(ip).contains(ipAddress)
        } else {
          otoroshi.utils.RegexPool(ip).matches(ipAddress)
        }
      }
    } else {
      false
    }
  }
  def notMatchesWhitelist(ipAddress: String): Boolean = {
    if (whitelist.nonEmpty) {
      !whitelist.exists { ip =>
        if (ip.contains("/")) {
          IpFiltering.cidr(ip).contains(ipAddress)
        } else {
          otoroshi.utils.RegexPool(ip).matches(ipAddress)
        }
      }
    } else {
      false
    }
  }
  def matchesBlacklist(ipAddress: String): Boolean = {
    if (blacklist.nonEmpty) {
      blacklist.exists { ip =>
        if (ip.contains("/")) {
          IpFiltering.cidr(ip).contains(ipAddress)
        } else {
          otoroshi.utils.RegexPool(ip).matches(ipAddress)
        }
      }
    } else {
      false
    }
  }
}

class CidrOfString(cdr: String) {
  private lazy val opt: Option[Cidr[IpAddress]] = Cidr.fromString(cdr)
  def contains(ip: String): Boolean = {
    opt match {
      case None       => false
      case Some(cidr) =>
        IpFiltering.ipaddrCache.get(ip, _ => IpAddress.fromString(ip)) match {
          case None         => false
          case Some(ipaddr) => cidr.contains(ipaddr)
        }
    }
  }
}

object IpFiltering {
  implicit val format             = Json.format[IpFiltering]
  private val cidrCache           = Caches.bounded[String, CidrOfString](10000)
  private[models] val ipaddrCache = Caches.bounded[String, Option[IpAddress]](10000)
  def cidr(cdr: String): CidrOfString = {
    cidrCache.get(cdr, _ => new CidrOfString(cdr))
  }
}

case class HealthCheck(
    enabled: Boolean,
    url: String,
    timeout: Int = 5000,
    healthyStatuses: Seq[Int] = Seq.empty,
    unhealthyStatuses: Seq[Int] = Seq.empty
) {
  def toJson = Json.obj(
    "enabled"           -> enabled,
    "url"               -> url,
    "timeout"           -> timeout,
    "healthyStatuses"   -> healthyStatuses,
    "unhealthyStatuses" -> unhealthyStatuses
  )
}

object HealthCheck {
  implicit val format = new Format[HealthCheck] {
    override def reads(json: JsValue): JsResult[HealthCheck] = Try {
      HealthCheck(
        enabled = json.select("enabled").asOpt[Boolean].getOrElse(false),
        url = json.select("url").asOpt[String].getOrElse(""),
        timeout = json.select("timeout").asOpt[Int].getOrElse(5000),
        healthyStatuses = json.select("healthyStatuses").asOpt[Seq[Int]].getOrElse(Seq.empty),
        unhealthyStatuses = json.select("unhealthyStatuses").asOpt[Seq[Int]].getOrElse(Seq.empty)
      )
    } match {
      case Failure(exception) => JsError(exception.getMessage)
      case Success(value)     => JsSuccess(value)
    }

    override def writes(o: HealthCheck): JsValue = o.toJson
  }
  val empty           = HealthCheck(false, "/")
}

case class CustomTimeouts(
    path: String = "/*",
    connectionTimeout: Long = 10000,
    idleTimeout: Long = 60000,
    callAndStreamTimeout: Long = 1.hour.toMillis,
    callTimeout: Long = 30000,
    globalTimeout: Long = 30000
) {
  def toJson: JsValue = CustomTimeouts.format.writes(this)
}

object CustomTimeouts {

  lazy val logger = Logger("otoroshi-custom-timeouts")

  implicit val format = new Format[CustomTimeouts] {

    override def reads(json: JsValue): JsResult[CustomTimeouts] =
      Try {
        CustomTimeouts(
          path = (json \ "path").asOpt[String].filterNot(_.trim.isEmpty).getOrElse("*"),
          connectionTimeout = (json \ "connectionTimeout").asOpt[Long].getOrElse(10000),
          idleTimeout = (json \ "connectionTimeout").asOpt[Long].getOrElse(60000),
          callAndStreamTimeout = (json \ "callAndStreamTimeout").asOpt[Long].getOrElse(1.hour.toMillis),
          callTimeout = (json \ "callTimeout").asOpt[Long].getOrElse(30000),
          globalTimeout = (json \ "globalTimeout").asOpt[Long].getOrElse(30000)
        )
      } map { case sd =>
        JsSuccess(sd)
      } recover { case t =>
        logger.error("Error while reading CustomTimeouts", t)
        JsError(t.getMessage)
      } get

    override def writes(o: CustomTimeouts): JsValue =
      Json.obj(
        "path"                 -> o.path,
        "callTimeout"          -> o.callTimeout,
        "callAndStreamTimeout" -> o.callAndStreamTimeout,
        "connectionTimeout"    -> o.connectionTimeout,
        "idleTimeout"          -> o.idleTimeout,
        "globalTimeout"        -> o.globalTimeout
      )
  }
}

case class ClientConfig(
    useCircuitBreaker: Boolean = true,
    retries: Int = 1,
    maxErrors: Int = 20,
    retryInitialDelay: Long = 50,
    backoffFactor: Long = 2,
    connectionTimeout: Long = 10000,
    idleTimeout: Long = 60000,
    callAndStreamTimeout: Long =
      120000, // http client timeout per call with streaming from otoroshi to client included (actually end the call)
    callTimeout: Long =
      30000,  // circuit breaker timeout per call (soft, streaming from otoroshi to client not included)
    globalTimeout: Long =
      30000,  // circuit breaker timeout around all calls (soft, streaming from otoroshi to client not included)
    sampleInterval: Long = 2000,
    proxy: Option[WSProxyServer] = None,
    customTimeouts: Seq[CustomTimeouts] = Seq.empty[CustomTimeouts],
    cacheConnectionSettings: CacheConnectionSettings = CacheConnectionSettings()
) {
  def toJson                                                                                            = ClientConfig.format.writes(this)
  def timeouts(path: String): Option[CustomTimeouts] = {
    if (customTimeouts.isEmpty) None
    else customTimeouts.find(c => otoroshi.utils.RegexPool(c.path).matches(path))
  }
  def extractTimeout(path: String, f: CustomTimeouts => Long, f2: ClientConfig => Long): FiniteDuration =
    timeouts(path).map(f).getOrElse(f2(this)).millis
  def extractTimeoutLong(path: String, f: CustomTimeouts => Long, f2: ClientConfig => Long): Long       =
    timeouts(path).map(f).getOrElse(f2(this))
}

object WSProxyServerJson {
  def maybeProxyToJson(p: Option[WSProxyServer]): JsValue =
    p match {
      case Some(proxy) => proxyToJson(proxy)
      case None        => JsNull
    }
  def proxyToJson(p: WSProxyServer): JsValue              =
    Json.obj(
      "host"          -> p.host, // host: String
      "port"          -> p.port, // port: Int
      "protocol"      -> p.protocol.map(JsString.apply).getOrElse(JsNull).as[JsValue], // protocol: Option[String]
      "principal"     -> p.principal.map(JsString.apply).getOrElse(JsNull).as[JsValue], // principal: Option[String]
      "password"      -> p.password.map(JsString.apply).getOrElse(JsNull).as[JsValue], // password: Option[String]
      "ntlmDomain"    -> p.ntlmDomain.map(JsString.apply).getOrElse(JsNull).as[JsValue], // ntlmDomain: Option[String]
      "encoding"      -> p.encoding.map(JsString.apply).getOrElse(JsNull).as[JsValue], // encoding: Option[String]
      "nonProxyHosts" -> p.nonProxyHosts
        .map(nph => JsArray(nph.map(JsString.apply)))
        .getOrElse(JsNull)
        .as[JsValue] // nonProxyHosts: Option[Seq[String]]
    )
  def proxyFromJson(json: JsValue): Option[WSProxyServer] = {
    val maybeHost = (json \ "host").asOpt[String].filterNot(_.trim.isEmpty)
    val maybePort = (json \ "port").asOpt[Int]
    (maybeHost, maybePort) match {
      case (Some(host), Some(port)) => {
        Some(DefaultWSProxyServer(host, port))
          .map { proxy =>
            (json \ "protocol")
              .asOpt[String]
              .filterNot(_.trim.isEmpty)
              .map(v => proxy.copy(protocol = Some(v)))
              .getOrElse(proxy)
          }
          .map { proxy =>
            (json \ "principal")
              .asOpt[String]
              .filterNot(_.trim.isEmpty)
              .map(v => proxy.copy(principal = Some(v)))
              .getOrElse(proxy)
          }
          .map { proxy =>
            (json \ "password")
              .asOpt[String]
              .filterNot(_.trim.isEmpty)
              .map(v => proxy.copy(password = Some(v)))
              .getOrElse(proxy)
          }
          .map { proxy =>
            (json \ "ntlmDomain")
              .asOpt[String]
              .filterNot(_.trim.isEmpty)
              .map(v => proxy.copy(ntlmDomain = Some(v)))
              .getOrElse(proxy)
          }
          .map { proxy =>
            (json \ "encoding")
              .asOpt[String]
              .filterNot(_.trim.isEmpty)
              .map(v => proxy.copy(encoding = Some(v)))
              .getOrElse(proxy)
          }
          .map { proxy =>
            (json \ "nonProxyHosts").asOpt[Seq[String]].map(v => proxy.copy(nonProxyHosts = Some(v))).getOrElse(proxy)
          }
      }
      case _                        => None
    }
  }
}

object ClientConfig {

  lazy val logger = Logger("otoroshi-client-config")

  implicit val format = new Format[ClientConfig] {

    override def reads(json: JsValue): JsResult[ClientConfig] =
      Try {
        ClientConfig(
          useCircuitBreaker = (json \ "useCircuitBreaker").asOpt[Boolean].getOrElse(true),
          retries = (json \ "retries").asOpt[Int].getOrElse(1),
          maxErrors = (json \ "maxErrors").asOpt[Int].getOrElse(20),
          retryInitialDelay = (json \ "retryInitialDelay").asOpt[Long].getOrElse(50),
          backoffFactor = (json \ "backoffFactor").asOpt[Long].getOrElse(2),
          connectionTimeout = (json \ "connectionTimeout").asOpt[Long].getOrElse(10000),
          idleTimeout = (json \ "idleTimeout").asOpt[Long].getOrElse(60000),
          callAndStreamTimeout = (json \ "callAndStreamTimeout").asOpt[Long].getOrElse(120000),
          callTimeout = (json \ "callTimeout").asOpt[Long].getOrElse(30000),
          globalTimeout = (json \ "globalTimeout").asOpt[Long].getOrElse(30000),
          sampleInterval = (json \ "sampleInterval").asOpt[Long].getOrElse(2000),
          proxy = (json \ "proxy").asOpt[JsValue].flatMap(p => WSProxyServerJson.proxyFromJson(p)),
          cacheConnectionSettings = CacheConnectionSettings(
            enabled = (json \ "cacheConnectionSettings" \ "enabled").asOpt[Boolean].getOrElse(false),
            queueSize = (json \ "cacheConnectionSettings" \ "queueSize").asOpt[Int].getOrElse(2048),
            strategy = NgOverflowStrategy.dropNew
          ),
          customTimeouts = (json \ "customTimeouts")
            .asOpt[JsArray]
            .map(_.value.map(e => CustomTimeouts.format.reads(e).get))
            .getOrElse(Seq.empty[CustomTimeouts])
        )
      } map { case sd =>
        JsSuccess(sd)
      } recover { case t =>
        logger.error("Error while reading ClientConfig", t)
        JsError(t.getMessage)
      } get

    override def writes(o: ClientConfig): JsValue =
      Json.obj(
        "useCircuitBreaker"       -> o.useCircuitBreaker,
        "retries"                 -> o.retries,
        "maxErrors"               -> o.maxErrors,
        "retryInitialDelay"       -> o.retryInitialDelay,
        "backoffFactor"           -> o.backoffFactor,
        "callTimeout"             -> o.callTimeout,
        "callAndStreamTimeout"    -> o.callAndStreamTimeout,
        "connectionTimeout"       -> o.connectionTimeout,
        "idleTimeout"             -> o.idleTimeout,
        "globalTimeout"           -> o.globalTimeout,
        "sampleInterval"          -> o.sampleInterval,
        "proxy"                   -> o.proxy.map(p => WSProxyServerJson.proxyToJson(p)).getOrElse(Json.obj()).as[JsValue],
        "customTimeouts"          -> JsArray(o.customTimeouts.map(_.toJson)),
        "cacheConnectionSettings" -> o.cacheConnectionSettings.json
      )
  }
}

case class Canary(
    enabled: Boolean = false,
    traffic: Double = 0.2,
    targets: Seq[Target] = Seq.empty[Target],
    root: String = "/"
) {
  def toJson = Canary.format.writes(this)
}

object Canary {

  lazy val logger = Logger("otoroshi-canary")

  implicit val format = new Format[Canary] {
    override def reads(json: JsValue): JsResult[Canary] =
      Try {
        Canary(
          enabled = (json \ "enabled").asOpt[Boolean].getOrElse(false),
          traffic = (json \ "traffic").asOpt[Double].getOrElse(0.2),
          targets = (json \ "targets")
            .asOpt[JsArray]
            .map(_.value.map(e => Target.format.reads(e).get))
            .getOrElse(Seq.empty[Target]),
          root = (json \ "root").asOpt[String].getOrElse("/")
        )
      } map { case sd =>
        JsSuccess(sd)
      } recover { case t =>
        logger.error("Error while reading Canary", t)
        JsError(t.getMessage)
      } get

    override def writes(o: Canary): JsValue =
      Json.obj(
        "enabled" -> o.enabled,
        "traffic" -> o.traffic,
        "targets" -> JsArray(o.targets.map(_.toJson)),
        "root"    -> o.root
      )
  }
}

case class RedirectionSettings(enabled: Boolean = false, code: Int = 303, to: String = "https://www.otoroshi.io") {
  def toJson       = RedirectionSettings.format.writes(this)
  def hasValidCode = RedirectionSettings.validRedirectionCodes.contains(code)
  def formattedTo(
      request: RequestHeader,
      descriptor: ServiceDescriptor,
      ctx: Map[String, String],
      attrs: TypedMap,
      env: Env
  ): String        =
    RedirectionExpressionLanguage(to, Some(request), Some(descriptor), None, None, None, ctx, attrs, env)
}

object RedirectionSettings {

  lazy val logger = Logger("otoroshi-redirection-settings")

  val validRedirectionCodes = Seq(301, 308, 302, 303, 307)

  implicit val format = new Format[RedirectionSettings] {
    override def reads(json: JsValue): JsResult[RedirectionSettings] =
      Try {
        RedirectionSettings(
          enabled = (json \ "enabled").asOpt[Boolean].getOrElse(false),
          code = (json \ "code").asOpt[Int].getOrElse(303),
          to = (json \ "to").asOpt[String].filterNot(_.trim.isEmpty).getOrElse("https://www.otoroshi.io")
        )
      } map { case sd =>
        JsSuccess(sd)
      } recover { case t =>
        logger.error("Error while reading RedirectionSettings", t)
        JsError(t.getMessage)
      } get

    override def writes(o: RedirectionSettings): JsValue =
      Json.obj(
        "enabled" -> o.enabled,
        "code"    -> o.code,
        "to"      -> o.to
      )
  }
}

case class BasicAuthConstraints(
    enabled: Boolean = true,
    headerName: Option[String] = None,
    queryName: Option[String] = None
)                                   {
  def json: JsValue =
    Json.obj(
      "enabled"    -> enabled,
      "headerName" -> headerName.map(JsString.apply).getOrElse(JsNull).as[JsValue],
      "queryName"  -> queryName.map(JsString.apply).getOrElse(JsNull).as[JsValue]
    )
}
object BasicAuthConstraints         {
  val format = new Format[BasicAuthConstraints] {
    override def writes(o: BasicAuthConstraints): JsValue             = o.json
    override def reads(json: JsValue): JsResult[BasicAuthConstraints] =
      Try {
        JsSuccess(
          BasicAuthConstraints(
            enabled = (json \ "enabled").asOpt[Boolean].getOrElse(true),
            headerName = (json \ "headerName").asOpt[String].filterNot(_.trim.isEmpty),
            queryName = (json \ "queryName").asOpt[String].filterNot(_.trim.isEmpty)
          )
        )
      } recover { case e =>
        JsError(e.getMessage)
      } get
  }
}
case class ClientIdAuthConstraints(
    enabled: Boolean = true,
    headerName: Option[String] = None,
    queryName: Option[String] = None
)                                   {
  def json: JsValue =
    Json.obj(
      "enabled"    -> enabled,
      "headerName" -> headerName.map(JsString.apply).getOrElse(JsNull).as[JsValue],
      "queryName"  -> queryName.map(JsString.apply).getOrElse(JsNull).as[JsValue]
    )
}
object ClientIdAuthConstraints      {
  val format = new Format[ClientIdAuthConstraints] {
    override def writes(o: ClientIdAuthConstraints): JsValue             = o.json
    override def reads(json: JsValue): JsResult[ClientIdAuthConstraints] =
      Try {
        JsSuccess(
          ClientIdAuthConstraints(
            enabled = (json \ "enabled").asOpt[Boolean].getOrElse(true),
            headerName = (json \ "headerName").asOpt[String].filterNot(_.trim.isEmpty),
            queryName = (json \ "queryName").asOpt[String].filterNot(_.trim.isEmpty)
          )
        )
      } recover { case e =>
        JsError(e.getMessage)
      } get
  }
}
case class CustomHeadersAuthConstraints(
    enabled: Boolean = true,
    clientIdHeaderName: Option[String] = None,
    clientSecretHeaderName: Option[String] = None
)                                   {
  def json: JsValue =
    Json.obj(
      "enabled"                -> enabled,
      "clientIdHeaderName"     -> clientIdHeaderName.map(JsString.apply).getOrElse(JsNull).as[JsValue],
      "clientSecretHeaderName" -> clientSecretHeaderName.map(JsString.apply).getOrElse(JsNull).as[JsValue]
    )
}
object CustomHeadersAuthConstraints {
  val format = new Format[CustomHeadersAuthConstraints] {
    override def writes(o: CustomHeadersAuthConstraints): JsValue             = o.json
    override def reads(json: JsValue): JsResult[CustomHeadersAuthConstraints] =
      Try {
        JsSuccess(
          CustomHeadersAuthConstraints(
            enabled = (json \ "enabled").asOpt[Boolean].getOrElse(true),
            clientIdHeaderName = (json \ "clientIdHeaderName").asOpt[String].filterNot(_.trim.isEmpty),
            clientSecretHeaderName = (json \ "clientSecretHeaderName").asOpt[String].filterNot(_.trim.isEmpty)
          )
        )
      } recover { case e =>
        JsError(e.getMessage)
      } get
  }
}

case class OtoBearerConstraints(
    enabled: Boolean = true,
    headerName: Option[String] = None,
    queryName: Option[String] = None,
    cookieName: Option[String] = None
) {
  def json: JsValue =
    Json.obj(
      "enabled"    -> enabled,
      "headerName" -> headerName.map(JsString.apply).getOrElse(JsNull).as[JsValue],
      "queryName"  -> queryName.map(JsString.apply).getOrElse(JsNull).as[JsValue],
      "cookieName" -> cookieName.map(JsString.apply).getOrElse(JsNull).as[JsValue]
    )
}

object OtoBearerConstraints {
  val format = new Format[OtoBearerConstraints] {
    override def writes(o: OtoBearerConstraints): JsValue             = o.json
    override def reads(json: JsValue): JsResult[OtoBearerConstraints] =
      Try {
        JsSuccess(
          OtoBearerConstraints(
            enabled = (json \ "enabled").asOpt[Boolean].getOrElse(true),
            headerName = (json \ "headerName").asOpt[String].filterNot(_.trim.isEmpty),
            queryName = (json \ "queryName").asOpt[String].filterNot(_.trim.isEmpty),
            cookieName = (json \ "cookieName").asOpt[String].filterNot(_.trim.isEmpty)
          )
        )
      } recover { case e =>
        JsError(e.getMessage)
      } get
  }
}

case class JwtAuthConstraints(
    enabled: Boolean = true,
    secretSigned: Boolean = true,
    keyPairSigned: Boolean = true,
    includeRequestAttributes: Boolean = false,
    maxJwtLifespanSecs: Option[Long] = None, //Some(10 * 365 * 24 * 60 * 60),
    headerName: Option[String] = None,
    queryName: Option[String] = None,
    cookieName: Option[String] = None
)                         {
  def json: JsValue =
    Json.obj(
      "enabled"                  -> enabled,
      "secretSigned"             -> secretSigned,
      "keyPairSigned"            -> keyPairSigned,
      "includeRequestAttributes" -> includeRequestAttributes,
      "maxJwtLifespanSecs"       -> maxJwtLifespanSecs.map(l => JsNumber(BigDecimal.exact(l))).getOrElse(JsNull).as[JsValue],
      "headerName"               -> headerName.map(JsString.apply).getOrElse(JsNull).as[JsValue],
      "queryName"                -> queryName.map(JsString.apply).getOrElse(JsNull).as[JsValue],
      "cookieName"               -> cookieName.map(JsString.apply).getOrElse(JsNull).as[JsValue]
    )
}
object JwtAuthConstraints {
  val format = new Format[JwtAuthConstraints] {
    override def writes(o: JwtAuthConstraints): JsValue             = o.json
    override def reads(json: JsValue): JsResult[JwtAuthConstraints] =
      Try {
        JsSuccess(
          JwtAuthConstraints(
            enabled = (json \ "enabled").asOpt[Boolean].getOrElse(true),
            secretSigned = (json \ "secretSigned").asOpt[Boolean].getOrElse(true),
            keyPairSigned = (json \ "keyPairSigned").asOpt[Boolean].getOrElse(true),
            includeRequestAttributes = (json \ "includeRequestAttributes").asOpt[Boolean].getOrElse(false),
            maxJwtLifespanSecs =
              (json \ "maxJwtLifespanSecs").asOpt[Long].filter(_ > -1), //.getOrElse(10 * 365 * 24 * 60 * 60),
            headerName = (json \ "headerName").asOpt[String].filterNot(_.trim.isEmpty),
            queryName = (json \ "queryName").asOpt[String].filterNot(_.trim.isEmpty),
            cookieName = (json \ "cookieName").asOpt[String].filterNot(_.trim.isEmpty)
          )
        )
      } recover { case e =>
        JsError(e.getMessage)
      } get
  }
}

case class ApiKeyRouteMatcher(
    noneTagIn: Seq[String] = Seq.empty,
    oneTagIn: Seq[String] = Seq.empty,
    allTagsIn: Seq[String] = Seq.empty,
    noneMetaIn: Map[String, String] = Map.empty,
    oneMetaIn: Map[String, String] = Map.empty,
    allMetaIn: Map[String, String] = Map.empty,
    noneMetaKeysIn: Seq[String] = Seq.empty,
    oneMetaKeyIn: Seq[String] = Seq.empty,
    allMetaKeysIn: Seq[String] = Seq.empty
) extends {
  def json: JsValue                         = ApiKeyRouteMatcher.format.writes(this)
  def gentleJson: JsValue                   = Json
    .obj()
    .applyOnIf(noneTagIn.nonEmpty)(obj => obj ++ Json.obj("noneTagIn" -> noneTagIn))
    .applyOnIf(oneTagIn.nonEmpty)(obj => obj ++ Json.obj("oneTagIn" -> oneTagIn))
    .applyOnIf(allTagsIn.nonEmpty)(obj => obj ++ Json.obj("allTagsIn" -> allTagsIn))
    .applyOnIf(noneMetaIn.nonEmpty)(obj => obj ++ Json.obj("noneMetaIn" -> noneMetaIn))
    .applyOnIf(oneMetaIn.nonEmpty)(obj => obj ++ Json.obj("oneMetaIn" -> oneMetaIn))
    .applyOnIf(allMetaIn.nonEmpty)(obj => obj ++ Json.obj("allMetaIn" -> allMetaIn))
    .applyOnIf(noneMetaKeysIn.nonEmpty)(obj => obj ++ Json.obj("noneMetaKeysIn" -> noneMetaKeysIn))
    .applyOnIf(oneMetaKeyIn.nonEmpty)(obj => obj ++ Json.obj("oneMetaKeyIn" -> oneMetaKeyIn))
    .applyOnIf(allMetaKeysIn.nonEmpty)(obj => obj ++ Json.obj("allMetaKeysIn" -> allMetaKeysIn))
  lazy val isActive: Boolean                = !hasNoRoutingConstraints
  lazy val hasNoRoutingConstraints: Boolean =
    oneMetaIn.isEmpty &&
    allMetaIn.isEmpty &&
    oneTagIn.isEmpty &&
    allTagsIn.isEmpty &&
    noneTagIn.isEmpty &&
    noneMetaIn.isEmpty &&
    oneMetaKeyIn.isEmpty &&
    allMetaKeysIn.isEmpty &&
    noneMetaKeysIn.isEmpty
}

object ApiKeyRouteMatcher {
  val format = new Format[ApiKeyRouteMatcher] {
    override def writes(o: ApiKeyRouteMatcher): JsValue             =
      Json.obj(
        "noneTagIn"      -> JsArray(o.noneTagIn.map(JsString.apply)),
        "oneTagIn"       -> JsArray(o.oneTagIn.map(JsString.apply)),
        "allTagsIn"      -> JsArray(o.allTagsIn.map(JsString.apply)),
        "noneMetaIn"     -> JsObject(o.noneMetaIn.mapValues(JsString.apply)),
        "oneMetaIn"      -> JsObject(o.oneMetaIn.mapValues(JsString.apply)),
        "allMetaIn"      -> JsObject(o.allMetaIn.mapValues(JsString.apply)),
        "noneMetaKeysIn" -> JsArray(o.noneMetaKeysIn.map(JsString.apply)),
        "oneMetaKeyIn"   -> JsArray(o.oneMetaKeyIn.map(JsString.apply)),
        "allMetaKeysIn"  -> JsArray(o.allMetaKeysIn.map(JsString.apply))
      )
    override def reads(json: JsValue): JsResult[ApiKeyRouteMatcher] =
      Try {
        JsSuccess(
          ApiKeyRouteMatcher(
            noneTagIn = (json \ "noneTagIn").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
            oneTagIn = (json \ "oneTagIn").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
            allTagsIn = (json \ "allTagsIn").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
            noneMetaIn = (json \ "noneMetaIn").asOpt[Map[String, String]].getOrElse(Map.empty[String, String]),
            oneMetaIn = (json \ "oneMetaIn").asOpt[Map[String, String]].getOrElse(Map.empty[String, String]),
            allMetaIn = (json \ "allMetaIn").asOpt[Map[String, String]].getOrElse(Map.empty[String, String]),
            noneMetaKeysIn = (json \ "noneMetaKeysIn").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
            oneMetaKeyIn = (json \ "oneMetaKeyIn").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
            allMetaKeysIn = (json \ "allMetaKeysIn").asOpt[Seq[String]].getOrElse(Seq.empty[String])
          )
        )
      } recover { case e =>
        JsError(e.getMessage)
      } get
  }
}

case class ApiKeyConstraints(
    basicAuth: BasicAuthConstraints = BasicAuthConstraints(),
    customHeadersAuth: CustomHeadersAuthConstraints = CustomHeadersAuthConstraints(),
    clientIdAuth: ClientIdAuthConstraints = ClientIdAuthConstraints(),
    jwtAuth: JwtAuthConstraints = JwtAuthConstraints(),
    otoBearerAuth: OtoBearerConstraints = OtoBearerConstraints(),
    routing: ApiKeyRouteMatcher = ApiKeyRouteMatcher()
)                        {
  def json: JsValue =
    Json.obj(
      "basicAuth"         -> basicAuth.json,
      "customHeadersAuth" -> customHeadersAuth.json,
      "otoBearerAuth"     -> otoBearerAuth.json,
      "clientIdAuth"      -> clientIdAuth.json,
      "jwtAuth"           -> jwtAuth.json,
      "routing"           -> routing.json
    )

  lazy val hasNoRoutingConstraints: Boolean = routing.hasNoRoutingConstraints
}
object ApiKeyConstraints {
  val format = new Format[ApiKeyConstraints] {
    override def writes(o: ApiKeyConstraints): JsValue             = o.json
    override def reads(json: JsValue): JsResult[ApiKeyConstraints] =
      Try {
        JsSuccess(
          ApiKeyConstraints(
            basicAuth = (json \ "basicAuth").as(BasicAuthConstraints.format),
            customHeadersAuth = (json \ "customHeadersAuth").as(CustomHeadersAuthConstraints.format),
            otoBearerAuth =
              (json \ "otoBearerAuth").asOpt(OtoBearerConstraints.format).getOrElse(OtoBearerConstraints()),
            clientIdAuth = (json \ "clientIdAuth").as(ClientIdAuthConstraints.format),
            jwtAuth = (json \ "jwtAuth").as(JwtAuthConstraints.format),
            routing = (json \ "routing").as(ApiKeyRouteMatcher.format)
          )
        )
      } recover { case e =>
        JsError(e.getMessage)
      } get
  }
}

sealed trait SecComVersion {
  def str: String
  def version: Int
  def json: JsValue
}

object SecComVersionV1 extends SecComVersion {
  def str: String   = "V1"
  def version: Int  = 1
  def json: JsValue = JsString(str)
}
object SecComVersionV2 extends SecComVersion {
  def str: String   = "V2"
  def version: Int  = 2
  def json: JsValue = JsString(str)
}
object SecComVersion {

  val V1 = SecComVersionV1
  val V2 = SecComVersionV2

  def apply(version: Int): Option[SecComVersion]    =
    version match {
      case 1 => Some(V1)
      case 2 => Some(V2)
      case _ => None
    }
  def apply(version: String): Option[SecComVersion] =
    version match {
      case "V1" => Some(V1)
      case "V2" => Some(V2)
      case "v1" => Some(V1)
      case "v2" => Some(V2)
      case _    => None
    }
}

sealed trait SecComInfoTokenVersion {
  def version: String
  def json: JsValue = JsString(version)
}

object SecComInfoTokenVersionLegacy extends SecComInfoTokenVersion {
  def version: String = "Legacy"
}

object SecComInfoTokenVersionLatest extends SecComInfoTokenVersion {
  def version: String = "Latest"
}
object SecComInfoTokenVersionUrl    extends SecComInfoTokenVersion {
  def version: String = "Url"
}
object SecComInfoTokenVersion {

  val Legacy = SecComInfoTokenVersionLegacy
  val Latest = SecComInfoTokenVersionLatest
  val Url    = SecComInfoTokenVersionUrl

  def apply(version: String): Option[SecComInfoTokenVersion] =
    version match {
      case "Legacy" => Some(Legacy)
      case "legacy" => Some(Legacy)
      case "Latest" => Some(Latest)
      case "latest" => Some(Latest)
      case "Url"    => Some(Url)
      case "url"    => Some(Url)
      case _        => None
    }
}

case class SecComHeaders(
    claimRequestName: Option[String] = None,
    stateRequestName: Option[String] = None,
    stateResponseName: Option[String] = None
) {
  def json: JsValue =
    Json.obj(
      "claimRequestName"  -> claimRequestName.map(JsString.apply).getOrElse(JsNull).as[JsValue],
      "stateRequestName"  -> stateRequestName.map(JsString.apply).getOrElse(JsNull).as[JsValue],
      "stateResponseName" -> stateResponseName.map(JsString.apply).getOrElse(JsNull).as[JsValue]
    )
}

object SecComHeaders {
  val format = new Format[SecComHeaders] {
    override def writes(o: SecComHeaders): JsValue             = o.json
    override def reads(json: JsValue): JsResult[SecComHeaders] =
      Try {
        JsSuccess(
          SecComHeaders(
            claimRequestName = (json \ "claimRequestName").asOpt[String].filterNot(_.trim.isEmpty),
            stateRequestName = (json \ "stateRequestName").asOpt[String].filterNot(_.trim.isEmpty),
            stateResponseName = (json \ "stateResponseName").asOpt[String].filterNot(_.trim.isEmpty)
          )
        )
      } recover { case e =>
        JsError(e.getMessage)
      } get
  }
}

case class RestrictionPath(method: String, path: String) {
  def json: JsValue = RestrictionPath.format.writes(this)
}

object RestrictionPath {
  val format = new Format[RestrictionPath] {
    override def writes(o: RestrictionPath): JsValue             =
      Json.obj(
        "method" -> o.method,
        "path"   -> o.path
      )
    override def reads(json: JsValue): JsResult[RestrictionPath] =
      Try {
        JsSuccess(
          RestrictionPath(
            method = (json \ "method").as[String],
            path = (json \ "path").as[String]
          )
        )
      } recover { case e =>
        JsError(e.getMessage)
      } get
  }
}

case class Restrictions(
    enabled: Boolean = false,
    allowLast: Boolean = true,
    allowed: Seq[RestrictionPath] = Seq.empty,
    forbidden: Seq[RestrictionPath] = Seq.empty,
    notFound: Seq[RestrictionPath] = Seq.empty
) {

  def json: JsValue = Restrictions.format.writes(this)

  def isAllowed(method: String, domain: String, path: String): Boolean = {
    if (enabled) {
      matches(method, domain, path, allowed)
    } else {
      false
    }
  }

  def isNotAllowed(method: String, domain: String, path: String): Boolean = {
    if (enabled) {
      !matches(method, domain, path, allowed)
    } else {
      false
    }
  }

  def isNotFound(method: String, domain: String, path: String): Boolean = {
    if (enabled) {
      matches(method, domain, path, notFound)
    } else {
      false
    }
  }

  def isForbidden(method: String, domain: String, path: String): Boolean = {
    if (enabled) {
      matches(method, domain, path, forbidden)
    } else {
      false
    }
  }

  private def matches(method: String, domain: String, path: String, paths: Seq[RestrictionPath]): Boolean = {
    val cleanMethod = method.trim().toLowerCase()
    paths
      .map(p => p.copy(method = p.method.trim.toLowerCase()))
      .filter(p => p.method == "*" || p.method == cleanMethod)
      .exists { p =>
        if (p.path.startsWith("/")) {
          RegexPool.regex(p.path).matches(path)
        } else {
          RegexPool.regex(p.path).matches(domain + path)
        }
      }
  }

  private val cache = Caches.bounded[String, (Boolean, Future[Result])](10000) // Not that clean but perfs matters

  // def handleRestrictions(descriptor: ServiceDescriptor, apk: Option[ApiKey], req: RequestHeader, attrs: TypedMap)(
  def handleRestrictions(
      id: String,
      descriptor: Option[ServiceDescriptor],
      apk: Option[ApiKey],
      req: RequestHeader,
      attrs: TypedMap
  )(implicit
      ec: ExecutionContext,
      env: Env
  ): (Boolean, Future[Result]) = {

    import otoroshi.utils.http.RequestImplicits._

    if (enabled) {
      val method = req.method
      val domain = req.theDomain
      val path   = req.thePath
      val key    = s"${id}:${apk.map(_.clientId).getOrElse("none")}:$method:$domain:$path"
      cache.get(
        key,
        _ => {
          if (allowLast) {
            if (isNotFound(method, domain, path)) {
              (
                true,
                Errors.craftResponseResult(
                  "Not Found",
                  Results.NotFound,
                  req,
                  descriptor,
                  Some("errors.not.found"),
                  emptyBody = true,
                  attrs = attrs
                )
              )
            } else if (isForbidden(method, domain, path)) {
              (
                true,
                Errors.craftResponseResult(
                  "Forbidden",
                  Results.Forbidden,
                  req,
                  descriptor,
                  Some("errors.forbidden"),
                  emptyBody = true,
                  attrs = attrs
                )
              )
            } else if (isNotAllowed(method, domain, path)) {
              (
                true,
                Errors.craftResponseResult(
                  "Not Found", // TODO: is it the right response ?
                  Results.NotFound,
                  req,
                  descriptor,
                  Some("errors.not.found"),
                  emptyBody = true,
                  attrs = attrs
                )
              )
            } else {
              Restrictions.failedFutureResp
            }
          } else {
            val allowed = isAllowed(method, domain, path)
            if (!allowed && isNotFound(method, domain, path)) {
              (
                true,
                Errors.craftResponseResult(
                  "Not Found",
                  Results.NotFound,
                  req,
                  descriptor,
                  Some("errors.not.found"),
                  emptyBody = true,
                  attrs = attrs
                )
              )
            } else if (!allowed && isForbidden(method, domain, path)) {
              (
                true,
                Errors.craftResponseResult(
                  "Forbidden",
                  Results.Forbidden,
                  req,
                  descriptor,
                  Some("errors.forbidden"),
                  emptyBody = true,
                  attrs = attrs
                )
              )
            } else if (isNotAllowed(method, domain, path)) {
              (
                true,
                Errors.craftResponseResult(
                  "Not Found", // TODO: is it the right response ?
                  Results.NotFound,
                  req,
                  descriptor,
                  Some("errors.not.found"),
                  emptyBody = true,
                  attrs = attrs
                )
              )
            } else {
              Restrictions.failedFutureResp
            }
          }
        }
      )
    } else {
      Restrictions.failedFutureResp
    }
  }
}

object Restrictions {

  private val failedFutureResp = (false, FastFuture.failed(new RuntimeException("Should never happen")))

  val format = new Format[Restrictions] {
    override def writes(o: Restrictions): JsValue             =
      Json.obj(
        "enabled"   -> o.enabled,
        "allowLast" -> o.allowLast,
        "allowed"   -> JsArray(o.allowed.map(_.json)),
        "forbidden" -> JsArray(o.forbidden.map(_.json)),
        "notFound"  -> JsArray(o.notFound.map(_.json))
      )
    override def reads(json: JsValue): JsResult[Restrictions] =
      Try {
        JsSuccess(
          Restrictions(
            enabled = (json \ "enabled").asOpt[Boolean].getOrElse(false),
            allowLast = (json \ "allowLast").asOpt[Boolean].getOrElse(true),
            allowed = (json \ "allowed")
              .asOpt[JsArray]
              .map(_.value.map(p => RestrictionPath.format.reads(p)).collect { case JsSuccess(rp, _) =>
                rp
              })
              .getOrElse(Seq.empty),
            forbidden = (json \ "forbidden")
              .asOpt[JsArray]
              .map(_.value.map(p => RestrictionPath.format.reads(p)).collect { case JsSuccess(rp, _) =>
                rp
              })
              .getOrElse(Seq.empty),
            notFound = (json \ "notFound")
              .asOpt[JsArray]
              .map(_.value.map(p => RestrictionPath.format.reads(p)).collect { case JsSuccess(rp, _) =>
                rp
              })
              .getOrElse(Seq.empty)
          )
        )
      } recover { case e =>
        JsError(e.getMessage)
      } get
  }
}

case class ServiceDescriptor(
    id: String,
    groups: Seq[String] = Seq("default"),
    name: String,
    description: String = "",
    env: String,
    domain: String,
    subdomain: String,
    targetsLoadBalancing: LoadBalancing = RoundRobin,
    targets: Seq[Target] = Seq.empty[Target],
    root: String = "/",
    matchingRoot: Option[String] = None,
    stripPath: Boolean = true,
    localHost: String = "localhost:8080",
    localScheme: String = "http",
    redirectToLocal: Boolean = false,
    enabled: Boolean = true,
    userFacing: Boolean = false,
    privateApp: Boolean = false,
    forceHttps: Boolean = true,
    maintenanceMode: Boolean = false,
    buildMode: Boolean = false,
    strictlyPrivate: Boolean = false,
    sendOtoroshiHeadersBack: Boolean = true,
    readOnly: Boolean = false,
    xForwardedHeaders: Boolean = false,
    overrideHost: Boolean = true,
    allowHttp10: Boolean = true,
    logAnalyticsOnServer: Boolean = false,
    useAkkaHttpClient: Boolean = false,
    useNewWSClient: Boolean = false,
    tcpUdpTunneling: Boolean = false,
    detectApiKeySooner: Boolean = false,
    letsEncrypt: Boolean = false,
    // TODO: group secCom configs in v2, not done yet to avoid breaking stuff
    enforceSecureCommunication: Boolean = true,
    sendInfoToken: Boolean = true,
    sendStateChallenge: Boolean = true,
    secComHeaders: SecComHeaders = SecComHeaders(),
    secComTtl: FiniteDuration = 30.seconds,
    secComVersion: SecComVersion = SecComVersion.V1,
    secComInfoTokenVersion: SecComInfoTokenVersion = SecComInfoTokenVersion.Legacy,
    secComExcludedPatterns: Seq[String] = Seq.empty[String],
    secComSettings: AlgoSettings = HSAlgoSettings(
      512,
      "${config.app.claim.sharedKey}",
      false
    ),
    secComUseSameAlgo: Boolean = true,
    secComAlgoChallengeOtoToBack: AlgoSettings = HSAlgoSettings(512, "secret", false),
    secComAlgoChallengeBackToOto: AlgoSettings = HSAlgoSettings(512, "secret", false),
    secComAlgoInfoToken: AlgoSettings = HSAlgoSettings(512, "secret", false),
    ///////////////////////////////////////////////////////////
    securityExcludedPatterns: Seq[String] = Seq.empty[String],
    publicPatterns: Seq[String] = Seq.empty[String],
    privatePatterns: Seq[String] = Seq.empty[String],
    additionalHeaders: Map[String, String] = Map.empty[String, String],
    additionalHeadersOut: Map[String, String] = Map.empty[String, String],
    missingOnlyHeadersIn: Map[String, String] = Map.empty[String, String],
    missingOnlyHeadersOut: Map[String, String] = Map.empty[String, String],
    removeHeadersIn: Seq[String] = Seq.empty[String],
    removeHeadersOut: Seq[String] = Seq.empty[String],
    headersVerification: Map[String, String] = Map.empty[String, String],
    matchingHeaders: Map[String, String] = Map.empty[String, String],
    ipFiltering: IpFiltering = IpFiltering(),
    api: ApiDescriptor = ApiDescriptor(false, None),
    healthCheck: HealthCheck = HealthCheck(false, "/"),
    clientConfig: ClientConfig = ClientConfig(),
    canary: Canary = Canary(),
    metadata: Map[String, String] = Map.empty[String, String],
    tags: Seq[String] = Seq.empty,
    chaosConfig: ChaosConfig = ChaosConfig(),
    jwtVerifier: JwtVerifier = RefJwtVerifier(),
    authConfigRef: Option[String] = None,
    cors: CorsSettings = CorsSettings(false),
    redirection: RedirectionSettings = RedirectionSettings(false),
    clientValidatorRef: Option[String] = None,
    ///////////////////////////////////////////////////////////
    transformerRefs: Seq[String] = Seq.empty,
    transformerConfig: JsValue = Json.obj(),
    accessValidator: AccessValidatorRef = AccessValidatorRef(),
    preRouting: PreRoutingRef = PreRoutingRef(),
    plugins: Plugins = Plugins(),
    ///////////////////////////////////////////////////////////
    gzip: GzipConfig = GzipConfig(),
    // thirdPartyApiKey: ThirdPartyApiKeyConfig = OIDCThirdPartyApiKeyConfig(false, None),
    apiKeyConstraints: ApiKeyConstraints = ApiKeyConstraints(),
    restrictions: Restrictions = Restrictions(),
    hosts: Seq[String] = Seq.empty[String],
    paths: Seq[String] = Seq.empty[String],
    handleLegacyDomain: Boolean = true,
    issueCert: Boolean = false,
    issueCertCA: Option[String] = None,
    location: otoroshi.models.EntityLocation = otoroshi.models.EntityLocation()
) extends otoroshi.models.EntityLocationSupport {

  def json: JsValue      = toJson
  def internalId: String = id

  def theDescription: String           = description
  def theMetadata: Map[String, String] = metadata
  def theName: String                  = name
  def theTags: Seq[String]             = tags

  def algoChallengeFromOtoToBack: AlgoSettings = if (secComUseSameAlgo) secComSettings else secComAlgoChallengeOtoToBack
  def algoChallengeFromBackToOto: AlgoSettings = if (secComUseSameAlgo) secComSettings else secComAlgoChallengeBackToOto
  def algoInfoFromOtoToBack: AlgoSettings      = if (secComUseSameAlgo) secComSettings else secComAlgoInfoToken

  lazy val toHost: String = subdomain match {
    case s if s.isEmpty                  => s"$env.$domain"
    case s if s.isEmpty && env == "prod" => s"$domain"
    case s if env == "prod"              => s"$subdomain.$domain"
    case s                               => s"$subdomain.$env.$domain"
  }

  lazy val allHosts: Seq[String] = hosts ++ (if (handleLegacyDomain) Seq(toHost) else Seq.empty[String])
  lazy val allPaths: Seq[String] = paths ++ (if (handleLegacyDomain) matchingRoot.toSeq else Seq.empty[String])

  def maybeStrippedUri(req: RequestHeader, rawUri: String): String = {
    val root        = req.relativeUri
    val rootMatched = allPaths match { //rootMatched was this.matchingRoot
      case ps if ps.isEmpty => None
      case ps               => ps.find(p => root.startsWith(p))
    }
    rootMatched
      .filter(m => stripPath && root.startsWith(m))
      .map(m => root.replaceFirst(m.replace(".", "\\."), ""))
      .getOrElse(rawUri)
  }

  def target: Target                                                 = targets.headOption.getOrElse(NgTarget.default.legacy)
  def save()(implicit ec: ExecutionContext, env: Env)                = env.datastores.serviceDescriptorDataStore.set(this)
  def delete()(implicit ec: ExecutionContext, env: Env)              = env.datastores.serviceDescriptorDataStore.delete(this)
  def exists()(implicit ec: ExecutionContext, env: Env)              = env.datastores.serviceDescriptorDataStore.exists(this)
  def toJson                                                         = ServiceDescriptor.toJson(this)
  def isUp(implicit ec: ExecutionContext, env: Env): Future[Boolean] = FastFuture.successful(true)
  // not useful anymore as circuit breakers should do the work
  // env.datastores.healthCheckDataStore.findLast(this).map(_.map(_.isUp).getOrElse(true))
  // TODO : check perfs
  // def isUriPublic(uri: String): Boolean = !privatePatterns.exists(p => uri.matches(p)) && publicPatterns.exists(p => uri.matches(p))
  def authorizedOnGroup(id: String): Boolean                         = groups.contains(id)

  lazy val hasNoRoutingConstraints: Boolean = apiKeyConstraints.hasNoRoutingConstraints

  def isUriPublic(uri: String): Boolean =
    !privatePatterns.exists(p => otoroshi.utils.RegexPool.regex(p).matches(uri)) && publicPatterns.exists(p =>
      otoroshi.utils.RegexPool.regex(p).matches(uri)
    )

  def isExcludedFromSecurity(uri: String): Boolean = {
    securityExcludedPatterns.exists(p => otoroshi.utils.RegexPool.regex(p).matches(uri))
  }

  def isUriExcludedFromSecuredCommunication(uri: String): Boolean =
    secComExcludedPatterns.exists(p => otoroshi.utils.RegexPool.regex(p).matches(uri))
  def isPrivate                                                   = privateApp
  def updateMetrics(
      callDuration: Long,
      callOverhead: Long,
      dataIn: Long,
      dataOut: Long,
      upstreamLatency: Long,
      config: otoroshi.models.GlobalConfig
  )(implicit
      ec: ExecutionContext,
      env: Env
  ): Future[Unit]                                                 =
    env.datastores.serviceDescriptorDataStore.updateMetrics(
      id,
      callDuration,
      callOverhead,
      dataIn,
      dataOut,
      upstreamLatency,
      config
    )
  def theScheme: String                                           = if (forceHttps) "https://" else "http://"
  def theLine: String                                             = if (env == "prod") "" else s".$env"
  def theDomain                                                   = if (s"$subdomain$theLine".isEmpty) domain else s".$subdomain$theLine"
  def exposedDomain: String                                       = s"$theScheme://$subdomain$theLine.$domain"
  lazy val _domain: String                                        = s"$subdomain$theLine.$domain"

  def validateClientCertificates(
      snowflake: String,
      req: RequestHeader,
      apikey: Option[ApiKey] = None,
      user: Option[PrivateAppsUser] = None,
      config: GlobalConfig,
      attrs: TypedMap
  )(f: => Future[Result])(implicit ec: ExecutionContext, env: Env): Future[Result] = {
    validateClientCertificatesGen(snowflake, req, apikey, user, config, attrs)(f.map(Right.apply)).map {
      case Left(r)  => r
      case Right(r) => r
    }
  }

  import play.api.http.websocket.{Message => PlayWSMessage}

  def wsValidateClientCertificates(
      snowflake: String,
      req: RequestHeader,
      apikey: Option[ApiKey] = None,
      user: Option[PrivateAppsUser] = None,
      config: GlobalConfig,
      attrs: TypedMap
  )(
      f: => Future[Either[Result, Flow[PlayWSMessage, PlayWSMessage, _]]]
  )(implicit ec: ExecutionContext, env: Env): Future[Either[Result, Flow[PlayWSMessage, PlayWSMessage, _]]] = {
    validateClientCertificatesGen(snowflake, req, apikey, user, config, attrs)(f)
  }

  def validateClientCertificatesGen[A](
      snowflake: String,
      req: RequestHeader,
      apikey: Option[ApiKey] = None,
      user: Option[PrivateAppsUser] = None,
      config: GlobalConfig,
      attrs: TypedMap
  )(
      f: => Future[Either[Result, A]]
  )(implicit ec: ExecutionContext, env: Env): Future[Either[Result, A]] = {

    val plugs    = plugins.accessValidators(req)
    val gScripts = env.datastores.globalConfigDataStore.latestSafe
      .filter(_.scripts.enabled)
      .map(_.scripts)
      .getOrElse(GlobalScripts())

    if (
      plugs.nonEmpty || (gScripts.enabled && gScripts.validatorRefs.nonEmpty) || (accessValidator.enabled && accessValidator.refs.nonEmpty)
    ) {
      val lScripts: Seq[String] = Some(accessValidator)
        .filter(pr =>
          pr.enabled && (pr.excludedPatterns.isEmpty || pr.excludedPatterns
            .exists(p => otoroshi.utils.RegexPool.regex(p).matches(req.path)))
        )
        .map(_.refs)
        .getOrElse(Seq.empty)
      val refs                  = (plugs ++ gScripts.validatorRefs ++ lScripts).distinct
      if (refs.nonEmpty) {
        env.metrics
          .withTimerAsync("otoroshi.core.proxy.validate-access") {
            Source(refs.toList.zipWithIndex)
              .mapAsync(1) { case (ref, index) =>
                val validator = env.scriptManager.getAnyScript[AccessValidator](ref) match {
                  case Left("compiling") => CompilingValidator
                  case Left(_)           => DefaultValidator
                  case Right(validator)  => validator
                }
                validator.access(
                  AccessContext(
                    snowflake = snowflake,
                    index = index,
                    request = req,
                    descriptor = this,
                    user = user,
                    apikey = apikey,
                    attrs = attrs,
                    globalConfig = ConfigUtils.mergeOpt(
                      gScripts.validatorConfig,
                      env.datastores.globalConfigDataStore.latestSafe.map(_.plugins.config)
                    ),
                    config = ConfigUtils.merge(accessValidator.config, plugins.config)
                  )
                )
              }
              .takeWhile(
                a =>
                  a match {
                    case Allowed   => true
                    case Denied(_) => false
                  },
                true
              )
              .toMat(Sink.last)(Keep.right)
              .run()(env.otoroshiMaterializer)
          }
          .flatMap {
            case Allowed        => f
            case Denied(result) => FastFuture.successful(Left(result))
          }
      } else {
        f
      }
    } else {
      clientValidatorRef.map { ref =>
        env.datastores.clientCertificateValidationDataStore.findById(ref).flatMap {
          case Some(validator) => validator.validateClientCertificatesGen[A](req, this, apikey, user, config, attrs)(f)
          case None            =>
            Errors
              .craftResponseResult(
                "Validator not found",
                Results.InternalServerError,
                req,
                None,
                None,
                attrs = attrs
              )
              .map(Left.apply)
        }
      } getOrElse f
    }
  }

  def generateInfoToken(
      apiKey: Option[ApiKey],
      paUsr: Option[PrivateAppsUser],
      requestHeader: Option[RequestHeader],
      issuer: Option[String] = None,
      sub: Option[String] = None
  )(implicit
      env: Env
  ): OtoroshiClaim = {
    InfoTokenHelper.generateInfoToken(
      name,
      secComInfoTokenVersion,
      secComTtl,
      apiKey,
      paUsr,
      requestHeader,
      issuer,
      sub,
      None
    )(env)
  }

  import otoroshi.utils.http.RequestImplicits._

  def preRoute(
      snowflake: String,
      req: RequestHeader,
      attrs: TypedMap
  )(f: => Future[Result])(implicit ec: ExecutionContext, env: Env): Future[Result] = {
    preRouteGen(snowflake, req, attrs)(f.map(Right.apply)).map {
      case Left(r)  => r
      case Right(r) => r
    }
  }

  def preRouteWS(snowflake: String, req: RequestHeader, attrs: TypedMap)(
      f: => Future[Either[Result, Flow[PlayWSMessage, PlayWSMessage, _]]]
  )(implicit ec: ExecutionContext, env: Env): Future[Either[Result, Flow[PlayWSMessage, PlayWSMessage, _]]] = {
    preRouteGen[Flow[PlayWSMessage, PlayWSMessage, _]](snowflake, req, attrs)(f)
  }

  def preRouteGen[A](snowflake: String, req: RequestHeader, attrs: TypedMap)(
      f: => Future[Either[Result, A]]
  )(implicit ec: ExecutionContext, env: Env): Future[Either[Result, A]] = {

    import otoroshi.utils.future.Implicits._

    val plugs    = plugins.preRoutings(req)
    val gScripts = env.datastores.globalConfigDataStore.latestSafe
      .filter(_.scripts.enabled)
      .map(_.scripts)
      .getOrElse(GlobalScripts())
    if (
      plugs.nonEmpty || (gScripts.enabled && gScripts.preRouteRefs.nonEmpty) || (preRouting.enabled && preRouting.refs.nonEmpty)
    ) {
      val lScripts: Seq[String] = Some(preRouting)
        .filter(pr =>
          pr.enabled && (pr.excludedPatterns.isEmpty || pr.excludedPatterns
            .exists(p => otoroshi.utils.RegexPool.regex(p).matches(req.path)))
        )
        .map(_.refs)
        .getOrElse(Seq.empty)
      val refs                  = (plugs ++ gScripts.preRouteRefs ++ lScripts).distinct
      if (refs.nonEmpty) {
        env.metrics
          .withTimerAsync("otoroshi.core.proxy.pre-routing") {
            Source(refs.toList.zipWithIndex)
              .mapAsync(1) { case (ref, index) =>
                val route = env.scriptManager.getAnyScript[PreRouting](ref) match {
                  case Left("compiling") => CompilingPreRouting
                  case Left(_)           => DefaultPreRouting
                  case Right(r)          => r
                }
                route.preRoute(
                  PreRoutingContext(
                    snowflake = snowflake,
                    index = index,
                    request = req,
                    descriptor = this,
                    attrs = attrs,
                    globalConfig = ConfigUtils.mergeOpt(
                      gScripts.preRouteConfig,
                      env.datastores.globalConfigDataStore.latestSafe.map(_.plugins.config)
                    ),
                    config = ConfigUtils.merge(preRouting.config, plugins.config)
                  )
                )
              }
              .toMat(Sink.last)(Keep.right)
              .run()(env.otoroshiMaterializer)
          }
          .flatMap(_ => f)
          .recoverWith {
            case PreRoutingError(body, code, ctype, headers) =>
              FastFuture.successful(Results.Status(code)(body).as(ctype).withHeaders(headers.toSeq: _*)).map(Left.apply)
            case PreRoutingErrorWithResult(result)           =>
              FastFuture.successful(result).map(Left.apply)
            case e                                           =>
              Errors
                .craftResponseResult(
                  message = e.getMessage,
                  status = Results.Status(500),
                  req = req,
                  maybeDescriptor = Some(this),
                  attrs = attrs
                )
                .map(Left.apply)
          }
      } else {
        f
      }
    } else {
      f
    }
  }
}

object ServiceDescriptor {

  lazy val logger = Logger("otoroshi-service-descriptor")

  val _fmt: Format[ServiceDescriptor] = new Format[ServiceDescriptor] {

    override def reads(json: JsValue): JsResult[ServiceDescriptor] =
      Try {
        ServiceDescriptor(
          location = otoroshi.models.EntityLocation.readFromKey(json),
          id = (json \ "id").as[String],
          // groupId = (json \ "groupId").as[String],
          groups = {
            val groupId: Seq[String] =
              (json \ "groupId").asOpt[String].toSeq
            val groups: Seq[String]  =
              (json \ "groups").asOpt[Seq[String]].getOrElse(Seq.empty[String])
            (groupId ++ groups).distinct
          },
          name = (json \ "name").asOpt[String].getOrElse((json \ "id").as[String]),
          description = (json \ "description").asOpt[String].getOrElse(""),
          env = (json \ "env").asOpt[String].getOrElse("prod"),
          domain = (json \ "domain").as[String],
          subdomain = (json \ "subdomain").as[String],
          targetsLoadBalancing = (json \ "targetsLoadBalancing").asOpt(LoadBalancing.format).getOrElse(RoundRobin),
          targets = (json \ "targets")
            .asOpt[JsArray]
            .map(_.value.map(e => Target.format.reads(e).get))
            .getOrElse(Seq.empty[Target]),
          root = (json \ "root").asOpt[String].getOrElse("/"),
          matchingRoot = (json \ "matchingRoot").asOpt[String].filter(_.nonEmpty),
          localHost = (json \ "localHost").asOpt[String].getOrElse("localhost:8080"),
          localScheme = (json \ "localScheme").asOpt[String].getOrElse("http"),
          redirectToLocal = (json \ "redirectToLocal").asOpt[Boolean].getOrElse(false),
          enabled = (json \ "enabled").asOpt[Boolean].getOrElse(true),
          stripPath = (json \ "stripPath").asOpt[Boolean].getOrElse(true),
          userFacing = (json \ "userFacing").asOpt[Boolean].getOrElse(false),
          privateApp = (json \ "privateApp").asOpt[Boolean].getOrElse(false),
          forceHttps = (json \ "forceHttps").asOpt[Boolean].getOrElse(true),
          logAnalyticsOnServer = (json \ "logAnalyticsOnServer").asOpt[Boolean].getOrElse(false),
          useAkkaHttpClient = (json \ "useAkkaHttpClient").asOpt[Boolean].getOrElse(false),
          useNewWSClient = (json \ "useNewWSClient").asOpt[Boolean].getOrElse(false),
          tcpUdpTunneling =
            (json \ "tcpUdpTunneling").asOpt[Boolean].orElse((json \ "tcpTunneling").asOpt[Boolean]).getOrElse(false),
          detectApiKeySooner = (json \ "detectApiKeySooner").asOpt[Boolean].getOrElse(false),
          maintenanceMode = (json \ "maintenanceMode").asOpt[Boolean].getOrElse(false),
          buildMode = (json \ "buildMode").asOpt[Boolean].getOrElse(false),
          strictlyPrivate = (json \ "strictlyPrivate").asOpt[Boolean].getOrElse(false),
          enforceSecureCommunication = (json \ "enforceSecureCommunication").asOpt[Boolean].getOrElse(true),
          sendInfoToken = (json \ "sendInfoToken").asOpt[Boolean].getOrElse(true),
          sendStateChallenge = (json \ "sendStateChallenge").asOpt[Boolean].getOrElse(true),
          sendOtoroshiHeadersBack = (json \ "sendOtoroshiHeadersBack").asOpt[Boolean].getOrElse(true),
          readOnly = (json \ "readOnly").asOpt[Boolean].getOrElse(false),
          xForwardedHeaders = (json \ "xForwardedHeaders").asOpt[Boolean].getOrElse(false),
          overrideHost = (json \ "overrideHost").asOpt[Boolean].getOrElse(true),
          allowHttp10 = (json \ "allowHttp10").asOpt[Boolean].getOrElse(true),
          letsEncrypt = (json \ "letsEncrypt").asOpt[Boolean].getOrElse(false),
          secComHeaders = (json \ "secComHeaders").asOpt(SecComHeaders.format).getOrElse(SecComHeaders()),
          secComTtl =
            (json \ "secComTtl").asOpt[Long].map(v => FiniteDuration(v, TimeUnit.MILLISECONDS)).getOrElse(30.seconds),
          secComVersion = (json \ "secComVersion")
            .asOpt[Int]
            .flatMap(SecComVersion.apply)
            .orElse((json \ "secComVersion").asOpt[String].flatMap(SecComVersion.apply))
            .getOrElse(SecComVersion.V1),
          secComInfoTokenVersion = (json \ "secComInfoTokenVersion")
            .asOpt[String]
            .flatMap(SecComInfoTokenVersion.apply)
            .getOrElse(SecComInfoTokenVersion.Legacy),
          secComExcludedPatterns = (json \ "secComExcludedPatterns").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
          securityExcludedPatterns =
            (json \ "securityExcludedPatterns").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
          publicPatterns = (json \ "publicPatterns").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
          privatePatterns = (json \ "privatePatterns").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
          additionalHeaders =
            (json \ "additionalHeaders").asOpt[Map[String, String]].getOrElse(Map.empty[String, String]),
          additionalHeadersOut =
            (json \ "additionalHeadersOut").asOpt[Map[String, String]].getOrElse(Map.empty[String, String]),
          missingOnlyHeadersIn =
            (json \ "missingOnlyHeadersIn").asOpt[Map[String, String]].getOrElse(Map.empty[String, String]),
          missingOnlyHeadersOut =
            (json \ "missingOnlyHeadersOut").asOpt[Map[String, String]].getOrElse(Map.empty[String, String]),
          headersVerification =
            (json \ "headersVerification").asOpt[Map[String, String]].getOrElse(Map.empty[String, String]),
          matchingHeaders = (json \ "matchingHeaders").asOpt[Map[String, String]].getOrElse(Map.empty[String, String]),
          removeHeadersIn = (json \ "removeHeadersIn").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
          removeHeadersOut = (json \ "removeHeadersOut").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
          ipFiltering = (json \ "ipFiltering").asOpt(IpFiltering.format).getOrElse(IpFiltering()),
          api = (json \ "api").asOpt(ApiDescriptor.format).getOrElse(ApiDescriptor(false, None)),
          healthCheck = (json \ "healthCheck").asOpt(HealthCheck.format).getOrElse(HealthCheck(false, "/")),
          clientConfig = (json \ "clientConfig").asOpt(ClientConfig.format).getOrElse(ClientConfig()),
          canary = (json \ "canary").asOpt(Canary.format).getOrElse(Canary()),
          gzip = (json \ "gzip").asOpt(GzipConfig._fmt).getOrElse(GzipConfig()),
          tags = (json \ "tags").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
          metadata = (json \ "metadata")
            .asOpt[Map[String, String]]
            .map(_.filter(_._1.nonEmpty))
            .getOrElse(Map.empty[String, String]),
          chaosConfig = (json \ "chaosConfig").asOpt(ChaosConfig._fmt).getOrElse(ChaosConfig()),
          jwtVerifier = JwtVerifier
            .fromJson((json \ "jwtVerifier").asOpt[JsValue].getOrElse(JsNull))
            .getOrElse(RefJwtVerifier()),
          secComSettings = AlgoSettings
            .fromJson((json \ "secComSettings").asOpt[JsValue].getOrElse(JsNull))
            .getOrElse(HSAlgoSettings(512, "${config.app.claim.sharedKey}", false)),
          secComUseSameAlgo = (json \ "secComUseSameAlgo").asOpt[Boolean].getOrElse(true),
          secComAlgoChallengeOtoToBack = AlgoSettings
            .fromJson((json \ "secComAlgoChallengeOtoToBack").asOpt[JsValue].getOrElse(JsNull))
            .getOrElse(HSAlgoSettings(512, "secret", false)),
          secComAlgoChallengeBackToOto = AlgoSettings
            .fromJson((json \ "secComAlgoChallengeBackToOto").asOpt[JsValue].getOrElse(JsNull))
            .getOrElse(HSAlgoSettings(512, "secret", false)),
          secComAlgoInfoToken = AlgoSettings
            .fromJson((json \ "secComAlgoInfoToken").asOpt[JsValue].getOrElse(JsNull))
            .getOrElse(HSAlgoSettings(512, "secret", false)),
          authConfigRef = (json \ "authConfigRef").asOpt[String].filterNot(_.trim.isEmpty),
          clientValidatorRef = (json \ "clientValidatorRef").asOpt[String].filterNot(_.trim.isEmpty),
          transformerRefs = (json \ "transformerRefs")
            .asOpt[Seq[String]]
            .orElse((json \ "transformerRef").asOpt[String].map(r => Seq(r)))
            .map(_.filterNot(_.trim.isEmpty))
            .getOrElse(Seq.empty),
          transformerConfig = (json \ "transformerConfig").asOpt[JsObject].getOrElse(Json.obj()),
          cors = CorsSettings.fromJson((json \ "cors").asOpt[JsValue].getOrElse(JsNull)).getOrElse(CorsSettings(false)),
          redirection = RedirectionSettings.format
            .reads((json \ "redirection").asOpt[JsValue].getOrElse(JsNull))
            .getOrElse(RedirectionSettings(false)),
          // thirdPartyApiKey = ThirdPartyApiKeyConfig.format
          //   .reads((json \ "thirdPartyApiKey").asOpt[JsValue].getOrElse(JsNull))
          //   .getOrElse(OIDCThirdPartyApiKeyConfig(false, None)),
          apiKeyConstraints = ApiKeyConstraints.format
            .reads((json \ "apiKeyConstraints").asOpt[JsValue].getOrElse(JsNull))
            .getOrElse(ApiKeyConstraints()),
          restrictions = Restrictions.format
            .reads((json \ "restrictions").asOpt[JsValue].getOrElse(JsNull))
            .getOrElse(Restrictions()),
          accessValidator = AccessValidatorRef.format
            .reads((json \ "accessValidator").asOpt[JsValue].getOrElse(JsNull))
            .getOrElse(AccessValidatorRef()),
          plugins = Plugins.format
            .reads((json \ "plugins").asOpt[JsValue].getOrElse(JsNull))
            .getOrElse(Plugins()),
          preRouting = PreRoutingRef.format
            .reads((json \ "preRouting").asOpt[JsValue].getOrElse(JsNull))
            .getOrElse(PreRoutingRef()),
          hosts = (json \ "hosts").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
          paths = (json \ "paths").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
          handleLegacyDomain = (json \ "handleLegacyDomain").asOpt[Boolean].getOrElse(true),
          issueCert = (json \ "issueCert").asOpt[Boolean].getOrElse(false),
          issueCertCA = (json \ "issueCertCA").asOpt[String]
        )
      } map { case sd =>
        JsSuccess(sd)
      } recover { case t =>
        logger.error("Error while reading ServiceDescriptor", t)
        JsError(t.getMessage)
      } get

    override def writes(sd: ServiceDescriptor): JsValue = {
      val oldGroupId: JsValue = sd.groups.headOption.map(JsString.apply).getOrElse(JsNull) // simulate old behavior
      sd.location.jsonWithKey ++ Json.obj(
        "id"                           -> sd.id,
        "groupId"                      -> oldGroupId,
        "groups"                       -> JsArray(sd.groups.map(JsString.apply)),
        "name"                         -> sd.name,
        "description"                  -> sd.description,
        "env"                          -> sd.env,
        "domain"                       -> sd.domain,
        "subdomain"                    -> sd.subdomain,
        "targetsLoadBalancing"         -> sd.targetsLoadBalancing.toJson,
        "targets"                      -> JsArray(sd.targets.map(_.toJson)),
        "root"                         -> sd.root,
        "matchingRoot"                 -> sd.matchingRoot,
        "stripPath"                    -> sd.stripPath,
        "localHost"                    -> sd.localHost,
        "localScheme"                  -> sd.localScheme,
        "redirectToLocal"              -> sd.redirectToLocal,
        "enabled"                      -> sd.enabled,
        "userFacing"                   -> sd.userFacing,
        "privateApp"                   -> sd.privateApp,
        "forceHttps"                   -> sd.forceHttps,
        "logAnalyticsOnServer"         -> sd.logAnalyticsOnServer,
        "useAkkaHttpClient"            -> sd.useAkkaHttpClient,
        "useNewWSClient"               -> sd.useNewWSClient,
        "tcpUdpTunneling"              -> sd.tcpUdpTunneling,
        "detectApiKeySooner"           -> sd.detectApiKeySooner,
        "maintenanceMode"              -> sd.maintenanceMode,
        "buildMode"                    -> sd.buildMode,
        "strictlyPrivate"              -> sd.strictlyPrivate,
        "enforceSecureCommunication"   -> sd.enforceSecureCommunication,
        "sendInfoToken"                -> sd.sendInfoToken,
        "sendStateChallenge"           -> sd.sendStateChallenge,
        "sendOtoroshiHeadersBack"      -> sd.sendOtoroshiHeadersBack,
        "readOnly"                     -> sd.readOnly,
        "xForwardedHeaders"            -> sd.xForwardedHeaders,
        "overrideHost"                 -> sd.overrideHost,
        "allowHttp10"                  -> sd.allowHttp10,
        "letsEncrypt"                  -> sd.letsEncrypt,
        "secComHeaders"                -> sd.secComHeaders.json,
        "secComTtl"                    -> sd.secComTtl.toMillis,
        "secComVersion"                -> sd.secComVersion.json,
        "secComInfoTokenVersion"       -> sd.secComInfoTokenVersion.json,
        "secComExcludedPatterns"       -> JsArray(sd.secComExcludedPatterns.map(JsString.apply)),
        "securityExcludedPatterns"     -> JsArray(sd.securityExcludedPatterns.map(JsString.apply)),
        "publicPatterns"               -> JsArray(sd.publicPatterns.map(JsString.apply)),
        "privatePatterns"              -> JsArray(sd.privatePatterns.map(JsString.apply)),
        "additionalHeaders"            -> JsObject(sd.additionalHeaders.mapValues(JsString.apply)),
        "additionalHeadersOut"         -> JsObject(sd.additionalHeadersOut.mapValues(JsString.apply)),
        "missingOnlyHeadersIn"         -> JsObject(sd.missingOnlyHeadersIn.mapValues(JsString.apply)),
        "missingOnlyHeadersOut"        -> JsObject(sd.missingOnlyHeadersOut.mapValues(JsString.apply)),
        "removeHeadersIn"              -> JsArray(sd.removeHeadersIn.map(JsString.apply)),
        "removeHeadersOut"             -> JsArray(sd.removeHeadersOut.map(JsString.apply)),
        "headersVerification"          -> JsObject(sd.headersVerification.mapValues(JsString.apply)),
        "matchingHeaders"              -> JsObject(sd.matchingHeaders.mapValues(JsString.apply)),
        "ipFiltering"                  -> sd.ipFiltering.toJson,
        "api"                          -> sd.api.toJson,
        "healthCheck"                  -> sd.healthCheck.toJson,
        "clientConfig"                 -> sd.clientConfig.toJson,
        "canary"                       -> sd.canary.toJson,
        "gzip"                         -> sd.gzip.asJson,
        "metadata"                     -> JsObject(sd.metadata.filter(_._1.nonEmpty).mapValues(JsString.apply)),
        "tags"                         -> JsArray(sd.tags.map(JsString.apply)),
        "chaosConfig"                  -> sd.chaosConfig.asJson,
        "jwtVerifier"                  -> sd.jwtVerifier.asJson,
        "secComSettings"               -> sd.secComSettings.asJson,
        "secComUseSameAlgo"            -> sd.secComUseSameAlgo,
        "secComAlgoChallengeOtoToBack" -> sd.secComAlgoChallengeOtoToBack.asJson,
        "secComAlgoChallengeBackToOto" -> sd.secComAlgoChallengeBackToOto.asJson,
        "secComAlgoInfoToken"          -> sd.secComAlgoInfoToken.asJson,
        "cors"                         -> sd.cors.asJson,
        "redirection"                  -> sd.redirection.toJson,
        "authConfigRef"                -> sd.authConfigRef,
        "clientValidatorRef"           -> sd.clientValidatorRef,
        "transformerRef"               -> sd.transformerRefs.headOption.map(JsString.apply).getOrElse(JsNull).as[JsValue],
        "transformerRefs"              -> sd.transformerRefs,
        "transformerConfig"            -> sd.transformerConfig,
        // "thirdPartyApiKey"             -> sd.thirdPartyApiKey.toJson,
        "apiKeyConstraints"            -> sd.apiKeyConstraints.json,
        "restrictions"                 -> sd.restrictions.json,
        "accessValidator"              -> sd.accessValidator.json,
        "preRouting"                   -> sd.preRouting.json,
        "plugins"                      -> sd.plugins.json,
        "hosts"                        -> JsArray(sd.hosts.map(JsString.apply)),
        "paths"                        -> JsArray(sd.paths.map(JsString.apply)),
        "handleLegacyDomain"           -> sd.handleLegacyDomain,
        "issueCert"                    -> sd.issueCert,
        "issueCertCA"                  -> sd.issueCertCA.map(JsString.apply).getOrElse(JsNull).as[JsValue]
      )
    }
  }
  def toJson(value: ServiceDescriptor): JsValue                 = _fmt.writes(value)
  def fromJsons(value: JsValue): ServiceDescriptor              =
    try {
      _fmt.reads(value).get
    } catch {
      case e: Throwable => {
        logger.error(s"Try to deserialize ${Json.prettyPrint(value)}")
        throw e
      }
    }
  def fromJsonSafe(value: JsValue): JsResult[ServiceDescriptor] = _fmt.reads(value)
}

object ServiceDescriptorDataStore {
  val logger = Logger("otoroshi-service-descriptor-datastore")
}

trait ServiceDescriptorDataStore extends BasicStore[ServiceDescriptor] {

  def template(env: Env): ServiceDescriptor = initiateNewDescriptor()(env)

  def initiateNewDescriptor()(implicit env: Env): ServiceDescriptor = {
    val (subdomain, envir, domain) = env.staticExposedDomain.map { v =>
      ServiceLocation.fullQuery(
        v,
        env.datastores.globalConfigDataStore.latest()(env.otoroshiExecutionContext, env)
      ) match {
        case None           => ("myservice", "prod", env.domain)
        case Some(location) => (location.subdomain, location.env, location.domain)
      }
    } getOrElse ("myservice", "prod", env.domain)
    val defaultDescriptor          = ServiceDescriptor(
      id = IdGenerator.namedId("service", env),
      name = "my-service",
      description = "a service",
      groups = Seq("default"),
      env = envir,
      domain = domain,
      subdomain = subdomain,
      targets = Seq(
        Target(
          host = "changeme.cleverapps.io",
          scheme = "https",
          mtlsConfig = MtlsConfig()
        )
      ),
      detectApiKeySooner = false,
      privateApp = false,
      sendOtoroshiHeadersBack = false,    // try to hide otoroshi as much as possible
      enforceSecureCommunication = false, // try to hide otoroshi as much as possible
      forceHttps = if (env.exposedRootSchemeIsHttps) true else false,
      allowHttp10 = true,
      letsEncrypt = false,
      removeHeadersIn = Seq.empty,
      removeHeadersOut = Seq.empty,
      accessValidator = AccessValidatorRef(),
      missingOnlyHeadersIn = Map.empty,
      missingOnlyHeadersOut = Map.empty,
      stripPath = true
    )
    env.datastores.globalConfigDataStore
      .latest()(env.otoroshiExecutionContext, env)
      .templates
      .descriptor
      .map { template =>
        ServiceDescriptor._fmt.reads(defaultDescriptor.json.asObject.deepMerge(template)).get
      }
      .getOrElse {
        defaultDescriptor
      }
  }
  def updateMetrics(
      id: String,
      callDuration: Long,
      callOverhead: Long,
      dataIn: Long,
      dataOut: Long,
      upstreamLatency: Long,
      config: otoroshi.models.GlobalConfig
  )(implicit
      ec: ExecutionContext,
      env: Env
  ): Future[Unit]
  def updateMetricsOnError(config: otoroshi.models.GlobalConfig)(implicit ec: ExecutionContext, env: Env): Future[Unit]
  def updateIncrementableMetrics(
      id: String,
      calls: Long,
      dataIn: Long,
      dataOut: Long,
      config: otoroshi.models.GlobalConfig
  )(implicit
      ec: ExecutionContext,
      env: Env
  ): Future[Unit]
  def count()(implicit ec: ExecutionContext, env: Env): Future[Long]
  def dataInPerSecFor(id: String)(implicit ec: ExecutionContext, env: Env): Future[Double]
  def dataOutPerSecFor(id: String)(implicit ec: ExecutionContext, env: Env): Future[Double]
  def globalCalls()(implicit ec: ExecutionContext, env: Env): Future[Long]
  def globalCallsPerSec()(implicit ec: ExecutionContext, env: Env): Future[Double]
  def globalCallsDuration()(implicit ec: ExecutionContext, env: Env): Future[Double]
  def globalCallsOverhead()(implicit ec: ExecutionContext, env: Env): Future[Double]
  def calls(id: String)(implicit ec: ExecutionContext, env: Env): Future[Long]
  def callsPerSec(id: String)(implicit ec: ExecutionContext, env: Env): Future[Double]
  def callsDuration(id: String)(implicit ec: ExecutionContext, env: Env): Future[Double]
  def callsOverhead(id: String)(implicit ec: ExecutionContext, env: Env): Future[Double]
  def globalDataIn()(implicit ec: ExecutionContext, env: Env): Future[Long]
  def globalDataOut()(implicit ec: ExecutionContext, env: Env): Future[Long]
  def dataInFor(id: String)(implicit ec: ExecutionContext, env: Env): Future[Long]
  def dataOutFor(id: String)(implicit ec: ExecutionContext, env: Env): Future[Long]
  def findByEnv(env: String)(implicit ec: ExecutionContext, _env: Env): Future[Seq[ServiceDescriptor]]
  def findByGroup(id: String)(implicit ec: ExecutionContext, env: Env): Future[Seq[ServiceDescriptor]]
  def findOrRouteById(id: String)(implicit ec: ExecutionContext, env: Env): Future[Option[ServiceDescriptor]] = {
    findById(id) flatMap {
      case Some(service) => service.some.vfuture
      case None          =>
        env.datastores.routeDataStore.findById(id) flatMap {
          case Some(service) => service.legacy.some.vfuture
          case None          =>
            env.datastores.routeCompositionDataStore.findById(id) map {
              case Some(service) => service.toRoutes.head.legacy.some
              case None          => None
            }
        }
    }
  }

  def getFastLookups(query: ServiceDescriptorQuery)(implicit ec: ExecutionContext, env: Env): Future[Seq[String]]
  def fastLookupExists(query: ServiceDescriptorQuery)(implicit ec: ExecutionContext, env: Env): Future[Boolean]
  def addFastLookups(query: ServiceDescriptorQuery, services: Seq[ServiceDescriptor])(implicit
      ec: ExecutionContext,
      env: Env
  ): Future[Boolean]
  def removeFastLookups(query: ServiceDescriptorQuery, services: Seq[ServiceDescriptor])(implicit
      ec: ExecutionContext,
      env: Env
  ): Future[Boolean]

  def cleanupFastLookups()(implicit ec: ExecutionContext, mat: Materializer, env: Env): Future[Long]

  @inline
  def matchAllHeaders(sr: ServiceDescriptor, query: ServiceDescriptorQuery)(implicit env: Env): Boolean =
    env.metrics.withTimer("otoroshi.core.proxy.services.match-headers") {
      val headersSeq: Map[String, String] = query.matchingHeaders.filterNot(_._1.trim.isEmpty)
      val allHeadersMatched: Boolean      =
        sr.matchingHeaders.filterNot(_._1.trim.isEmpty).forall { case (key, value) =>
          val regex = RegexPool.regex(value)
          headersSeq.get(key).exists(h => regex.matches(h))
        }
      allHeadersMatched
    }

  @inline
  def sortServices(
      services: Seq[ServiceDescriptor],
      query: ServiceDescriptorQuery,
      requestHeader: RequestHeader,
      attrs: TypedMap
  )(implicit
      ec: ExecutionContext,
      env: Env
  ): Future[Seq[ServiceDescriptor]] = env.metrics.withTimerAsync("otoroshi.core.proxy.services.sort") {

    /*services.exists(_.hasNoRoutingConstraints) match {
      case true => {
        val filtered1 = services.filter { sr =>
          val allHeadersMatched = matchAllHeaders(sr, query)
          val rootMatched = sr.matchingRoot match {
            case Some(matchingRoot) => query.root.startsWith(matchingRoot) //matchingRoot == query.root
            case None               => true
          }
          allHeadersMatched && rootMatched
        }
        val sersWithoutMatchingRoot = filtered1.filter(_.matchingRoot.isEmpty)
        val sersWithMatchingRoot = filtered1.filter(_.matchingRoot.isDefined).sortWith {
          case (a, b) => a.matchingRoot.get.size > b.matchingRoot.get.size
        }
        sersWithMatchingRoot ++ sersWithoutMatchingRoot
      }
      case false => {
        val filtered1 = services.filter { sr =>
          val allHeadersMatched = matchAllHeaders(sr, query)
          val rootMatched = sr.matchingRoot match {
            case Some(matchingRoot) => query.root.startsWith(matchingRoot) //matchingRoot == query.root
            case None               => true
          }
          allHeadersMatched && rootMatched
        }
        val sersWithoutMatchingRoot = filtered1.filter(_.matchingRoot.isEmpty)
        val sersWithMatchingRoot = filtered1.filter(_.matchingRoot.isDefined).sortWith {
          case (a, b) => a.matchingRoot.get.size > b.matchingRoot.get.size
        }
        val filtered = sersWithMatchingRoot ++ sersWithoutMatchingRoot
        FastFuture
          .sequence(filtered.map { sr =>
            matchApiKeyRouting(sr, requestHeader).map(m => (sr, m))
          })
          .map { s =>
            val allSers = s.filter(_._2).map(_._1)
            if (filtered.size > 0 && filtered.size > allSers.size && allSers.size == 0) {
              // let apikey check in handler produce an Unauthorized response instead of service not found
              Seq(filtered.last)
            } else {
              allSers
            }
          }
      }
    }*/

    /*
    val filtered1 = services.filter { sr =>
      val allHeadersMatched = matchAllHeaders(sr, query)
      val rootMatched = sr.matchingRoot match {
        case Some(matchingRoot) => query.root.startsWith(matchingRoot) //matchingRoot == query.root
        case None               => true
      }
      allHeadersMatched && rootMatched
    }
    val sersWithoutMatchingRoot = filtered1.filter(_.matchingRoot.isEmpty)
    val sersWithMatchingRoot = filtered1.filter(_.matchingRoot.isDefined).sortWith {
      case (a, b) => a.matchingRoot.get.size > b.matchingRoot.get.size
    }
    val filtered = sersWithMatchingRoot ++ sersWithoutMatchingRoot
    FastFuture
      .sequence(filtered.map { sr =>
        matchApiKeyRouting(sr, requestHeader).map(m => (sr, m))
      })
      .map { s =>
        val allSers = s.filter(_._2).map(_._1)
        val res1 = if (filtered.size > 0 && filtered.size > allSers.size && allSers.size == 0) {
          // let apikey check in handler produce an Unauthorized response instead of service not found
          Seq(filtered.last)
        } else {
          allSers
        }
        res1.sortWith {
          case (a, b) => b.toHost.contains("*") && !a.toHost.contains("*")
        }
      }
     */

    val matched                 = new UnboundedTrieMap[String, String]()
    val filtered1               = services.filter { sr =>
      val allHeadersMatched = matchAllHeaders(sr, query)
      val rootMatched       = sr.allPaths match {
        case ps if ps.isEmpty => true
        case ps               =>
          val found = sr.allPaths.find(p => query.root.startsWith(p))
          found.foreach(p => matched.putIfAbsent(sr.id, p))
          found.isDefined
      }
      sr.enabled && allHeadersMatched && rootMatched
    }
    val sersWithoutMatchingRoot = filtered1.filter(_.allPaths.isEmpty)
    val sersWithMatchingRoot    = filtered1.filter(_.allPaths.nonEmpty).sortWith { case (a, b) =>
      val aMatchedSize = matched.get(a.id).map(_.size).getOrElse(0)
      val bMatchedSize = matched.get(b.id).map(_.size).getOrElse(0)
      aMatchedSize > bMatchedSize
    }
    val filtered                = sersWithMatchingRoot ++ sersWithoutMatchingRoot
    FastFuture
      .sequence(filtered.map { sr =>
        matchApiKeyRouting(sr, requestHeader, attrs).map(m => (sr, m))
      })
      .map { s =>
        val allSers = s.filter(_._2).map(_._1)
        val res1    = if (filtered.size > 0 && filtered.size > allSers.size && allSers.size == 0) {
          // let apikey check in handler produce an Unauthorized response instead of service not found
          Seq(filtered.last)
        } else {
          allSers
        }
        res1.sortWith { case (a, b) =>
          b.toHost.contains("*") && !a.toHost.contains("*")
        }
      }
  }

  @inline
  def matchApiKeyRouting(sr: ServiceDescriptor, requestHeader: RequestHeader, attrs: TypedMap)(implicit
      ec: ExecutionContext,
      env: Env
  ): Future[Boolean] = env.metrics.withTimerAsync("otoroshi.core.proxy.services.match-apikey-routing") {

    lazy val shouldSearchForAndApiKey =
      if (sr.isPrivate && sr.authConfigRef.isDefined && !sr.isExcludedFromSecurity(requestHeader.path)) {
        if (sr.isUriPublic(requestHeader.path)) {
          false
        } else {
          true // false positive in 33% of the cases
        }
      } else {
        if (sr.isUriPublic(requestHeader.path)) {
          false
        } else {
          true
        }
      }

    val shouldNotSearchForAnApiKey = sr.hasNoRoutingConstraints && !shouldSearchForAndApiKey

    if (shouldNotSearchForAnApiKey) {
      FastFuture.successful(true)
    } else {
      ApiKeyHelper.extractApiKey(requestHeader, sr, attrs).map {
        case None         => true
        case Some(apiKey) => apiKey.matchRouting(sr)
      }
    }
  }

  @inline
  def rawFind(query: ServiceDescriptorQuery, requestHeader: RequestHeader, attrs: TypedMap)(implicit
      ec: ExecutionContext,
      env: Env
  ): Future[Seq[ServiceDescriptor]] = env.metrics.withTimerAsync("otoroshi.core.proxy.services.raw-find") {
    if (ServiceDescriptorDataStore.logger.isDebugEnabled)
      ServiceDescriptorDataStore.logger.debug("Full scan of services, should not pass here anymore ...")
    // val redisCli = this.asInstanceOf[KvServiceDescriptorDataStore].redisLike
    // val all = if (redisCli.optimized) {
    //   redisCli.asOptimized.serviceDescriptors_findByHost(query)
    // } else {
    //   findAll()
    // }
    findAll().flatMap { descriptors =>
      val validDescriptors = descriptors.filter { sr =>
        if (!sr.enabled) {
          false
        } else {
          sr.allHosts match {
            case hosts if hosts.isEmpty   => false
            case hosts if hosts.size == 1 => otoroshi.utils.RegexPool(hosts.head).matches(query.toHost)
            case hosts                    => {
              hosts.exists(host => RegexPool(host).matches(query.toHost))
            }
          }
        }
      }
      query.addServices(validDescriptors)
      sortServices(validDescriptors, query, requestHeader, attrs)
    }
  }

  // TODO : prefill ServiceDescriptorQuery lookup set when crud service descriptors
  def find(query: ServiceDescriptorQuery, requestHeader: RequestHeader, attrs: TypedMap)(implicit
      ec: ExecutionContext,
      env: Env
  ): Future[Option[ServiceDescriptor]] = env.metrics.withTimerAsync("otoroshi.core.proxy.services.find") {
    val start = System.currentTimeMillis()
    query.exists().flatMap {
      case true  => {
        if (ServiceDescriptorDataStore.logger.isDebugEnabled)
          ServiceDescriptorDataStore.logger.debug(s"Service descriptors exists for fast lookups ${query.asKey}")
        query
          .getServices(false)
          .fast
          .flatMap {
            case services if services.isEmpty => {
              // fast lookup should not store empty results, so ...
              ServiceDescriptorDataStore.logger
                .debug(s"FastLookup false positive for ${query.toHost}, doing a fullscan instead ...")
              rawFind(query, requestHeader, attrs)
            }
            case services                     => sortServices(services, query, requestHeader, attrs)
          }
      }
      case false => {
        rawFind(query, requestHeader, attrs)
      }
    } map { filteredDescriptors =>
      filteredDescriptors.headOption
    } /* andThen {
      case _ =>
        ServiceDescriptorDataStore.logger.debug(s"Found microservice in ${System.currentTimeMillis() - start} ms.")
    }*/
  }
}

object SeqImplicits {
  implicit class BetterSeq[A](val seq: Seq[A]) extends AnyVal {
    def findOne(in: Seq[A]): Boolean = seq.intersect(in).nonEmpty
    def findAll(in: Seq[A]): Boolean = {
      // println(s"trying to find ${in} in ${seq} => ${seq.intersect(in).toSet.size == in.size}")
      seq.intersect(in).toSet.size == in.size
    }
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy