diff --git a/clients/bazel-module-registry/build.gradle.kts b/clients/bazel-module-registry/build.gradle.kts new file mode 100644 index 0000000000000..5ad163c57efca --- /dev/null +++ b/clients/bazel-module-registry/build.gradle.kts @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + // Apply precompiled plugins. + id("ort-library-conventions") + + // Apply third-party plugins. + alias(libs.plugins.kotlinSerialization) +} + +dependencies { + api(projects.model) + api(libs.okhttp) + api(libs.retrofit) + + implementation(libs.bundles.kotlinxSerialization) + implementation(libs.retrofit.converter.kotlinxSerialization) +} + +tasks.withType().configureEach { + val customCompilerArgs = listOf( + "-opt-in=kotlinx.serialization.ExperimentalSerializationApi" + ) + + compilerOptions { + freeCompilerArgs.addAll(customCompilerArgs) + } +} diff --git a/clients/bazel-module-registry/src/funTest/kotlin/BazelModuleRegistryServiceFunTest.kt b/clients/bazel-module-registry/src/funTest/kotlin/BazelModuleRegistryServiceFunTest.kt new file mode 100644 index 0000000000000..946b65f3f05db --- /dev/null +++ b/clients/bazel-module-registry/src/funTest/kotlin/BazelModuleRegistryServiceFunTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2021 The ORT Project Authors (see ) + * + * 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.clients.bazelmoduleregistry + +import io.kotest.core.spec.style.WordSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.comparables.shouldBeGreaterThan +import io.kotest.matchers.shouldBe + +import org.ossreviewtoolkit.model.HashAlgorithm + +class BazelModuleRegistryServiceTest : WordSpec({ + val service = BazelModuleRegistryService.create() + val repoUrl = "https://github.com/google/glog" + + "getModuleMetadata" should { + "include a homepage URL, non-empty VCS info and version 0.5.0 for the 'glog' module" { + val metadata = service.getModuleMetadata("glog") + + metadata.homepage.toString() shouldBe repoUrl + metadata.vcsInfo().url shouldBe repoUrl + metadata.versions.size shouldBeGreaterThan 1 + metadata.versions shouldContain "0.5.0" + } + } + + "getModuleSource" should { + "return non-empty remote artifact info" { + val sourceInfo = service.getModuleSourceInfo("glog", "0.5.0") + + sourceInfo.remoteArtifact().url shouldBe "${repoUrl}/archive/refs/tags/v0.5.0.tar.gz" + sourceInfo.remoteArtifact().hash.algorithm shouldBe HashAlgorithm.SHA256 + } + } +}) diff --git a/clients/bazel-module-registry/src/main/kotlin/BazelModuleRegistryService.kt b/clients/bazel-module-registry/src/main/kotlin/BazelModuleRegistryService.kt new file mode 100644 index 0000000000000..ec197e0b73d06 --- /dev/null +++ b/clients/bazel-module-registry/src/main/kotlin/BazelModuleRegistryService.kt @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2020 The ORT Project Authors (see ) + * + * 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.clients.bazelmoduleregistry + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory + +import java.net.URI +import java.util.Base64 + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.Serializer +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNamingStrategy + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient + +import retrofit2.Retrofit +import retrofit2.http.GET +import retrofit2.http.Path + +import org.ossreviewtoolkit.model.Hash +import org.ossreviewtoolkit.model.HashAlgorithm +import org.ossreviewtoolkit.model.RemoteArtifact +import org.ossreviewtoolkit.model.VcsInfo +import org.ossreviewtoolkit.model.VcsType + +@Serializer(URI::class) +object URISerializer : KSerializer { + override fun serialize(encoder: Encoder, value: URI) = encoder.encodeString(value.toString()) + override fun deserialize(decoder: Decoder) = URI(decoder.decodeString()) +} + +/** + * Interface for a Bazel Module Registry, based on the directory structure of https://bcr.bazel.build + * and the git repository it is based on (https://github.com/bazelbuild/bazel-central-registry/). + */ +interface BazelModuleRegistryService { + companion object { + /** + * The JSON (de-)serialization object used by this service. + */ + private val JSON = Json { + ignoreUnknownKeys = true + namingStrategy = JsonNamingStrategy.SnakeCase + } + + /** + * The service uses the Bazel Central Registry by default. + */ + const val DEFAULT_URL = "https://bcr.bazel.build" + + /** + * Create a Bazel Module Registry service instance for communicating with a server running at the given [url], + * defaulting to the Bazel Central Registry, optionally with a pre-built OkHttp [client]. + */ + fun create( + url: String = DEFAULT_URL, + client: OkHttpClient? = null + ): BazelModuleRegistryService { + val bmrClient = client ?: OkHttpClient() + + val contentType = "application/json".toMediaType() + val retrofit = Retrofit.Builder() + .client(bmrClient) + .baseUrl(url) + .addConverterFactory(JSON.asConverterFactory(contentType)) + .build() + + return retrofit.create(BazelModuleRegistryService::class.java) + } + } + + @Serializable + data class ModuleMetadata( + @Serializable(URISerializer::class) val homepage: URI?, + val maintainers: List? = null, + val repository: List? = null, + val versions: List, + // The key in the map is the version, the value the reason for yanking it. + val yankedVersions: Map, + ) { + fun vcsInfo(): VcsInfo { + if (repository.isNullOrEmpty()) { + return VcsInfo.EMPTY + } + + val repo = repository[0] + // From looking at all current values of this field on BCR, it looks like only the special value "github:" + // exists. Otherwise it's just the repo URL. + val url = if (repo.startsWith("github:")) { + val path = repo.substringAfter("github:") + "https://github.com/${path}" + } else { + repo + } + + return VcsInfo( + type = VcsType.GIT, + url = url, + revision = "", + path = "", + ) + } + } + + @Serializable + data class ModuleMaintainer( + val email: String? = null, + val name: String? = null + ) + + @Serializable + data class ModuleSource( + val integrity: String, + val patchStrip: Int? = null, + val patches: Map? = null, + val stripPrefix: String? = null, + @Serializable(URISerializer::class) val url: URI, + ) { + fun remoteArtifact() = RemoteArtifact(url = url.toString(), hash = hash()) + + private fun hash(): Hash { + fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) } + + val (algo, b64digest) = integrity.split("-", limit = 2) + val digest = Base64.getDecoder().decode(b64digest).toHex() + + return Hash( + value = digest, + algorithm = HashAlgorithm.fromString(algo), + ) + } + } + + /** + * Retrieve the metadata for a module. + * E.g. https://bcr.bazel.build/modules/glog/metadata.json. + */ + @GET("modules/{name}/metadata.json") + suspend fun getModuleMetadata(@Path("name") name: String): ModuleMetadata + + /** + * Retrieve information about the source code for a specific version of a module. + * E.g. https://bcr.bazel.build/modules/glog/0.5.0/source.json. + */ + @GET("modules/{name}/{version}/source.json") + suspend fun getModuleSourceInfo(@Path("name") name: String, @Path("version") version: String): ModuleSource +} diff --git a/settings.gradle.kts b/settings.gradle.kts index ed8997b62b03d..cef54ad5d32fe 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,6 +26,7 @@ rootProject.name = "oss-review-toolkit" include(":advisor") include(":analyzer") include(":cli") +include(":clients:bazel-module-registry") include(":clients:clearly-defined") include(":clients:fossid-webapp") include(":clients:github-graphql") @@ -48,6 +49,7 @@ include(":utils:scripting") include(":utils:spdx") include(":utils:test") +project(":clients:bazel-module-registry").name = "bazel-module-registry-client" project(":clients:clearly-defined").name = "clearly-defined-client" project(":clients:fossid-webapp").name = "fossid-webapp-client" project(":clients:github-graphql").name = "github-graphql-client"