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

wvlet.airframe.http.HttpClient.scala Maven / Gradle / Ivy

/*
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package wvlet.airframe.http
import wvlet.airframe.codec.MessageCodec
import wvlet.airframe.control.Retry
import wvlet.airframe.control.Retry.RetryContext
import wvlet.airframe.http.HttpClient.urlEncode
import wvlet.airframe.http.HttpMessage.{Request, Response}
import wvlet.airframe.json.JSON.{JSONArray, JSONObject}
import wvlet.airframe.surface.Surface
import wvlet.log.LogSupport

import scala.language.higherKinds
import scala.reflect.ClassTag
import scala.util.Try

/**
  * Asynchronous HTTP Client interface
  *
  * @tparam F
  *   An abstraction for Future type (e.g., Resolves the differences between Twitter Future, Scala Future, etc.)
  * @tparam Req
  * @tparam Resp
  */
trait HttpClient[F[_], Req, Resp] extends HttpClientBase[F, Req, Resp] with AutoCloseable {

  /**
    * Send an HTTP request and get the response. It will throw an exception for non successful responses (after reaching
    * the max retry limit)
    *
    * @throws HttpClientMaxRetryException
    *   if max retry reaches
    * @throws HttpClientException
    *   for non-retryable error is happend
    */
  def send(req: Req, requestFilter: Req => Req = identity): F[Resp]

  /**
    * Send an HTTP request and returns a response (or the last response if the request is retried)
    */
  def sendSafe(req: Req, requestFilter: Req => Req = identity): F[Resp]

  /**
    * Await the response and extract the return value
    *
    * @param f
    * @tparam A
    * @return
    */
  private[http] def awaitF[A](f: F[A]): A

  def syncClient: HttpSyncClient[Req, Resp] = new HttpSyncClientAdapter(this)
}

/**
  * A synchronous HTTP Client interface
  *
  * @tparam Req
  * @tparam Resp
  */
trait HttpSyncClient[Req, Resp] extends HttpSyncClientBase[Req, Resp] with AutoCloseable {

  def send(req: Req, requestFilter: Req => Req = identity): Resp

  def sendSafe(req: Req, requestFilter: Req => Req = identity): Resp

  private val standardResponseCodec = new HttpResponseBodyCodec[Response]

  protected def convertAs[A](response: Response, surface: Surface): A = {
    if (classOf[Response].isAssignableFrom(surface.rawType)) {
      // Can return the response as is
      response.asInstanceOf[A]
    } else {
      // Need a conversion
      val codec   = MessageCodec.ofSurface(surface)
      val msgpack = standardResponseCodec.toMsgPack(response)
      val obj     = codec.unpack(msgpack)
      obj.asInstanceOf[A]
    }
  }

  protected def buildGETRequest(resourcePath: String, requestBody: JSONObject): Request = {
    val queryParams: Seq[String] =
      requestBody.v.map {
        case (k, j @ JSONArray(_)) =>
          s"${urlEncode(k)}=${urlEncode(j.toJSON)}" // Flatten the JSON array value
        case (k, j @ JSONObject(_)) =>
          s"${urlEncode(k)}=${urlEncode(j.toJSON)}" // Flatten the JSON object value
        case (k, other) =>
          s"${urlEncode(k)}=${urlEncode(other.toString)}"
      }

    val r0 = Http.GET(resourcePath)
    val r = (r0.query, queryParams) match {
      case (query, queryParams) if query.isEmpty && queryParams.nonEmpty =>
        r0.withUri(s"${r0.uri}?${queryParams.mkString("&")}")
      case (query, queryParams) if query.nonEmpty && queryParams.nonEmpty =>
        r0.withUri(s"${r0.uri}&${queryParams.mkString("&")}")
      case _ =>
        r0
    }
    r
  }

}

/**
  * A synchronous HttpClient that awaits responses.
  *
  * @param asyncClient
  * @tparam F
  * @tparam Req
  * @tparam Resp
  */
class HttpSyncClientAdapter[F[_], Req, Resp](asyncClient: HttpClient[F, Req, Resp])
    extends HttpSyncClientAdapterBase[F, Req, Resp](asyncClient) {

  /**
    * Send an HTTP request and get the response. It will throw an exception for non successful responses (after reaching
    * the max retry)
    *
    * @throws HttpClientMaxRetryException
    *   if max retry reaches
    * @throws HttpClientException
    *   for non-retryable error is happend
    */
  override def send(req: Req, requestFilter: Req => Req = identity): Resp = awaitF(asyncClient.send(req, requestFilter))

  /**
    * Send an HTTP request and returns a response (or the last response if the request is retried)
    */
  override def sendSafe(req: Req, requestFilter: Req => Req = identity): Resp =
    awaitF(asyncClient.sendSafe(req, requestFilter))

  override def close(): Unit = {
    asyncClient.close()
  }
}

object HttpClient extends LogSupport {

  private[http] def baseHttpClientRetry[Req: HttpRequestAdapter: ClassTag, Resp: HttpResponseAdapter]: RetryContext = {
    Retry
      .withJitter(maxRetry = 15)
      .withResultClassifier(HttpClientException.classifyHttpResponse[Resp])
      .beforeRetry(defaultBeforeRetryAction[Req])
  }

  def defaultHttpClientRetry[Req: HttpRequestAdapter: ClassTag, Resp: HttpResponseAdapter]: RetryContext = {
    baseHttpClientRetry[Req, Resp]
      // JVM specific error handler
      .withErrorClassifier(HttpClientException.classifyExecutionFailure)
  }

  def defaultBeforeRetryAction[Req: HttpRequestAdapter: ClassTag](ctx: RetryContext): Any = {
    val cls = implicitly[ClassTag[Req]].runtimeClass

    val errorMessage = ctx.context match {
      case Some(r) if cls.isAssignableFrom(r.getClass) =>
        val adapter = implicitly[HttpRequestAdapter[Req]]
        val req     = r.asInstanceOf[Req]
        val path    = adapter.pathOf(req)
        s"Request to ${path} is failed: ${ctx.lastError.getMessage}"
      case _ =>
        s"Request is failed: ${ctx.lastError.getMessage}"
    }

    val nextWaitMillis = ctx.nextWaitMillis
    warn(
      f"[${ctx.retryCount}/${ctx.maxRetry}] ${errorMessage}. Retry the request in ${nextWaitMillis / 1000.0}%.3f sec."
    )
  }

  def urlEncode(s: String): String = {
    compat.urlEncode(s)
  }

  /**
    * Generate a GET resource url by embedding the resource object into query parameters
    * @param path
    * @param resource
    * @param resourceSurface
    * @tparam Resource
    * @return
    */
  private[http] def buildResourceUri[Resource](path: String, resource: Resource, resourceSurface: Surface): String = {
    val queryParams    = HttpClient.flattenResourceToQueryParams(resource, resourceSurface)
    val pathWithParams = new StringBuilder()
    pathWithParams.append(path)
    if (queryParams.nonEmpty) {
      val queryParamString = queryParams.entries.map(x => s"${x.key}=${x.value}").mkString("&")
      pathWithParams.append("?")
      pathWithParams.append(queryParamString)
    }
    pathWithParams.result()
  }

  /**
    * Flatten resource objects into query parameters for GET request
    * @param resource
    * @param resourceSurface
    * @tparam Resource
    * @return
    */
  private[http] def flattenResourceToQueryParams[Resource](
      resource: Resource,
      resourceSurface: Surface
  ): HttpMultiMap = {
    val resourceCodec = MessageCodec.ofSurface(resourceSurface).asInstanceOf[MessageCodec[Resource]]
    val resourceJson  = Try(resourceCodec.toJSONObject(resource)).getOrElse(JSONObject.empty)

    val queryParams = HttpMultiMap.newBuilder
    resourceJson.v.map {
      case (k, j @ JSONArray(_)) =>
        queryParams += urlEncode(k) -> urlEncode(j.toJSON) // Flatten the JSON array value
      case (k, j @ JSONObject(_)) =>
        queryParams += urlEncode(k) -> urlEncode(j.toJSON) // Flatten the JSON object value
      case (k, other) =>
        queryParams += urlEncode(k) -> urlEncode(other.toString)
    }
    queryParams.result()
  }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy