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

sttp.model.headers.Cookie.scala Maven / Gradle / Ivy

The newest version!
package sttp.model.headers

import sttp.model.Header
import sttp.model.headers.Cookie.SameSite
import sttp.model.internal.Rfc2616.validateToken
import sttp.model.internal.Validate._
import sttp.model.internal.{Rfc2616, Validate}

import java.time.Instant
import scala.util.{Failure, Success, Try}

/** A cookie name-value pair.
  *
  * The `name` and `value` should be already encoded (if necessary), as when serialised, they end up unmodified in the
  * header.
  */
case class Cookie(name: String, value: String) {

  /** @return
    *   Representation of the cookie as in a header value, in the format: `[name]=[value]`.
    */
  override def toString: String = s"$name=$value"
}

/** For a description of the behavior of `apply`, `parse`, `safeApply` and `unsafeApply` methods, see [[sttp.model]].
  */
object Cookie {
  // see: https://stackoverflow.com/questions/1969232/allowed-characters-in-cookies/1969339
  private val AllowedValueCharacters = s"[^${Rfc2616.CTL}]*".r

  private[model] def validateName(name: String): Option[String] = validateToken("Cookie name", name)

  private[model] def validateValue(value: String): Option[String] =
    if (AllowedValueCharacters.unapplySeq(value).isEmpty) {
      Some("Cookie value can not contain control characters")
    } else None

  /** @throws IllegalArgumentException
    *   If the cookie name or value contain illegal characters.
    */
  def unsafeApply(name: String, value: String): Cookie = safeApply(name, value).getOrThrow

  def safeApply(name: String, value: String): Either[String, Cookie] = {
    Validate.all(validateName(name), validateValue(value))(new Cookie(name, value))
  }

  /** Parse the cookie, represented as a header value (in the format: `[name]=[value]`).
    */
  def parse(s: String): Either[String, List[Cookie]] = {
    val ss = s.split(";")
    val cs = List.newBuilder[Cookie]
    var i = 0
    while (i < ss.length) {
      val vs = ss(i).split("=", 2)
      (if (vs.length == 1) Cookie.safeApply(vs(0).trim, "")
       else Cookie.safeApply(vs(0).trim, vs(1).trim)) match {
        case Right(c)    => cs += c
        case Left(error) => return Left(error)
      }
      i += 1
    }
    Right(cs.result())
  }

  def unsafeParse(s: String): List[Cookie] = parse(s).getOrThrow

  /** @return
    *   Representation of the cookies as in a header value, in the format: `[name]=[value]; [name]=[value]; ...`.
    */
  def toString(cs: List[Cookie]): String = cs.map(_.toString).mkString("; ")

  sealed trait SameSite
  object SameSite {
    case object Lax extends SameSite { override def toString = "Lax" }
    case object Strict extends SameSite { override def toString = "Strict" }
    case object None extends SameSite { override def toString = "None" }
  }
}

case class CookieValueWithMeta(
    value: String,
    expires: Option[Instant],
    maxAge: Option[Long],
    domain: Option[String],
    path: Option[String],
    secure: Boolean,
    httpOnly: Boolean,
    sameSite: Option[SameSite],
    otherDirectives: Map[String, Option[String]]
)

object CookieValueWithMeta {
  private val AllowedDirectiveValueCharacters = s"""[^;${Rfc2616.CTL}]*""".r

  private[model] def validateDirectiveValue(directiveName: String, value: String): Option[String] =
    if (AllowedDirectiveValueCharacters.unapplySeq(value).isEmpty) {
      Some(s"Value of directive $directiveName name can contain any characters except ; and control characters")
    } else None

  def unsafeApply(
      value: String,
      expires: Option[Instant] = None,
      maxAge: Option[Long] = None,
      domain: Option[String] = None,
      path: Option[String] = None,
      secure: Boolean = false,
      httpOnly: Boolean = false,
      sameSite: Option[SameSite] = None,
      otherDirectives: Map[String, Option[String]] = Map.empty
  ): CookieValueWithMeta =
    safeApply(value, expires, maxAge, domain, path, secure, httpOnly, sameSite, otherDirectives).getOrThrow

  def safeApply(
      value: String,
      expires: Option[Instant] = None,
      maxAge: Option[Long] = None,
      domain: Option[String] = None,
      path: Option[String] = None,
      secure: Boolean = false,
      httpOnly: Boolean = false,
      sameSite: Option[SameSite] = None,
      otherDirectives: Map[String, Option[String]] = Map.empty
  ): Either[String, CookieValueWithMeta] =
    Cookie
      .validateValue(value)
      .orElse(path.flatMap(validateDirectiveValue("path", _)))
      .orElse(domain.flatMap(validateDirectiveValue("domain", _))) match {
      case None => Right(apply(value, expires, maxAge, domain, path, secure, httpOnly, sameSite, otherDirectives))
      case Some(error) => Left(error)
    }
}

/** A cookie name-value pair with directives.
  *
  * All `String` values should be already encoded (if necessary), as when serialised, they end up unmodified in the
  * header.
  */
case class CookieWithMeta(
    name: String,
    valueWithMeta: CookieValueWithMeta
) {
  def value: String = valueWithMeta.value
  def expires: Option[Instant] = valueWithMeta.expires
  def maxAge: Option[Long] = valueWithMeta.maxAge
  def domain: Option[String] = valueWithMeta.domain
  def path: Option[String] = valueWithMeta.path
  def secure: Boolean = valueWithMeta.secure
  def httpOnly: Boolean = valueWithMeta.httpOnly
  def sameSite: Option[SameSite] = valueWithMeta.sameSite
  def otherDirectives: Map[String, Option[String]] = valueWithMeta.otherDirectives

  def value(v: String): CookieWithMeta = copy(valueWithMeta = valueWithMeta.copy(value = v))
  def expires(v: Option[Instant]): CookieWithMeta = copy(valueWithMeta = valueWithMeta.copy(expires = v))
  def maxAge(v: Option[Long]): CookieWithMeta = copy(valueWithMeta = valueWithMeta.copy(maxAge = v))
  def domain(v: Option[String]): CookieWithMeta = copy(valueWithMeta = valueWithMeta.copy(domain = v))
  def path(v: Option[String]): CookieWithMeta = copy(valueWithMeta = valueWithMeta.copy(path = v))
  def secure(v: Boolean): CookieWithMeta = copy(valueWithMeta = valueWithMeta.copy(secure = v))
  def httpOnly(v: Boolean): CookieWithMeta = copy(valueWithMeta = valueWithMeta.copy(httpOnly = v))
  def sameSite(s: Option[SameSite]): CookieWithMeta = copy(valueWithMeta = valueWithMeta.copy(sameSite = s))
  def otherDirective(v: (String, Option[String])): CookieWithMeta =
    copy(valueWithMeta = valueWithMeta.copy(otherDirectives = otherDirectives + v))

  /** @return
    *   Representation of the cookie as in a header value, in the format: `[name]=[value]; [directive]=[value]; ...`.
    */
  override def toString: String = {
    val sb = new java.lang.StringBuilder(64)
    sb.append(name).append('=').append(value)
    expires match {
      case x: Some[Instant] => sb.append("; Expires=").append(Header.toHttpDateString(x.value))
      case _                => ()
    }
    maxAge match {
      case x: Some[Long] => sb.append("; Max-Age=").append(x.value)
      case _             => ()
    }
    domain match {
      case x: Some[String] => sb.append("; Domain=").append(x.value)
      case _               => ()
    }
    path match {
      case x: Some[String] => sb.append("; Path=").append(x.value)
      case _               => ()
    }
    if (secure) sb.append("; Secure")
    else ()
    if (httpOnly) sb.append("; HttpOnly")
    else ()
    sameSite match {
      case x: Some[SameSite] => sb.append("; SameSite=").append(x.value)
      case _                 => ()
    }
    otherDirectives.foreach { case (k, optV) =>
      sb.append("; ").append(k)
      optV match {
        case x: Some[String] => sb.append('=').append(x.value)
        case _               => ()
      }
    }
    sb.toString
  }
}

object CookieWithMeta {
  def unsafeApply(
      name: String,
      value: String,
      expires: Option[Instant] = None,
      maxAge: Option[Long] = None,
      domain: Option[String] = None,
      path: Option[String] = None,
      secure: Boolean = false,
      httpOnly: Boolean = false,
      sameSite: Option[SameSite] = None,
      otherDirectives: Map[String, Option[String]] = Map.empty
  ): CookieWithMeta =
    safeApply(name, value, expires, maxAge, domain, path, secure, httpOnly, sameSite, otherDirectives).getOrThrow

  def safeApply(
      name: String,
      value: String,
      expires: Option[Instant] = None,
      maxAge: Option[Long] = None,
      domain: Option[String] = None,
      path: Option[String] = None,
      secure: Boolean = false,
      httpOnly: Boolean = false,
      sameSite: Option[SameSite] = None,
      otherDirectives: Map[String, Option[String]] = Map.empty
  ): Either[String, CookieWithMeta] =
    Cookie.validateName(name) match {
      case None =>
        CookieValueWithMeta
          .safeApply(value, expires, maxAge, domain, path, secure, httpOnly, sameSite, otherDirectives)
          .map(v => apply(name, v))
      case Some(e) => Left(e)
    }

  def apply(
      name: String,
      value: String,
      expires: Option[Instant] = None,
      maxAge: Option[Long] = None,
      domain: Option[String] = None,
      path: Option[String] = None,
      secure: Boolean = false,
      httpOnly: Boolean = false,
      sameSite: Option[SameSite] = None,
      otherDirectives: Map[String, Option[String]] = Map.empty
  ): CookieWithMeta =
    apply(
      name,
      CookieValueWithMeta(value, expires, maxAge, domain, path, secure, httpOnly, sameSite, otherDirectives)
    )

  // https://tools.ietf.org/html/rfc6265#section-4.1.1
  /** Parse the cookie, represented as a header value (in the format: `[name]=[value]; [directive]=[value]; ...`).
    */
  def parse(s: String): Either[String, CookieWithMeta] = {
    def splitkv(kv: String): (String, Option[String]) =
      (kv.split("=", 2): @unchecked) match {
        case Array(v1)     => (v1.trim, None)
        case Array(v1, v2) => (v1.trim, Some(v2.trim))
      }

    val components = s.split(";")
    val (first, other) = (components.head.trim, components.tail)
    val (name, value) = splitkv(first)
    var result: Either[String, CookieWithMeta] = Right(CookieWithMeta.apply(name, value.getOrElse("")))
    other.map(splitkv).foreach {
      case (ci"expires", Some(v)) =>
        Header.parseHttpDate(v) match {
          case Right(expires) => result = result.map(_.expires(Some(expires)))
          case Left(_) => result = Left(s"Expires cookie directive is not a valid RFC1123 or RFC850 datetime: $v")
        }
      case (ci"max-age", Some(v)) =>
        Try(v.toLong) match {
          case Success(maxAge) => result = result.map(_.maxAge(Some(maxAge)))
          case Failure(_)      => result = Left(s"Max-Age cookie directive is not a number: $v")
        }
      case (ci"domain", v)   => result = result.map(_.domain(Some(v.getOrElse(""))))
      case (ci"path", v)     => result = result.map(_.path(Some(v.getOrElse(""))))
      case (ci"secure", _)   => result = result.map(_.secure(true))
      case (ci"httponly", _) => result = result.map(_.httpOnly(true))
      case (ci"samesite", Some(v)) =>
        v.trim match {
          case ci"lax"    => result = result.map(_.sameSite(Some(SameSite.Lax)))
          case ci"strict" => result = result.map(_.sameSite(Some(SameSite.Strict)))
          case ci"none"   => result = result.map(_.sameSite(Some(SameSite.None)))
          case _          => result = Left(s"Same-Site cookie directive is not an allowed value: $v")
        }
      case (k, v) => result = result.map(_.otherDirective((k, v)))
    }
    result
  }

  def unsafeParse(s: String): CookieWithMeta = parse(s).getOrThrow

  private implicit class StringInterpolations(sc: StringContext) {
    class CaseInsensitiveStringMatcher {
      def unapply(other: String): Boolean = sc.parts.mkString.equalsIgnoreCase(other)
    }
    def ci: CaseInsensitiveStringMatcher = new CaseInsensitiveStringMatcher
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy