From 3e6872ace8c10d4d61ac63f8656aeb254f35965e Mon Sep 17 00:00:00 2001 From: Kelly Robinson Date: Sun, 10 Mar 2024 03:53:59 -0700 Subject: [PATCH] First draft of Sonatype Central Portal API client (#724) * related to https://github.com/vanniktech/gradle-maven-publish-plugin/issues/722 initial implementation of a (not fully tested) client for the portal api * related to https://github.com/vanniktech/gradle-maven-publish-plugin/issues/722 move api to separate module * remove API which fails the build --------- Co-authored-by: Gabriel Ittner --- central-portal/build.gradle.kts | 12 ++ central-portal/gradle.properties | 3 + .../publish/portal/SonatypeCentralPortal.kt | 132 ++++++++++++++++++ .../portal/SonatypeCentralPortalModel.kt | 50 +++++++ .../SonatypeCentralPortalOkHttpInterceptor.kt | 20 +++ .../portal/SonatypeCentralPortalService.kt | 61 ++++++++ settings.gradle.kts | 1 + 7 files changed, 279 insertions(+) create mode 100644 central-portal/build.gradle.kts create mode 100644 central-portal/gradle.properties create mode 100644 central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortal.kt create mode 100644 central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortalModel.kt create mode 100644 central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortalOkHttpInterceptor.kt create mode 100644 central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortalService.kt diff --git a/central-portal/build.gradle.kts b/central-portal/build.gradle.kts new file mode 100644 index 00000000..cede62a5 --- /dev/null +++ b/central-portal/build.gradle.kts @@ -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) +} diff --git a/central-portal/gradle.properties b/central-portal/gradle.properties new file mode 100644 index 00000000..9be55128 --- /dev/null +++ b/central-portal/gradle.properties @@ -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 diff --git a/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortal.kt b/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortal.kt new file mode 100644 index 00000000..7cd348f2 --- /dev/null +++ b/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortal.kt @@ -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()}") + } + } +} diff --git a/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortalModel.kt b/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortalModel.kt new file mode 100644 index 00000000..00eeb9b7 --- /dev/null +++ b/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortalModel.kt @@ -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, + val pathStarting: String, +) + +@JsonClass(generateAdapter = true) +internal data class DeployedComponentVersion( + val name: String, + val path: String, + val errors: List, +) + +@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, + val deployedComponentVersions: List, +) + +@JsonClass(generateAdapter = true) +internal data class DeploymentStatus( + val deploymentId: String, + val deploymentName: String, + val deploymentState: String, + val purls: List, + val errors: List, +) + +@JsonClass(generateAdapter = true) +internal data class DeploymentResponseFiles( + val deployments: List, + val page: Int, + val pageSize: Int, + val pageCount: Int, + val totalResultCount: Int, +) diff --git a/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortalOkHttpInterceptor.kt b/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortalOkHttpInterceptor.kt new file mode 100644 index 00000000..e87ba210 --- /dev/null +++ b/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortalOkHttpInterceptor.kt @@ -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()) + } +} diff --git a/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortalService.kt b/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortalService.kt new file mode 100644 index 00000000..5b2c0b13 --- /dev/null +++ b/central-portal/src/main/kotlin/com/vanniktech/maven/publish/portal/SonatypeCentralPortalService.kt @@ -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 + + @POST("publisher/deployment/{deploymentId}") + fun publishDeployment( + @Path("deploymentId") deploymentId: String, + ): Call + + @Streaming + @GET("publisher/deployment/download/{deploymentId}/{relativePath}") + suspend fun getDeploymentDownloadByIdAndPath( + @Path("deploymentId") deploymentId: String, + @Path("relativePath") relativePath: String, + ): Call + + @GET("publisher/published") + fun getPublished( + @Query("namespace") namespace: String, + @Query("name") name: String, + @Query("version") version: String, + ): Call + + @POST("publisher/status") + fun getStatus( + @Query("id") id: String, + ): Call + + @Multipart + @POST("publisher/upload") + fun uploadBundle( + @Query("name") name: String?, + @Query("publishingType") publishingType: String?, + @Body input: MultipartBody.Part, + ): Call + + @Streaming + @GET("publisher/deployments/download") + suspend fun getDeploymentDownload( + @Query("relativePath") relativePath: String, + ): Call +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 6b317ead..e4b6350e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,5 +22,6 @@ gradleEnterprise { include(":plugin") include(":nexus") +include(":central-portal") includeBuild("build-logic") enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")