Skip to content

Commit

Permalink
First draft of Sonatype Central Portal API client (#724)
Browse files Browse the repository at this point in the history
* related to #722
initial implementation of a (not fully tested) client for the portal api

* related to #722
move api to separate module

* remove API which fails the build

---------

Co-authored-by: Gabriel Ittner <[email protected]>
  • Loading branch information
kellyrob99 and gabrielittner authored Mar 10, 2024
1 parent 8b71655 commit 3e6872a
Show file tree
Hide file tree
Showing 7 changed files with 279 additions and 0 deletions.
12 changes: 12 additions & 0 deletions central-portal/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
plugins {
id("shared")
}

dependencies {
kapt(libs.moshi.codegen)

implementation(libs.okhttp)
implementation(libs.moshi)
implementation(libs.retrofit)
implementation(libs.retrofit.converter.moshi)
}
3 changes: 3 additions & 0 deletions central-portal/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
POM_ARTIFACT_ID=central-portal
POM_NAME=Sonatype related APIs and tasks
POM_DESCRIPTION=APIs and tasks to help working with the Sonatype Central Portal
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package com.vanniktech.maven.publish.portal

import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory

class SonatypeCentralPortal(
private val baseUrl: String,
private val usertoken: String,
userAgentName: String,
userAgentVersion: String,
okhttpTimeoutSeconds: Long,
private val closeTimeoutSeconds: Long,
) {
private val service by lazy {
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(SonatypeCentralPortalOkHttpInterceptor(usertoken, userAgentName, userAgentVersion))
.connectTimeout(okhttpTimeoutSeconds, TimeUnit.SECONDS)
.readTimeout(okhttpTimeoutSeconds, TimeUnit.SECONDS)
.writeTimeout(okhttpTimeoutSeconds, TimeUnit.SECONDS)
.build()
val retrofit = Retrofit.Builder()
.addConverterFactory(MoshiConverterFactory.create())
.client(okHttpClient)
.baseUrl(baseUrl)
.build()

retrofit.create(SonatypeCentralPortalService::class.java)
}

private fun deleteDeployment(deploymentId: String) {
val deleteDeploymentResponse = service.deleteDeployment(deploymentId).execute()
if (!deleteDeploymentResponse.isSuccessful) {
throw IOException(
"Failed to delete deploymentId $deploymentId code: ${deleteDeploymentResponse.code()} msg: ${
deleteDeploymentResponse.errorBody()?.string()
}",
)
}
}

private fun publishDeployment(deploymentId: String) {
val publishDeploymentResponse = service.publishDeployment(deploymentId).execute()
if (!publishDeploymentResponse.isSuccessful) {
throw IOException(
"Failed to delete deploymentId $deploymentId code: ${publishDeploymentResponse.code()} msg: ${
publishDeploymentResponse.errorBody()?.string()
}",
)
}
}

// trying logic outlined on: https://www.megumethod.com/blog/downloading-files-retrofit-library
private suspend fun getDeploymentDownloadByIdAndPath(deploymentId: String, relativePath: String, outputPath: String): File {
val destinationFile = File(outputPath)
val response = service.getDeploymentDownloadByIdAndPath(deploymentId, relativePath).execute()
if (response.isSuccessful) {
response.body()?.byteStream().use { inputStream ->
destinationFile.outputStream().use { outputStream ->
inputStream?.copyTo(outputStream)
}
}
} else {
throw IOException(
"Failed to retrieve content for $deploymentId on relativePath: $relativePath. msg: ${
response.errorBody()?.string()
}",
)
}
return destinationFile
}

private suspend fun getDeploymentDownload(relativePath: String, outputPath: String): File {
val destinationFile = File(outputPath)
val response = service.getDeploymentDownload(relativePath).execute()
if (response.isSuccessful) {
response.body()?.byteStream().use { inputStream ->
destinationFile.outputStream().use { outputStream ->
inputStream?.copyTo(outputStream)
}
}
} else {
throw IOException(
"Failed to retrieve content on relativePath: $relativePath. msg: ${
response.errorBody()?.string()
}",
)
}
return destinationFile
}

private fun getPublished(namespace: String, name: String, version: String): String? {
val stringResponse = service.getPublished(namespace, name, version).execute()
if (stringResponse.isSuccessful) {
return stringResponse.body()
} else {
throw IOException(
"Failed to get published status for $namespace:$name:$version. msg: ${
stringResponse.errorBody()?.string()
}",
)
}
}

private fun getStatus(deploymentId: String): DeploymentStatus {
val statusResponse = service.getStatus(deploymentId).execute()
if (statusResponse.isSuccessful) {
return statusResponse.body()!!
} else {
throw IOException("Failed to get status for $deploymentId. msg: ${statusResponse.errorBody()?.string()}")
}
}

private fun upload(name: String?, publishingType: String?, file: File): String {
val uploadFile: RequestBody = file.asRequestBody("application/octet-stream".toMediaType())
val multipart =
MultipartBody.Part.createFormData("bundle", file.getName(), uploadFile)
val uploadResponse = service.uploadBundle(name, publishingType, multipart).execute()
if (uploadResponse.isSuccessful) {
return uploadResponse.body()!!
} else {
throw IOException("Upload failed: ${uploadResponse.errorBody()?.string()}")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.vanniktech.maven.publish.portal

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
internal data class FileRequest(
val page: Int,
@Json(name = "size") val sizze: Int,
val sortField: String,
val sortDirection: String,
val deploymentIds: List<String>,
val pathStarting: String,
)

@JsonClass(generateAdapter = true)
internal data class DeployedComponentVersion(
val name: String,
val path: String,
val errors: List<String>,
)

@JsonClass(generateAdapter = true)
internal data class DeploymentResponseFile(
val deploymentId: String,
val deploymentName: String,
val deploymentState: String,
val deploymentType: String,
val createTimestamp: Long,
val purls: List<String>,
val deployedComponentVersions: List<DeployedComponentVersion>,
)

@JsonClass(generateAdapter = true)
internal data class DeploymentStatus(
val deploymentId: String,
val deploymentName: String,
val deploymentState: String,
val purls: List<String>,
val errors: List<String>,
)

@JsonClass(generateAdapter = true)
internal data class DeploymentResponseFiles(
val deployments: List<DeploymentResponseFile>,
val page: Int,
val pageSize: Int,
val pageCount: Int,
val totalResultCount: Int,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.vanniktech.maven.publish.portal

import okhttp3.Interceptor
import okhttp3.Response

internal class SonatypeCentralPortalOkHttpInterceptor(
private val usertoken: String,
private val userAgentName: String,
private val userAgentVersion: String,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val requestBuilder = chain.request().newBuilder()

requestBuilder.addHeader("Accept", "application/json") // request json by default, XML is returned else
requestBuilder.addHeader("Authorization", "UserToken $usertoken")
requestBuilder.addHeader("User-Agent", "$userAgentName/$userAgentVersion")

return chain.proceed(requestBuilder.build())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.vanniktech.maven.publish.portal

import okhttp3.MultipartBody
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming

/*
* Sonatype Central Portal Publishing based on https://central.sonatype.org/publish/publish-portal-api/
*/
internal interface SonatypeCentralPortalService {
@DELETE("publisher/deployment/{deploymentId}")
fun deleteDeployment(
@Path("deploymentId") deploymentId: String,
): Call<Unit>

@POST("publisher/deployment/{deploymentId}")
fun publishDeployment(
@Path("deploymentId") deploymentId: String,
): Call<Unit>

@Streaming
@GET("publisher/deployment/download/{deploymentId}/{relativePath}")
suspend fun getDeploymentDownloadByIdAndPath(
@Path("deploymentId") deploymentId: String,
@Path("relativePath") relativePath: String,
): Call<ResponseBody>

@GET("publisher/published")
fun getPublished(
@Query("namespace") namespace: String,
@Query("name") name: String,
@Query("version") version: String,
): Call<String>

@POST("publisher/status")
fun getStatus(
@Query("id") id: String,
): Call<DeploymentStatus>

@Multipart
@POST("publisher/upload")
fun uploadBundle(
@Query("name") name: String?,
@Query("publishingType") publishingType: String?,
@Body input: MultipartBody.Part,
): Call<String>

@Streaming
@GET("publisher/deployments/download")
suspend fun getDeploymentDownload(
@Query("relativePath") relativePath: String,
): Call<ResponseBody>
}
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ gradleEnterprise {

include(":plugin")
include(":nexus")
include(":central-portal")
includeBuild("build-logic")
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")

0 comments on commit 3e6872a

Please sign in to comment.