aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoradamw <adam@warski.org>2017-07-15 11:59:10 +0200
committeradamw <adam@warski.org>2017-07-15 11:59:10 +0200
commit8d9145a04cdd21a471fba61fc19203759b95514d (patch)
tree0d5eb11f6c87ed8d2f41b483e381852d871c8ee1
parentbc685df2cd50814b45e669f4f602732887c2879c (diff)
downloadsttp-8d9145a04cdd21a471fba61fc19203759b95514d.tar.gz
sttp-8d9145a04cdd21a471fba61fc19203759b95514d.tar.bz2
sttp-8d9145a04cdd21a471fba61fc19203759b95514d.zip
Cookie parsing
-rw-r--r--core/src/main/scala/com/softwaremill/sttp/Response.scala107
-rw-r--r--core/src/main/scala/com/softwaremill/sttp/package.scala1
-rw-r--r--tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala67
3 files changed, 175 insertions, 0 deletions
diff --git a/core/src/main/scala/com/softwaremill/sttp/Response.scala b/core/src/main/scala/com/softwaremill/sttp/Response.scala
index dbaf71f..a4d60a5 100644
--- a/core/src/main/scala/com/softwaremill/sttp/Response.scala
+++ b/core/src/main/scala/com/softwaremill/sttp/Response.scala
@@ -1,5 +1,17 @@
package com.softwaremill.sttp
+import java.net.HttpCookie
+import java.text.SimpleDateFormat
+import java.time.ZonedDateTime
+import java.util.{
+ Calendar,
+ GregorianCalendar,
+ Locale,
+ StringTokenizer,
+ TimeZone
+}
+
+import scala.collection.JavaConverters._
import scala.collection.immutable.Seq
import scala.util.Try
@@ -18,4 +30,99 @@ case class Response[T](body: T, code: Int, headers: Seq[(String, String)]) {
def contentType: Option[String] = header(ContentTypeHeader)
def contentLength: Option[Long] =
header(ContentLengthHeader).flatMap(cl => Try(cl.toLong).toOption)
+
+ def cookies: Seq[Cookie] =
+ headers(SetCookieHeader)
+ .flatMap(h => HttpCookie.parse(h).asScala.map(hc => Cookie.apply(hc, h)))
+}
+
+case class Cookie(name: String,
+ value: String,
+ expires: Option[ZonedDateTime] = None,
+ maxAge: Option[Long] = None,
+ domain: Option[String] = None,
+ path: Option[String] = None,
+ secure: Boolean = false,
+ httpOnly: Boolean = false)
+
+object Cookie {
+ def apply(hc: HttpCookie, h: String): Cookie = {
+ // HttpCookie.parse has special handling for the expires attribute and turns it into max-age
+ // if the cookie contains an expires header; hand-parsing in such case to preserve the
+ // values from the cookie
+ val lch = h.toLowerCase
+ val (expires, maxAge) = if (lch.contains("expires=")) {
+ val tokenizer = new StringTokenizer(h, ";")
+ var e: Option[ZonedDateTime] = None
+ var ma: Option[Long] = None
+
+ while (tokenizer.hasMoreTokens) {
+ val t = tokenizer.nextToken()
+ val nv = t.split("=", 2)
+ if (nv(0).toLowerCase.contains("expires") && nv.length > 1) {
+ e = expiryDate2ZonedDateTime(nv(1).trim())
+ }
+ if (nv(0).toLowerCase.contains("max-age") && nv.length > 1) {
+ ma = Try(nv(1).toLong).toOption
+ }
+ }
+
+ (e, ma)
+ } else {
+ (None, if (hc.getMaxAge == -1) None else Some(hc.getMaxAge))
+ }
+
+ Cookie(
+ hc.getName,
+ hc.getValue,
+ expires,
+ maxAge,
+ Option(hc.getDomain),
+ Option(hc.getPath),
+ hc.getSecure,
+ hc.isHttpOnly
+ )
+ }
+
+ /**
+ * Modified version of `HttpCookie.expiryDate2DeltaSeconds` to return a `ZonedDateTime`, not a second-delta.
+ */
+ private def expiryDate2ZonedDateTime(
+ dateString: String): Option[ZonedDateTime] = {
+ val cal = new GregorianCalendar(Gmt)
+ CookieDateFormats.foreach { format =>
+ val df = new SimpleDateFormat(format, Locale.US)
+ cal.set(1970, 0, 1, 0, 0, 0)
+ df.setTimeZone(Gmt)
+ df.setLenient(false)
+ df.set2DigitYearStart(cal.getTime)
+ try {
+ cal.setTime(df.parse(dateString))
+ if (!format.contains("yyyy")) { // 2-digit years following the standard set
+ // out it rfc 6265
+ var year = cal.get(Calendar.YEAR)
+ year %= 100
+ if (year < 70) year += 2000
+ else year += 1900
+ cal.set(Calendar.YEAR, year)
+ }
+
+ return Some(cal.toZonedDateTime)
+ } catch {
+ case e: Exception =>
+ // Ignore, try the next date format
+ }
+ }
+
+ None
+ }
+ private val Gmt = TimeZone.getTimeZone("GMT")
+ private val CookieDateFormats = List(
+ "EEE',' dd-MMM-yyyy HH:mm:ss 'GMT'",
+ "EEE',' dd MMM yyyy HH:mm:ss 'GMT'",
+ "EEE MMM dd yyyy HH:mm:ss 'GMT'Z",
+ "EEE',' dd-MMM-yy HH:mm:ss 'GMT'",
+ "EEE',' dd MMM yy HH:mm:ss 'GMT'",
+ "EEE MMM dd yy HH:mm:ss 'GMT'Z"
+ )
}
diff --git a/core/src/main/scala/com/softwaremill/sttp/package.scala b/core/src/main/scala/com/softwaremill/sttp/package.scala
index c39f256..a64908a 100644
--- a/core/src/main/scala/com/softwaremill/sttp/package.scala
+++ b/core/src/main/scala/com/softwaremill/sttp/package.scala
@@ -233,6 +233,7 @@ package object sttp {
private[sttp] val ContentTypeHeader = "content-type"
private[sttp] val ContentLengthHeader = "content-length"
+ private[sttp] val SetCookieHeader = "set-cookie"
private val Utf8 = "utf-8"
private val ApplicationOctetStreamContentType = "application/octet-stream"
diff --git a/tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala b/tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala
index 86d28a9..5d21d6f 100644
--- a/tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala
+++ b/tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala
@@ -2,10 +2,12 @@ package com.softwaremill.sttp
import java.io.ByteArrayInputStream
import java.net.URI
+import java.time.{ZoneId, ZonedDateTime}
import akka.stream.ActorMaterializer
import akka.actor.ActorSystem
import akka.http.scaladsl.Http
+import akka.http.scaladsl.model.DateTime
import akka.http.scaladsl.model.headers._
import akka.http.scaladsl.model.headers.CacheDirectives._
import akka.http.scaladsl.server.Directives._
@@ -56,6 +58,32 @@ class BasicTests
}
}
}
+ } ~ pathPrefix("set_cookies") {
+ path("with_expires") {
+ setCookie(
+ HttpCookie("c",
+ "v",
+ expires = Some(DateTime(1997, 12, 8, 12, 49, 12)))) {
+ complete("ok")
+ }
+ } ~ get {
+ setCookie(
+ HttpCookie("cookie1",
+ "value1",
+ secure = true,
+ httpOnly = true,
+ maxAge = Some(123L))) {
+ setCookie(HttpCookie("cookie2", "value2")) {
+ setCookie(
+ HttpCookie("cookie3",
+ "",
+ domain = Some("xyz"),
+ path = Some("a/b/c"))) {
+ complete("ok")
+ }
+ }
+ }
+ }
}
private implicit val actorSystem: ActorSystem = ActorSystem("sttp-test")
@@ -102,6 +130,7 @@ class BasicTests
bodyTests()
headerTests()
errorsTests()
+ cookiesTests()
def parseResponseTests(): Unit = {
name should "parse response as string" in {
@@ -197,5 +226,43 @@ class BasicTests
resp.isClientError should be(true)
}
}
+
+ def cookiesTests(): Unit = {
+ name should "read response cookies" in {
+ val wrappedResponse =
+ sttp.get(new URI(endpoint + "/set_cookies")).send(ignoreResponse)
+ val response = forceResponse.force(wrappedResponse)
+ response.cookies should have length (3)
+ response.cookies.toSet should be(
+ Set(
+ Cookie("cookie1",
+ "value1",
+ secure = true,
+ httpOnly = true,
+ maxAge = Some(123L)),
+ Cookie("cookie2", "value2"),
+ Cookie("cookie3", "", domain = Some("xyz"), path = Some("a/b/c"))
+ ))
+ }
+
+ name should "read response cookies with the expires attribute" in {
+ val wrappedResponse = sttp
+ .get(new URI(endpoint + "/set_cookies/with_expires"))
+ .send(ignoreResponse)
+ val response = forceResponse.force(wrappedResponse)
+ response.cookies should have length (1)
+ val c = response.cookies(0)
+
+ c.name should be("c")
+ c.value should be("v")
+ c.expires.map(_.toInstant.toEpochMilli) should be(
+ Some(
+ ZonedDateTime
+ .of(1997, 12, 8, 12, 49, 12, 0, ZoneId.of("GMT"))
+ .toInstant
+ .toEpochMilli
+ ))
+ }
+ }
}
}