package com.softwaremill.sttp import java.io.{File, InputStream} import java.nio.ByteBuffer import java.nio.file.Path import java.util.Base64 import scala.collection.immutable.Seq import scala.concurrent.duration.Duration import scala.language.higherKinds /** * @param response What's the target type to which the response body should * be read. Needs to be specified upfront so that the response * is always consumed and hence there are no requirements on * client code to consume it. An exception to this are * streaming responses, which need to fully consumed by the * client if such a response type is requested. * @param tags Request-specific tags which can be used by backends for * logging, metrics, etc. Not used by default. * @tparam U Specifies if the method & uri are specified. By default can be * either: * * `Empty`, which is a type constructor which always resolves to * `None`. This type of request is aliased to `PartialRequest`: * there's no method and uri specified, and the request cannot be * sent. * * `Id`, which is an identity type constructor. This type of * request is aliased to `Request`: the method and uri are * specified, and the request can be sent. */ case class RequestT[U[_], T, +S]( method: U[Method], uri: U[Uri], body: RequestBody[S], headers: Seq[(String, String)], response: ResponseAs[T, S], options: RequestOptions, tags: Map[String, Any] ) { def get(uri: Uri): Request[T, S] = this.copy[Id, T, S](uri = uri, method = Method.GET) def head(uri: Uri): Request[T, S] = this.copy[Id, T, S](uri = uri, method = Method.HEAD) def post(uri: Uri): Request[T, S] = this.copy[Id, T, S](uri = uri, method = Method.POST) def put(uri: Uri): Request[T, S] = this.copy[Id, T, S](uri = uri, method = Method.PUT) def delete(uri: Uri): Request[T, S] = this.copy[Id, T, S](uri = uri, method = Method.DELETE) def options(uri: Uri): Request[T, S] = this.copy[Id, T, S](uri = uri, method = Method.OPTIONS) def patch(uri: Uri): Request[T, S] = this.copy[Id, T, S](uri = uri, method = Method.PATCH) def contentType(ct: String): RequestT[U, T, S] = header(ContentTypeHeader, ct, replaceExisting = true) def contentType(ct: String, encoding: String): RequestT[U, T, S] = header(ContentTypeHeader, contentTypeWithEncoding(ct, encoding), replaceExisting = true) def contentLength(l: Long): RequestT[U, T, S] = header(ContentLengthHeader, l.toString, replaceExisting = true) def header(k: String, v: String, replaceExisting: Boolean = false): RequestT[U, T, S] = { val current = if (replaceExisting) headers.filterNot(_._1.equalsIgnoreCase(k)) else headers this.copy(headers = current :+ (k -> v)) } def headers(hs: Map[String, String]): RequestT[U, T, S] = headers(hs.toSeq: _*) def headers(hs: (String, String)*): RequestT[U, T, S] = this.copy(headers = headers ++ hs) def cookie(nv: (String, String)): RequestT[U, T, S] = cookies(nv) def cookie(n: String, v: String): RequestT[U, T, S] = cookies((n, v)) def cookies(r: Response[_]): RequestT[U, T, S] = cookies(r.cookies.map(c => (c.name, c.value)): _*) def cookies(cs: Seq[Cookie]): RequestT[U, T, S] = cookies(cs.map(c => (c.name, c.value)): _*) def cookies(nvs: (String, String)*): RequestT[U, T, S] = header(CookieHeader, nvs.map(p => p._1 + "=" + p._2).mkString("; ")) def auth: SpecifyAuthScheme[U, T, S] = new SpecifyAuthScheme[U, T, S](AuthorizationHeader, this) def proxyAuth: SpecifyAuthScheme[U, T, S] = new SpecifyAuthScheme[U, T, S](ProxyAuthorizationHeader, this) def acceptEncoding(encoding: String): RequestT[U, T, S] = header(AcceptEncodingHeader, encoding) /** * Uses the `utf-8` encoding. * * If content type is not yet specified, will be set to `text/plain` * with `utf-8` encoding. * * If content length is not yet specified, will be set to the number of * bytes in the string using the `utf-8` encoding. */ def body(b: String): RequestT[U, T, S] = body(b, Utf8) /** * If content type is not yet specified, will be set to `text/plain` * with the given encoding. * * If content length is not yet specified, will be set to the number of * bytes in the string using the given encoding. */ def body(b: String, encoding: String): RequestT[U, T, S] = withBasicBody(StringBody(b, encoding)) .setContentLengthIfMissing(b.getBytes(encoding).length) /** * If content type is not yet specified, will be set to * `application/octet-stream`. * * If content length is not yet specified, will be set to the length * of the given array. */ def body(b: Array[Byte]): RequestT[U, T, S] = withBasicBody(ByteArrayBody(b)) .setContentLengthIfMissing(b.length) /** * If content type is not yet specified, will be set to * `application/octet-stream`. */ def body(b: ByteBuffer): RequestT[U, T, S] = withBasicBody(ByteBufferBody(b)) /** * If content type is not yet specified, will be set to * `application/octet-stream`. */ def body(b: InputStream): RequestT[U, T, S] = withBasicBody(InputStreamBody(b)) /** * If content type is not yet specified, will be set to * `application/octet-stream`. * * If content length is not yet specified, will be set to the length * of the given file. */ def body(b: File): RequestT[U, T, S] = body(b.toPath) /** * If content type is not yet specified, will be set to * `application/octet-stream`. * * If content length is not yet specified, will be set to the length * of the given file. */ def body(b: Path): RequestT[U, T, S] = withBasicBody(PathBody(b)) .setContentLengthIfMissing(b.toFile.length()) /** * Encodes the given parameters as form data using `utf-8`. * If content type is not yet specified, will be set to * `application/x-www-form-urlencoded`. * * If content length is not yet specified, will be set to the length * of the number of bytes in the url-encoded parameter string. */ def body(fs: Map[String, String]): RequestT[U, T, S] = formDataBody(fs.toList, Utf8) /** * Encodes the given parameters as form data. * If content type is not yet specified, will be set to * `application/x-www-form-urlencoded`. * * If content length is not yet specified, will be set to the length * of the number of bytes in the url-encoded parameter string. */ def body(fs: Map[String, String], encoding: String): RequestT[U, T, S] = formDataBody(fs.toList, encoding) /** * Encodes the given parameters as form data using `utf-8`. * If content type is not yet specified, will be set to * `application/x-www-form-urlencoded`. * * If content length is not yet specified, will be set to the length * of the number of bytes in the url-encoded parameter string. */ def body(fs: (String, String)*): RequestT[U, T, S] = formDataBody(fs.toList, Utf8) /** * Encodes the given parameters as form data. * If content type is not yet specified, will be set to * `application/x-www-form-urlencoded`. * * If content length is not yet specified, will be set to the length * of the number of bytes in the url-encoded parameter string. */ def body(fs: Seq[(String, String)], encoding: String): RequestT[U, T, S] = formDataBody(fs, encoding) /** * If content type is not yet specified, will be set to * `application/octet-stream`. */ def body[B: BodySerializer](b: B): RequestT[U, T, S] = withBasicBody(implicitly[BodySerializer[B]].apply(b)) def multipartBody(ps: Seq[Multipart]): RequestT[U, T, S] = this.copy(body = MultipartBody(ps)) def multipartBody(p1: Multipart, ps: Multipart*): RequestT[U, T, S] = this.copy(body = MultipartBody(p1 :: ps.toList)) def streamBody[S2 >: S](b: S2): RequestT[U, T, S2] = copy[U, T, S2](body = StreamBody(b)) def readTimeout(t: Duration): RequestT[U, T, S] = this.copy(options = options.copy(readTimeout = t)) /** * Specifies the target type to which the response body should be read. * Note that this replaces any previous specifications, which also includes * any previous `mapResponse` invocations. */ def response[T2, S2 >: S](ra: ResponseAs[T2, S2]): RequestT[U, T2, S2] = this.copy(response = ra) def mapResponse[T2](f: T => T2): RequestT[U, T2, S] = this.copy(response = response.map(f)) def followRedirects(fr: Boolean): RequestT[U, T, S] = this.copy(options = options.copy(followRedirects = fr)) def tag(k: String, v: Any): RequestT[U, T, S] = this.copy(tags = tags + (k -> v)) def tag(k: String): Option[Any] = tags.get(k) def send[R[_]]()(implicit backend: SttpBackend[R, S], isIdInRequest: IsIdInRequest[U]): R[Response[T]] = { // we could avoid the asInstanceOf by creating an artificial copy // changing the method & url fields using `isIdInRequest`, but that // would be only to satisfy the type checker, and a needless copy at // runtime. backend.send(this.asInstanceOf[RequestT[Id, T, S]]) } private def hasContentType: Boolean = headers.exists(_._1.equalsIgnoreCase(ContentTypeHeader)) private def setContentTypeIfMissing(ct: String): RequestT[U, T, S] = if (hasContentType) this else contentType(ct) private def withBasicBody(body: BasicRequestBody) = { val defaultCt = body match { case StringBody(_, encoding, Some(ct)) => Some(contentTypeWithEncoding(ct, encoding)) case _ => body.defaultContentType } defaultCt.fold(this)(setContentTypeIfMissing).copy(body = body) } private def hasContentLength: Boolean = headers.exists(_._1.equalsIgnoreCase(ContentLengthHeader)) private def setContentLengthIfMissing(l: => Long): RequestT[U, T, S] = if (hasContentLength) this else contentLength(l) private def formDataBody(fs: Seq[(String, String)], encoding: String): RequestT[U, T, S] = { val b = RequestBody.paramsToStringBody(fs, encoding) setContentTypeIfMissing(ApplicationFormContentType) .setContentLengthIfMissing(b.s.getBytes(encoding).length) .copy(body = b) } } class SpecifyAuthScheme[U[_], T, +S](hn: String, rt: RequestT[U, T, S]) { def basic(user: String, password: String): RequestT[U, T, S] = { val c = new String(Base64.getEncoder.encode(s"$user:$password".getBytes(Utf8)), Utf8) rt.header(hn, s"Basic $c") } def bearer(token: String): RequestT[U, T, S] = rt.header(hn, s"Bearer $token") } case class RequestOptions(followRedirects: Boolean, readTimeout: Duration)