aboutsummaryrefslogtreecommitdiff
path: root/core/src/main/scala/com/softwaremill/sttp/Response.scala
blob: 4d9c38519d75a62c26147775e9ddb5d343377b35 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
package com.softwaremill.sttp

import java.net.HttpCookie
import java.text.SimpleDateFormat
import java.time.ZonedDateTime
import java.util.{Calendar, GregorianCalendar, Locale, TimeZone}

import scala.collection.JavaConverters._
import scala.collection.immutable.Seq
import scala.util.Try

/**
  * @param body `Right(T)`, if the request was successful (status code 2xx).
  *            The body is then handled as specified in the request.
  *            `Left(String)`, if the request wasn't successful (status code
  *            3xx, 4xx or 5xx). In this case, the response body is read into
  *            a `String`.
  * @param history If redirects are followed, and there were redirects,
  *                contains responses for the intermediate requests.
  *                The first response (oldest) comes first.
  */
case class Response[T](body: Either[String, T],
                       code: Int,
                       headers: Seq[(String, String)],
                       history: List[Response[Unit]]) {
  def is200: Boolean = code == 200
  def isSuccess: Boolean = codeIsSuccess(code)
  def isRedirect: Boolean = code >= 300 && code < 400
  def isClientError: Boolean = code >= 400 && code < 500
  def isServerError: Boolean = code >= 500 && code < 600

  def header(h: String): Option[String] =
    headers.find(_._1.equalsIgnoreCase(h)).map(_._2)
  def headers(h: String): Seq[String] =
    headers.filter(_._1.equalsIgnoreCase(h)).map(_._2)

  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)))

  /**
    * Get the body of the response. If the status code wasn't 2xx (and there's
    * no body to return), an exception is thrown, containing the status code
    * and the response from the server.
    */
  def unsafeBody: T = body match {
    case Left(v)  => throw new NoSuchElementException(s"Status code $code: $v")
    case Right(v) => v
  }
}

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 tokens = h.split(";")
      var e: Option[ZonedDateTime] = None
      var ma: Option[Long] = None

      for (t <- tokens) {
        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"
  )
}