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

github4s.http.HttpClient.scala Maven / Gradle / Ivy

The newest version!
/*
 * Copyright 2016-2024 47 Degrees Open Source 
 *
 * 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 github4s.http

import cats.data.EitherT
import cats.effect.Resource
import cats.effect.kernel.Concurrent
import cats.syntax.all._
import github4s.GHError._
import github4s._
import github4s.algebras.{AccessHeader, AccessToken}
import github4s.domain.Pagination
import github4s.http.Http4sSyntax._
import io.circe.{Decoder, Encoder}
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.circe.jsonOf
import org.http4s.client.Client
import org.http4s._

class HttpClient[F[_]: Concurrent] private (
    client: Client[F],
    val config: GithubConfig,
    accessHeader: AccessHeader[F]
) {
  import HttpClient._
  import accessHeader._

  def get[Res: Decoder](
      method: String,
      headers: Map[String, String] = Map.empty,
      params: Map[String, String] = Map.empty,
      pagination: Option[Pagination] = None
  ): F[GHResponse[Res]] =
    withAccessHeader { authHeader =>
      run[Unit, Res](
        RequestBuilder(url = buildURL(method))
          .withAuthHeader(authHeader)
          .withHeaders(headers)
          .withParams(
            params ++ pagination.fold(Map.empty[String, String])(p =>
              Map("page" -> p.page.toString, "per_page" -> p.per_page.toString)
            )
          )
      )
    }

  def getWithoutResponse(
      url: String,
      headers: Map[String, String] = Map.empty
  ): F[GHResponse[Unit]] =
    withAccessHeader(authHeader =>
      runWithoutResponse[Unit](
        RequestBuilder(buildURL(url)).withHeaders(headers).withAuthHeader(authHeader)
      )
    )

  def patch[Req: Encoder, Res: Decoder](
      method: String,
      headers: Map[String, String] = Map.empty,
      data: Req
  ): F[GHResponse[Res]] =
    withAccessHeader(authHeader =>
      run[Req, Res](
        RequestBuilder(buildURL(method)).patchMethod
          .withAuthHeader(authHeader)
          .withHeaders(headers)
          .withData(data)
      )
    )

  def put[Req: Encoder, Res: Decoder](
      url: String,
      headers: Map[String, String] = Map(),
      data: Req
  ): F[GHResponse[Res]] =
    withAccessHeader(authHeader =>
      run[Req, Res](
        RequestBuilder(buildURL(url)).putMethod
          .withAuthHeader(authHeader)
          .withHeaders(headers)
          .withData(data)
      )
    )

  def post[Req: Encoder, Res: Decoder](
      url: String,
      headers: Map[String, String] = Map.empty,
      data: Req
  ): F[GHResponse[Res]] =
    withAccessHeader(authHeader =>
      run[Req, Res](
        RequestBuilder(buildURL(url)).postMethod
          .withAuthHeader(authHeader)
          .withHeaders(headers)
          .withData(data)
      )
    )

  def postAuth[Req: Encoder, Res: Decoder](
      method: String,
      headers: Map[String, String] = Map.empty,
      data: Req
  ): F[GHResponse[Res]] =
    run[Req, Res](RequestBuilder(buildURL(method)).postMethod.withHeaders(headers).withData(data))

  def postOAuth[Res: Decoder](
      url: String,
      headers: Map[String, String] = Map.empty,
      params: Map[String, String] = Map.empty
  ): F[GHResponse[Res]] =
    run[Unit, Res](
      RequestBuilder(url).postMethod
        .withHeaders(Map("Accept" -> "application/json") ++ headers)
        .withParams(params)
    )

  def delete(
      url: String,
      headers: Map[String, String] = Map.empty
  ): F[GHResponse[Unit]] =
    withAccessHeader(authHeader =>
      run[Unit, Unit](
        RequestBuilder(buildURL(url)).deleteMethod.withHeaders(headers).withAuthHeader(authHeader)
      )
    )

  def deleteWithResponse[Res: Decoder](
      url: String,
      headers: Map[String, String] = Map.empty
  ): F[GHResponse[Res]] =
    withAccessHeader(authHeader =>
      run[Unit, Res](
        RequestBuilder(buildURL(url)).deleteMethod
          .withAuthHeader(authHeader)
          .withHeaders(headers)
      )
    )

  def deleteWithBody[Req: Encoder, Res: Decoder](
      url: String,
      headers: Map[String, String] = Map.empty,
      data: Req
  ): F[GHResponse[Res]] =
    withAccessHeader(authHeader =>
      run[Req, Res](
        RequestBuilder(buildURL(url)).deleteMethod
          .withAuthHeader(authHeader)
          .withHeaders(headers)
          .withData(data)
      )
    )

  private def buildURL(method: String): String = s"${config.baseUrl}$method"

  private def run[Req: Encoder, Res: Decoder](request: RequestBuilder[Req]): F[GHResponse[Res]] =
    runRequest(request)
      .use { response =>
        buildResponse(response).map(GHResponse(_, response.status.code, response.headers.toMap))
      }

  private def runWithoutResponse[Req: Encoder](request: RequestBuilder[Req]): F[GHResponse[Unit]] =
    runRequest(
      request
    ).use { response =>
      buildResponseFromEmpty(response).map(
        GHResponse(_, response.status.code, response.headers.toMap)
      )
    }

  private def runRequest[Req: Encoder](request: RequestBuilder[Req]): Resource[F, Response[F]] =
    client
      .run(
        Request[F]()
          .withMethod(request.httpVerb)
          .withUri(request.toUri(config))
          .withHeaders(Headers(config.toHeaderList) ++ Headers(request.toHeaderList))
          .withJsonBody(request.data)
      )
}

object HttpClient {
  // the GitHub API sometimes returns [[BasicError]] when 404.
  private[github4s] val notFoundDecoder: Decoder[GHError] =
    implicitly[Decoder[NotFoundError]].widen.or(BasicError.basicErrorDecoder.widen)
  private def notFoundEntityDecoder[F[_]: Concurrent]: EntityDecoder[F, GHError] =
    jsonOf(implicitly, notFoundDecoder)

  private[github4s] def buildResponse[F[_]: Concurrent, A: Decoder](
      response: Response[F]
  ): F[Either[GHError, A]] =
    (response.status.code match {
      case i if Status.fromInt(i).exists(_.isSuccess) => response.attemptAs[A].map(_.asRight)
      case 400 => response.attemptAs[BadRequestError].map(_.asLeft)
      case 401 => response.attemptAs[UnauthorizedError].map(_.asLeft)
      case 403 => response.attemptAs[ForbiddenError].map(_.asLeft)
      case 404 => response.attemptAs[GHError](notFoundEntityDecoder).map(_.asLeft)
      case 422 => response.attemptAs[UnprocessableEntityError].map(_.asLeft)
      case 423 => response.attemptAs[RateLimitExceededError].map(_.asLeft)
      case _ =>
        EitherT
          .right[DecodeFailure](responseBody(response))
          .map(s =>
            UnhandledResponseError(s"Unhandled status code ${response.status.code}", s).asLeft
          )
    }).fold(
      e => (JsonParsingError(e): GHError).asLeft,
      _.leftMap[GHError](identity)
    )

  private[github4s] def buildResponseFromEmpty[F[_]: Concurrent](
      response: Response[F]
  ): F[Either[GHError, Unit]] =
    if (response.status.isSuccess)
      Either.unit[GHError].pure[F]
    else
      buildResponse[F, Unit](response)

  private def responseBody[F[_]: Concurrent](response: Response[F]): F[String] =
    response.bodyText.compile.foldMonoid

  def apply[F[_]: Concurrent](
      client: Client[F],
      config: GithubConfig,
      accessToken: AccessToken[F]
  ): HttpClient[F] =
    new HttpClient[F](client, config, AccessHeader.from(accessToken))

  def apply[F[_]: Concurrent](
      client: Client[F],
      config: GithubConfig,
      accessHeader: AccessHeader[F]
  ): HttpClient[F] =
    new HttpClient[F](client, config, accessHeader)
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy