From 04b73cd593863dd52b07db97073cf9a04aace7c7 Mon Sep 17 00:00:00 2001 From: Kevin Lee Date: Thu, 28 Jan 2021 22:10:50 +1100 Subject: [PATCH] Issue #146 - Add upload assets to GitHub release --- .../scala/kevinlee/github/GitHubApi.scala | 46 ++- .../kevinlee/github/data/GitHubRelease.scala | 32 ++ src/main/scala/kevinlee/http/HttpClient.scala | 26 +- src/main/scala/kevinlee/http/HttpError.scala | 9 +- .../scala/kevinlee/http/HttpRequest.scala | 295 ++++++++++++++---- 5 files changed, 342 insertions(+), 66 deletions(-) diff --git a/src/main/scala/kevinlee/github/GitHubApi.scala b/src/main/scala/kevinlee/github/GitHubApi.scala index 9c7faef..55bac5f 100644 --- a/src/main/scala/kevinlee/github/GitHubApi.scala +++ b/src/main/scala/kevinlee/github/GitHubApi.scala @@ -26,6 +26,11 @@ trait GitHubApi[F[_]] { repo: GitHubRepoWithAuth, ): F[Either[GitHubError, Option[GitHubRelease.Response]]] + def uploadAssetToRelease( + params: GitHubRelease.UploadAssetParams, + repo: GitHubRepoWithAuth, + ): F[Either[GitHubError, Option[GitHubRelease.Asset]]] + } object GitHubApi { @@ -34,7 +39,8 @@ object GitHubApi { final class GitHubApiF[F[_]: Monad](val httpClient: HttpClient[F]) extends GitHubApi[F] { // TODO: make it configurable - val baseUrl: String = "https://api.github.com" + val baseUrl: String = "https://api.github.com" + val baseUploadUrl: String = "https://uploads.github.com" val DefaultAccept: String = "application/vnd.github.v3+json" @@ -66,7 +72,7 @@ object GitHubApi { ): F[Either[GitHubError, Option[GitHubRelease.Response]]] = { val url = s"$baseUrl/repos/${repo.gitHubRepo.org.org}/${repo.gitHubRepo.repo.repo}/releases" val httpRequest = HttpRequest - .withHeadersAndBody[GitHubRelease.CreateRequestParams]( + .withHeadersAndJsonBody[GitHubRelease.CreateRequestParams]( HttpRequest.Method.post, HttpRequest.Uri(url), HttpRequest.Header("accept" -> DefaultAccept) :: @@ -91,7 +97,7 @@ object GitHubApi { val url = s"$baseUrl/repos/${repo.gitHubRepo.org.org}/${repo.gitHubRepo.repo.repo}/releases/${params.releaseId.releaseId}" val httpRequest = HttpRequest - .withHeadersAndBody[GitHubRelease.UpdateRequestParams]( + .withHeadersAndJsonBody[GitHubRelease.UpdateRequestParams]( HttpRequest.Method.patch, HttpRequest.Uri(url), HttpRequest.Header("accept" -> DefaultAccept) :: @@ -109,6 +115,40 @@ object GitHubApi { ) } + override def uploadAssetToRelease( + params: GitHubRelease.UploadAssetParams, + repo: GitHubRepoWithAuth, + ): F[Either[GitHubError, Option[GitHubRelease.Asset]]] = { + val url = + s"$baseUploadUrl/repos/${repo.gitHubRepo.org.org}/${repo.gitHubRepo.repo.repo}/releases/${params.releaseId.releaseId}/assets" + val httpRequest = HttpRequest + .withHeadersParamsAndMultipartBody( + HttpRequest.Method.post, + HttpRequest.Uri(url), + repo + .accessToken + .toHeaderList, + List( + HttpRequest.Param( + "name" -> params.name.assetName + ) + ) ++ params.label + .map(assetLabel => + HttpRequest.Param( + "label" -> assetLabel.assetLabel + ) + ) + .toList, + params.multipartData, + ) + httpClient + .request[Option[GitHubRelease.Asset]](httpRequest) + .map( + _.toOptionIfNotFound + .leftMap(GitHubError.fromHttpError) + .flatMap(res => res.asRight[GitHubError]) + ) + } } } diff --git a/src/main/scala/kevinlee/github/data/GitHubRelease.scala b/src/main/scala/kevinlee/github/data/GitHubRelease.scala index c236a76..b797d9b 100644 --- a/src/main/scala/kevinlee/github/data/GitHubRelease.scala +++ b/src/main/scala/kevinlee/github/data/GitHubRelease.scala @@ -4,6 +4,7 @@ import io.circe.syntax._ import io.circe.{Decoder, Encoder, Json} import io.estatico.newtype.macros.{newsubtype, newtype} import kevinlee.git.Git +import kevinlee.http.HttpRequest import java.time.Instant @@ -50,6 +51,7 @@ object GitHubRelease { } yield CreateRequestParams(tagName, name, body, draft, prerelease) } + final case class UpdateRequestParams( tagName: Git.TagName, releaseId: ReleaseId, @@ -87,6 +89,18 @@ object GitHubRelease { } + final case class UploadAssetParams( + tagName: Git.TagName, + releaseId: ReleaseId, + name: UploadAssetParams.AssetName, + label: Option[UploadAssetParams.AssetLabel], + multipartData: HttpRequest.MultipartData, + ) + object UploadAssetParams { + @newtype case class AssetName(assetName: String) + @newtype case class AssetLabel(assetLabel: String) + } + @newtype case class Accept(accept: String) object Accept { implicit val encoder: Encoder[Accept] = deriving @@ -285,6 +299,8 @@ object GitHubRelease { final case class Response( id: Response.Id, uri: Response.Url, + assetsUrl: Response.AssetsUrl, + uploadUrl: Response.UploadUrl, author: User, tagName: Git.TagName, name: ReleaseName, @@ -308,6 +324,16 @@ object GitHubRelease { implicit val encoder: Encoder[Url] = deriving implicit val decoder: Decoder[Url] = deriving } + @newtype case class AssetsUrl(assetsUrl: String) + object AssetsUrl { + implicit val encoder: Encoder[AssetsUrl] = deriving + implicit val decoder: Decoder[AssetsUrl] = deriving + } + @newtype case class UploadUrl(uploadUrl: String) + object UploadUrl { + implicit val encoder: Encoder[UploadUrl] = deriving + implicit val decoder: Decoder[UploadUrl] = deriving + } @newtype case class CreatedAt(createdAt: Instant) object CreatedAt { implicit val encoder: Encoder[CreatedAt] = deriving @@ -324,6 +350,8 @@ object GitHubRelease { Json.obj( "id" -> response.id.id.asJson, "url" -> response.uri.url.asJson, + "assets_url" -> response.assetsUrl.assetsUrl.asJson, + "upload_url" -> response.uploadUrl.uploadUrl.asJson, "author" -> response.author.asJson, "tag_name" -> response.tagName.asJson, "name" -> response.name.asJson, @@ -340,6 +368,8 @@ object GitHubRelease { for { id <- c.downField("id").as[Id] url <- c.downField("url").as[Url] + assetsUrl <- c.downField("assets_url").as[AssetsUrl] + uploadUrl <- c.downField("upload_url").as[UploadUrl] author <- c.downField("author").as[User] tagName <- c.downField("tag_name").as[Git.TagName] name <- c.downField("name").as[ReleaseName] @@ -352,6 +382,8 @@ object GitHubRelease { } yield Response( id, url, + assetsUrl, + uploadUrl, author, tagName, name, diff --git a/src/main/scala/kevinlee/http/HttpClient.scala b/src/main/scala/kevinlee/http/HttpClient.scala index 293bb5c..e253d29 100644 --- a/src/main/scala/kevinlee/http/HttpClient.scala +++ b/src/main/scala/kevinlee/http/HttpClient.scala @@ -31,11 +31,15 @@ trait HttpClient[F[_]] { object HttpClient { - def apply[F[_]: Monad: EffectConstructor: ConcurrentEffect: Log](client: Client[F]): HttpClient[F] = + def apply[ + F[_]: Monad: EffectConstructor: ConcurrentEffect: ContextShift: Log + ](client: Client[F]): HttpClient[F] = new HttpClientF[F](client) @SuppressWarnings(Array("org.wartremover.warts.Any", "org.wartremover.warts.Nothing")) - final class HttpClientF[F[_]: Monad: EffectConstructor: ConcurrentEffect: Log]( + final class HttpClientF[ + F[_]: Monad: EffectConstructor: ConcurrentEffect: ContextShift: Log + ]( client: Client[F] ) extends HttpClient[F] { @@ -47,18 +51,24 @@ object HttpClient { sendRequest[A](httpRequest).value private[this] def sendRequest[A]( - httpRequest: HttpRequest, + httpRequest: HttpRequest )( implicit entityDecoderA: Decoder[A] ): EitherT[F, HttpError, A] = for { request <- EitherT.fromEither( - HttpRequest.toHttp4s( - httpRequest, - ) + httpRequest.toHttp4s[F] ) - postProcessedReq <- eitherTRightF[HttpError](postProcessRequest(request, implicitly[EntityDecoder[F, A]].consumes)) + postProcessedReq <- eitherTRightF[HttpError]( + postProcessRequest( + request, + if (httpRequest.isBodyMultipart) + Set.empty[MediaRange] + else + implicitly[EntityDecoder[F, A]].consumes, + ) + ) res <- log( @@ -87,7 +97,7 @@ object HttpClient { private[this] def responseHandler[A]( httpRequest: HttpRequest )( - implicit decoderA: Decoder[A], + implicit decoderA: Decoder[A] ): Response[F] => F[Either[HttpError, A]] = { case Successful(successResponse) => implicitly[EntityDecoder[F, A]] diff --git a/src/main/scala/kevinlee/http/HttpError.scala b/src/main/scala/kevinlee/http/HttpError.scala index 71aea0a..054e12c 100644 --- a/src/main/scala/kevinlee/http/HttpError.scala +++ b/src/main/scala/kevinlee/http/HttpError.scala @@ -45,6 +45,9 @@ object HttpError { httpRequest: HttpRequest, httpResponse: HttpResponse, ) extends HttpError + final case class MethodUnsupportedForMultipart( + httpRequest: HttpRequest + ) extends HttpError def invalidUri(uriString: String, errorMessage: String): HttpError = InvalidUri(uriString, errorMessage) @@ -77,11 +80,15 @@ object HttpError { error.asLeft[Option[A]] } - def forbidden(httpRequest: HttpRequest, httpResponse: HttpResponse): HttpError = Forbidden(httpRequest, httpResponse) + def forbidden(httpRequest: HttpRequest, httpResponse: HttpResponse): HttpError = + Forbidden(httpRequest, httpResponse) def unprocessableEntity(httpRequest: HttpRequest, httpResponse: HttpResponse): HttpError = UnprocessableEntity(httpRequest, httpResponse) + def methodUnsupportedForMultipart(httpRequest: HttpRequest): HttpError = + MethodUnsupportedForMultipart(httpRequest) + @SuppressWarnings(Array("org.wartremover.warts.ToString")) implicit final val show: Show[HttpError] = _.toString diff --git a/src/main/scala/kevinlee/http/HttpRequest.scala b/src/main/scala/kevinlee/http/HttpRequest.scala index d5ff9c6..da2a9e0 100644 --- a/src/main/scala/kevinlee/http/HttpRequest.scala +++ b/src/main/scala/kevinlee/http/HttpRequest.scala @@ -1,11 +1,15 @@ package kevinlee.http; -import cats.{Applicative, Show} +import cats.effect.{Blocker, ContextShift, Sync} import cats.syntax.all._ -import io.circe.{Encoder, Json} +import cats.{Applicative, Show} +import io.circe.Encoder import io.estatico.newtype.macros._ -import HttpRequest.Method.{Delete, Get, Patch, Post, Put} -import org.http4s.{Request, Header => Http4sHeader, Uri => Http4sUri} +import org.http4s.headers.`Content-Type` +import org.http4s.util.CaseInsensitiveString +import org.http4s.{Headers, MediaType, Request, Header => Http4sHeader, Uri => Http4sUri} + +import java.net.URL /** @author Kevin Lee * @since 2021-01-03 @@ -15,7 +19,7 @@ final case class HttpRequest( uri: HttpRequest.Uri, headers: List[HttpRequest.Header], params: List[HttpRequest.Param], - body: Option[Json], + body: Option[HttpRequest.Body], ) @SuppressWarnings( @@ -81,7 +85,20 @@ object HttpRequest { s"($name: $value)" } .mkString("[", ", ", "]") - val bodyString = httpRequest.body.fold("")(_.spaces2) + val bodyString = + httpRequest.body.fold("") { + case HttpRequest.Body.Json(json) => + json.spaces2 + + case HttpRequest.Body.Multipart(multipartData) => + multipartData match { + case HttpRequest.MultipartData.File(name, file, mediaType, _) => + s"Multipart(name=${name.name}, file=${file.getCanonicalPath}, mediaType=${mediaType.show})" + case HttpRequest.MultipartData.Url(name, url, mediaType, _) => + s"Multipart(name=${name.name}, url=${url.toString}, mediaType=${mediaType.show})" + } + + } s"HttpRequest(method=${httpRequest.httpMethod.show}, url=${httpRequest.uri.uri}, headers=$headerString, params=$paramsString, body=$bodyString)" } @@ -90,62 +107,126 @@ object HttpRequest { import org.http4s.dsl.request._ @SuppressWarnings(Array("org.wartremover.warts.Any", "org.wartremover.warts.Nothing")) - def toHttp4s[F[_]: Applicative]( + def toHttp4s[F[_]: Applicative: Sync: ContextShift]( httpRequest: HttpRequest ): Either[HttpError, F[Request[F]]] = - httpRequest.uri.toHttp4s.map { uri => + httpRequest.uri.toHttp4s.flatMap { uri => val http4sHeaders = httpRequest.headers.map(_.toHttp4s) + val uriWithParams = + httpRequest.params match { + case Nil => + uri + case params => + params.foldLeft(uri) { (uri, param) => + uri.withQueryParam(param.param._1, param.param._2) + } + } + println(s""" + |uriWithParams: ${uriWithParams.toString} + |""".stripMargin) httpRequest.httpMethod match { - case Get => + case HttpRequest.Method.Get => httpRequest .body - .fold(GET.apply(uri, http4sHeaders: _*))( - GET.apply( - _, - uri, - http4sHeaders: _* - ) - ) - case Post => + .fold(GET.apply(uriWithParams, http4sHeaders: _*).asRight[HttpError]) { + case HttpRequest.Body.Json(json) => + GET + .apply( + json, + uriWithParams, + http4sHeaders: _* + ) + .asRight[HttpError] + + case HttpRequest.Body.Multipart(_) => + HttpError.methodUnsupportedForMultipart(httpRequest).asLeft[F[Request[F]]] + } + case HttpRequest.Method.Post => httpRequest .body - .fold(POST.apply(uri, http4sHeaders: _*))( - POST.apply( - _, - uri, - http4sHeaders: _* - ) - ) - case Put => + .fold(POST.apply(uriWithParams, http4sHeaders: _*).asRight[HttpError]) { + case HttpRequest.Body.Json(json) => + POST + .apply( + json, + uriWithParams, + http4sHeaders: _* + ) + .asRight[HttpError] + + case HttpRequest.Body.Multipart(multipartData) => + val body = multipartData.toHttp4s[F] + POST + .apply( + body, + uriWithParams, + http4sHeaders: _* + ) + .map(req => + req.withHeaders( + Headers( + req.headers.toList ++ body + .headers + .toList + .filterNot(header => + /* Without this filtering, the headers contain "Transfer-Encoding: chunked" + * which causes [400, Bad Content-Length] when uploading a release asset file using GitHub API + */ + header.name === CaseInsensitiveString("Transfer-Encoding") + ) + ) + ) + ) + .asRight[HttpError] + } + case HttpRequest.Method.Put => httpRequest .body - .fold(PUT.apply(uri, http4sHeaders: _*))( - PUT.apply( - _, - uri, - http4sHeaders: _* - ) - ) - case Patch => + .fold(PUT.apply(uriWithParams, http4sHeaders: _*).asRight[HttpError]) { + case HttpRequest.Body.Json(json) => + PUT + .apply( + json, + uriWithParams, + http4sHeaders: _* + ) + .asRight[HttpError] + + case HttpRequest.Body.Multipart(_) => + HttpError.methodUnsupportedForMultipart(httpRequest).asLeft[F[Request[F]]] + } + case HttpRequest.Method.Patch => httpRequest .body - .fold(PATCH.apply(uri, http4sHeaders: _*))( - PATCH.apply( - _, - uri, - http4sHeaders: _* - ) - ) - case Delete => + .fold(PATCH.apply(uriWithParams, http4sHeaders: _*).asRight[HttpError]) { + case HttpRequest.Body.Json(json) => + PATCH + .apply( + json, + uriWithParams, + http4sHeaders: _* + ) + .asRight[HttpError] + + case HttpRequest.Body.Multipart(_) => + HttpError.methodUnsupportedForMultipart(httpRequest).asLeft[F[Request[F]]] + } + case HttpRequest.Method.Delete => httpRequest .body - .fold(DELETE.apply(uri, http4sHeaders: _*))( - DELETE.apply( - _, - uri, - http4sHeaders: _* - ) - ) + .fold(DELETE.apply(uriWithParams, http4sHeaders: _*).asRight[HttpError]) { + case HttpRequest.Body.Json(json) => + DELETE + .apply( + json, + uriWithParams, + http4sHeaders: _* + ) + .asRight[HttpError] + + case HttpRequest.Body.Multipart(multipartData) => + HttpError.methodUnsupportedForMultipart(httpRequest).asLeft[F[Request[F]]] + } } } @@ -162,21 +243,112 @@ object HttpRequest { @newtype case class Param(param: (String, String)) + sealed trait Body + + object Body { + final case class Json(json: io.circe.Json) extends Body + final case class Multipart(multipartData: MultipartData) extends Body + + def json(json: io.circe.Json): Body = Json(json) + + def multipart(multipartData: MultipartData): Body = Multipart(multipartData) + } + + sealed trait MultipartData + + object MultipartData { + final case class File( + name: Name, + file: java.io.File, + mediaTypes: List[MediaType], + blocker: Blocker, + ) extends MultipartData + + final case class Url( + name: Name, + url: URL, + mediaTypes: List[MediaType], + blocker: Blocker, + ) extends MultipartData + + def file( + name: Name, + file: java.io.File, + mediaTypes: List[MediaType], + blocker: Blocker, + ): MultipartData = + File(name, file, mediaTypes, blocker) + + def url( + name: Name, + url: URL, + mediaTypes: List[MediaType], + blocker: Blocker, + ): MultipartData = Url(name, url, mediaTypes, blocker) + + @newtype case class Name(name: String) + +// import org.http4s.headers._ + import org.http4s.multipart.{Part, Multipart => Http4sMultipart} + + implicit final class MultipartDataOps(val multipartData: MultipartData) extends AnyVal { + def toHttp4s[F[_]: Sync: ContextShift]: Http4sMultipart[F] = + MultipartData.toHttp4s(multipartData) + } + + def toHttp4s[F[_]: Sync: ContextShift](multipartData: MultipartData): Http4sMultipart[F] = + Http4sMultipart[F]( + multipartData match { + case File(name, file, mediaTypes, blocker) => + Vector( + Part.fileData( + name.name, + file, + blocker, + mediaTypes.map(`Content-Type`(_)): _* + ) + ) + + case Url(name, url, mediaTypes, blocker) => + Vector( + Part.fileData( + name.name, + url, + blocker, + mediaTypes.map(`Content-Type`(_)): _* + ) + ) + + } + ) + } + implicit final class HttpRequestOps(val httpRequest: HttpRequest) extends AnyVal { def withHeader(header: Header): HttpRequest = httpRequest.copy(headers = httpRequest.headers :+ header) + + def toHttp4s[F[_]: Applicative: Sync: ContextShift]: Either[HttpError, F[Request[F]]] = + HttpRequest.toHttp4s[F](httpRequest) + + def isBodyMultipart: Boolean = + httpRequest.body match { + case Some(HttpRequest.Body.Multipart(_)) => + true + case _ => + false + } } def withParams(httpMethod: Method, uri: Uri, params: List[Param]): HttpRequest = - HttpRequest(httpMethod, uri, List.empty[Header], params, none[Json]) + HttpRequest(httpMethod, uri, List.empty[Header], params, none[HttpRequest.Body]) - def withBody(httpMethod: Method, uri: Uri, body: Json): HttpRequest = + def withBody(httpMethod: Method, uri: Uri, body: HttpRequest.Body): HttpRequest = HttpRequest(httpMethod, uri, List.empty[Header], List.empty[Param], body.some) def withHeaders(httpMethod: Method, uri: Uri, headers: List[Header]): HttpRequest = - HttpRequest(httpMethod, uri, headers, List.empty[Param], none[Json]) + HttpRequest(httpMethod, uri, headers, List.empty[Param], none[HttpRequest.Body]) - def withHeadersAndBody[A: Encoder]( + def withHeadersAndJsonBody[A: Encoder]( httpMethod: Method, uri: Uri, headers: List[Header], @@ -187,10 +359,25 @@ object HttpRequest { uri, headers, List.empty[Param], - Encoder[A].apply(body).some, + HttpRequest.Body.json(Encoder[A].apply(body)).some, + ) + + def withHeadersParamsAndMultipartBody( + httpMethod: Method, + uri: Uri, + headers: List[Header], + params: List[Param], + multipartData: MultipartData, + ): HttpRequest = + HttpRequest( + httpMethod, + uri, + headers, + params, + HttpRequest.Body.multipart(multipartData).some, ) def withoutBody(httpMethod: Method, uri: Uri): HttpRequest = - HttpRequest(httpMethod, uri, List.empty[Header], List.empty[Param], none[Json]) + HttpRequest(httpMethod, uri, List.empty[Header], List.empty[Param], none[HttpRequest.Body]) }