app/services/GitlabAPI.scala (103 lines of code) (raw):

package services import akka.actor.ActorSystem import akka.http.scaladsl.Http import akka.http.scaladsl.model.{ContentType, ContentTypes, HttpEntity, HttpHeader, HttpMethod, HttpMethods, HttpRequest, HttpResponse, StatusCode, StatusCodes} import akka.stream.Materializer import akka.stream.scaladsl.{Keep, Sink} import akka.util.ByteString import io.circe.syntax._ import io.circe.generic.auto._ import models.gitlab.MergeRequestState.MergeRequestState import models.gitlab.{Branch, GitlabProject, JobResponse, MergeRequest, PipelineResponse} import org.slf4j.LoggerFactory import play.api.Configuration import java.net.URLEncoder import java.nio.charset.StandardCharsets import javax.inject.{Inject, Singleton} import scala.concurrent.{ExecutionContext, Future} @Singleton class GitlabAPI @Inject() (config:Configuration)(implicit actorSystem: ActorSystem, materializer: Materializer) extends VCSAPI { private implicit val ec:ExecutionContext = actorSystem.dispatcher private val logger = LoggerFactory.getLogger(getClass) import HttpHelpers._ protected def http = Http() private val token:String = config.get[String]("gitlab.api-token") /** * prepend the base URL to the request. The "partial" url _must_ begin with a /. * @param partial partial url to append * @return the full url string */ def makeFullUrl(partial:String) = "https://gitlab.com/api/v4/projects" + partial /** * makes an HTTP request to the github API, setting the authorization and content types as appropriate * @param method Http method for the request * @param url request to send to * @param moreHeaders if any more headers are required, then put them into this map * @param bodyContent optional content to send. This object will be marshalled via Circe and sent as JSON, you must * ensure that appropriate codecs are in scope for Circe to perform this * @tparam O data type of the expected object response, content will be unmarshalled into this format. * @return if the request fails, then the Future fails with an HttpError(). If the request succeeds but the data read fails, * then the future completes with a Left containing the circe decoding error. If the request completes and the * data reads and unmarshals, then the future completes with a Right containing the decoded object of type O. */ protected def makeRequest[O:io.circe.Decoder](method:HttpMethod, url:String, moreHeaders:Map[String,String], bodyContent:Option[ByteString]) = { unmarshalContent[O](makeRequestRaw(method, url, moreHeaders, bodyContent)) } protected def findLocationHeader(from:Seq[HttpHeader]):Option[String] = from.find(_.lowercaseName() == "location").map(_.value()) protected def makeRequestRaw(method:HttpMethod, url:String, moreHeaders:Map[String,String], bodyContent:Option[ByteString]):Future[ByteString] = { logger.debug(s"${method} $url") val entity = bodyContent match { case None=> HttpEntity.Empty case Some(content)=> HttpEntity(content).withContentType(ContentTypes.`application/json`) } val baseHeaders = Map( "PRIVATE-TOKEN"->token, "Accept"->"application/json" ) val headers = (baseHeaders++moreHeaders) .map(kv=>HttpHeader.parse(kv._1, kv._2)) .collect({case HttpHeader.ParsingResult.Ok(hdr,_)=>hdr}) .toSeq val req = HttpRequest(method, url, headers, entity) http .singleRequest(req) .flatMap(response=>{ val contentFut = consumeResponseContent(response) if(response.status==StatusCodes.TemporaryRedirect || response.status==StatusCodes.PermanentRedirect || response.status==StatusCodes.Found) { findLocationHeader(response.headers) match { case Some(nextUrl)=> logger.debug(s"Following ${response.status} redirect to $nextUrl...") makeRequestRaw(method, nextUrl, moreHeaders, bodyContent) case None=> logger.error(s"Received ${response.status} with no Location header?? Got ${response.headers}") Future.failed(new RuntimeException("Received redirect with no location")) } } else if(response.status==StatusCodes.OK || response.status==StatusCodes.Created || response.status==StatusCodes.Accepted) { contentFut } else { contentFut.flatMap(bytes=>Future.failed(new HttpError(response.status, bytes.utf8String))) } }) } private def encodeParam(from:String):String = URLEncoder.encode(from, StandardCharsets.UTF_8) def listProjects = { makeRequest[Seq[GitlabProject]](HttpMethods.GET, makeFullUrl("?owned=true"), Map(), None) } def jobsForProject(projectId:String) = { makeRequest[Seq[JobResponse]](HttpMethods.GET, makeFullUrl(s"/$projectId/jobs?scope=success"), Map(), None) } def artifactsZipForBranch(projectId:String, branchName:String, jobName:String) = { makeRequestRaw(HttpMethods.GET, makeFullUrl(s"/$projectId/jobs/artifacts/${encodeParam(branchName)}/download?job=${encodeParam(jobName)}"), Map(), None ).map(Some.apply) } def branchesForProject(projectId:String) = { makeRequest[Seq[Branch]](HttpMethods.GET, makeFullUrl(s"/$projectId/repository/branches"), Map(), None) } def getOpenMergeRequests(projectId:String, forStatus:Option[MergeRequestState]) = { import models.gitlab.MergeRequestCodec._ val baseUrl = s"/$projectId/merge_requests" val finalUrl = forStatus match { case Some(status)=>baseUrl + s"?state=${encodeParam(status.toString)}" case None=>baseUrl } makeRequest[Seq[MergeRequest]](HttpMethods.GET, makeFullUrl(finalUrl), Map(), None) } }