diff options
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 + )) + } + } } } |