Skip to content

Commit

Permalink
Implement GMavenService (#6644)
Browse files Browse the repository at this point in the history
Per [b/392134866](https://b.corp.google.com/issues/392134866),

This implements a centralized interface for communicating with GMaven
called `GMavenService`. This service implements the gradle build-service
interface to provide proper parallel access, and keeps local
`ConcurrentHashMap` instances to cache responses.

Cached responses are on a _per-build_ basis, to avoid improper caching
of dynamic data. That is, given any build- **all** tasks that utilize
`GMavenService` will share the responses from GMaven; even within a
parallel environment. But if the tasks are considered out-of-date and
are ran again, then new requests will be made to the GMaven backend.

Tests and documentation are provided for everything added as well.

Note that while this PR _implements_ `GMavenService`- it does _not_
refactor the existing `GMavenHelper` and `RepositoryClient` usages to
use it. That will occur in subsequent PRs, as to avoid polluting this
PR. Furthermore, while there are no tests for `PomElement` directly- in
a future PR that includes tests for bom generation, `PomElement` will be
tested as a by-product.

This PR also fixes the following:

- [b/392135224](https://b.corp.google.com/issues/392135224) -> Implement
centralized pom datamodel
  • Loading branch information
daymxn authored Jan 28, 2025
1 parent 3f23c5f commit b17d041
Show file tree
Hide file tree
Showing 13 changed files with 1,488 additions and 7 deletions.
7 changes: 7 additions & 0 deletions plugins/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,18 @@ dependencies {
implementation("com.google.code.gson:gson:2.8.9")
implementation(libs.android.gradlePlugin.gradle)
implementation(libs.android.gradlePlugin.builder.test.api)
implementation("io.github.pdvrieze.xmlutil:serialization-jvm:0.90.3") {
exclude("org.jetbrains.kotlinx", "kotlinx-serialization-json")
exclude("org.jetbrains.kotlinx", "kotlinx-serialization-core")
}

testImplementation(gradleTestKit())
testImplementation(libs.bundles.kotest)
testImplementation(libs.mockk)
testImplementation(libs.junit)
testImplementation(libs.truth)
testImplementation("commons-io:commons-io:2.15.1")
testImplementation(kotlin("test"))
}

gradlePlugin {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package com.google.firebase.gradle.plugins

import com.android.build.gradle.LibraryExtension
import com.google.firebase.gradle.plugins.ci.Coverage
import com.google.firebase.gradle.plugins.services.GMavenService
import java.io.File
import java.nio.file.Paths
import org.gradle.api.Plugin
Expand Down Expand Up @@ -52,6 +53,7 @@ import org.w3c.dom.Element
abstract class BaseFirebaseLibraryPlugin : Plugin<Project> {
protected fun setupDefaults(project: Project, library: FirebaseLibraryExtension) {
with(library) {
project.gradle.sharedServices.registerIfAbsent<GMavenService, _>("gmaven")
previewMode.convention("")
publishJavadoc.convention(true)
artifactId.convention(project.name)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@ import org.gradle.api.attributes.Attribute
import org.gradle.api.attributes.AttributeContainer
import org.gradle.api.plugins.PluginManager
import org.gradle.api.provider.Provider
import org.gradle.api.services.BuildService
import org.gradle.api.services.BuildServiceParameters
import org.gradle.api.services.BuildServiceRegistry
import org.gradle.api.services.BuildServiceSpec
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.provideDelegate
import org.gradle.workers.WorkAction
import org.gradle.workers.WorkParameters
import org.gradle.workers.WorkQueue
Expand Down Expand Up @@ -244,3 +247,19 @@ fun LibraryAndroidComponentsExtension.onReleaseVariants(
) {
onVariants(selector().withBuildType("release"), callback)
}

/**
* Register a build service under the specified [name], if it hasn't been registered already.
*
* ```
* project.gradle.sharedServices.registerIfAbsent<GMavenService, _>("gmaven")
* ```
*
* @param T The build service class to register
* @param P The parameters class for the build service to register
* @param name The name to register the build service under
* @param config An optional configuration block to setup the build service with
*/
inline fun <reified T : BuildService<P>, reified P : BuildServiceParameters> BuildServiceRegistry
.registerIfAbsent(name: String, noinline config: BuildServiceSpec<P>.() -> Unit = {}) =
registerIfAbsent(name, T::class.java, config)
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
package com.google.firebase.gradle.plugins

import java.io.File
import java.io.InputStream
import org.w3c.dom.Element
import org.w3c.dom.Node
import org.w3c.dom.NodeList

/** Replaces all matching substrings with an empty string (nothing) */
Expand Down Expand Up @@ -124,6 +126,13 @@ fun Element.findOrCreate(tag: String): Element =
fun Element.findElementsByTag(tag: String) =
getElementsByTagName(tag).children().mapNotNull { it as? Element }

/**
* Returns the text of an attribute, if it exists.
*
* @param name The name of the attribute to get the text for
*/
fun Node.textByAttributeOrNull(name: String) = attributes?.getNamedItem(name)?.textContent

/**
* Yields the items of this [NodeList] as a [Sequence].
*
Expand Down Expand Up @@ -267,6 +276,19 @@ infix fun <T> List<T>.diff(other: List<T>): List<Pair<T?, T?>> {
*/
fun <T> List<T>.coerceToSize(targetSize: Int) = List(targetSize) { getOrNull(it) }

/**
* Writes the [InputStream] to this file.
*
* While this method _does_ close the generated output stream, it's the callers responsibility to
* close the passed [stream].
*
* @return This [File] instance for chaining.
*/
fun File.writeStream(stream: InputStream): File {
outputStream().use { stream.copyTo(it) }
return this
}

/**
* The [path][File.path] represented as a qualified unix path.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ enum class PreReleaseVersionType {
* Where `Type` is a case insensitive string of any [PreReleaseVersionType], and `Build` is a two
* digit number (single digits should have a leading zero).
*
* Note that `build` will always be present as starting at one by defalt. That is, the following
* Note that `build` will always be present as starting at one by default. That is, the following
* transform occurs:
* ```
* "12.13.1-beta" // 12.13.1-beta01
Expand Down Expand Up @@ -92,7 +92,7 @@ data class PreReleaseVersion(val type: PreReleaseVersionType, val build: Int = 1
*/
fun fromStringsOrNull(type: String, build: String): PreReleaseVersion? =
runCatching {
val preType = PreReleaseVersionType.valueOf(type.toUpperCase())
val preType = PreReleaseVersionType.valueOf(type.uppercase())
val buildNumber = build.takeUnless { it.isBlank() }?.toInt() ?: 1

PreReleaseVersion(preType, buildNumber)
Expand All @@ -115,7 +115,7 @@ data class PreReleaseVersion(val type: PreReleaseVersionType, val build: Int = 1
* PreReleaseVersion(RC, 12).toString() // "rc12"
* ```
*/
override fun toString() = "${type.name.toLowerCase()}${build.toString().padStart(2, '0')}"
override fun toString() = "${type.name.lowercase()}${build.toString().padStart(2, '0')}"
}

/**
Expand All @@ -140,7 +140,7 @@ data class ModuleVersion(
) : Comparable<ModuleVersion> {

/** Formatted as `MAJOR.MINOR.PATCH-PRE` */
override fun toString() = "$major.$minor.$patch${pre?.let { "-${it.toString()}" } ?: ""}"
override fun toString() = "$major.$minor.$patch${pre?.let { "-$it" } ?: ""}"

override fun compareTo(other: ModuleVersion) =
compareValuesBy(
Expand All @@ -149,7 +149,7 @@ data class ModuleVersion(
{ it.major },
{ it.minor },
{ it.patch },
{ it.pre == null }, // a version with no prerelease version takes precedence
{ it.pre == null }, // a version with no pre-release version takes precedence
{ it.pre },
)

Expand All @@ -176,7 +176,7 @@ data class ModuleVersion(
* ```
*/
val VERSION_REGEX =
"(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)(?:\\-\\b)?(?<pre>\\w\\D+)?(?<build>\\B\\d+)?"
"(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)(?:-\\b)?(?<pre>\\w\\D+)?(?<build>\\B\\d+)?"
.toRegex()

/**
Expand Down Expand Up @@ -209,6 +209,29 @@ data class ModuleVersion(
}
}
.getOrNull()

/**
* Parse a [ModuleVersion] from a string.
*
* You should use [fromStringOrNull] when you don't know the `artifactId` of the corresponding
* artifact, if you don't need to throw on failure, or if you need to throw a more specific
* message.
*
* This method exists to cover the common ground of getting [ModuleVersion] representations of
* artifacts.
*
* @param artifactId The artifact that this version belongs to. Will be used in the error
* message on failure.
* @param version The version to parse into a [ModuleVersion].
* @return A [ModuleVersion] created from the string.
* @throws IllegalArgumentException If the string doesn't represent a valid semver version.
* @see fromStringOrNull
*/
fun fromString(artifactId: String, version: String): ModuleVersion =
fromStringOrNull(version)
?: throw IllegalArgumentException(
"Invalid module version found for '${artifactId}': $version"
)
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/*
* Copyright 2025 Google LLC
*
* 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
*
* http://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.
*/

package com.google.firebase.gradle.plugins.datamodels

import com.google.firebase.gradle.plugins.ModuleVersion
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.newReader
import nl.adaptivity.xmlutil.serialization.XML
import nl.adaptivity.xmlutil.serialization.XmlChildrenName
import nl.adaptivity.xmlutil.serialization.XmlElement
import nl.adaptivity.xmlutil.serialization.XmlSerialName
import nl.adaptivity.xmlutil.xmlStreaming
import org.w3c.dom.Element

/**
* Representation of a `<license />` element in a a pom file.
*
* @see PomElement
*/
@Serializable
@XmlSerialName("license")
data class LicenseElement(
@XmlElement val name: String,
@XmlElement val url: String? = null,
@XmlElement val distribution: String? = null,
)

/**
* Representation of an `<scm />` element in a a pom file.
*
* @see PomElement
*/
@Serializable
@XmlSerialName("scm")
data class SourceControlManagement(@XmlElement val connection: String, @XmlElement val url: String)

/**
* Representation of a `<dependency />` element in a pom file.
*
* @see PomElement
*/
@Serializable
@XmlSerialName("dependency")
data class ArtifactDependency(
@XmlElement val groupId: String,
@XmlElement val artifactId: String,
// Can be null if the artifact derives its version from a bom
@XmlElement val version: String? = null,
@XmlElement val type: String? = null,
@XmlElement val scope: String? = null,
) {
/**
* Returns the artifact dependency as a a gradle dependency string.
*
* ```
* implementation("com.google.firebase:firebase-firestore:1.0.0")
* ```
*
* @see configuration
* @see simpleDepString
*/
override fun toString() = "$configuration(\"$simpleDepString\")"
}

/**
* The artifact type of this dependency, or the default inferred by gradle.
*
* We use a separate variable instead of inferring the default in the constructor so we can
* serialize instances of [ArtifactDependency] that should specifically _not_ have a type in the
* output (like in [DependencyManagementElement] instances).
*/
val ArtifactDependency.typeOrDefault: String
get() = type ?: "jar"

/**
* The artifact scope of this dependency, or the default inferred by gradle.
*
* We use a separate variable instead of inferring the default in the constructor so we can
* serialize instances of [ArtifactDependency] that should specifically _not_ have a scope in the
* output (like in [DependencyManagementElement] instances).
*/
val ArtifactDependency.scopeOrDefault: String
get() = scope ?: "compile"

/**
* The [version][ArtifactDependency.version] represented as a [ModuleVersion].
*
* @throws RuntimeException if the version isn't valid semver, or it's missing.
*/
val ArtifactDependency.moduleVersion: ModuleVersion
get() =
version?.let { ModuleVersion.fromString(artifactId, it) }
?: throw RuntimeException(
"Missing required version property for artifact dependency: $artifactId"
)

/**
* The fully qualified name of the artifact.
*
* Shorthand for:
* ```
* "${artifact.groupId}:${artifact.artifactId}"
* ```
*/
val ArtifactDependency.fullArtifactName: String
get() = "$groupId:$artifactId"

/**
* A string representing the dependency as a maven artifact marker.
*
* ```
* "com.google.firebase:firebase-common:21.0.0"
* ```
*/
val ArtifactDependency.simpleDepString: String
get() = "$fullArtifactName${version?.let { ":$it" } ?: ""}"

/** The gradle configuration that this dependency would apply to (eg; `api` or `implementation`). */
val ArtifactDependency.configuration: String
get() = if (scopeOrDefault == "compile") "api" else "implementation"

@Serializable
@XmlSerialName("dependencyManagement")
data class DependencyManagementElement(
@XmlChildrenName("dependency") val dependencies: List<ArtifactDependency>? = null
)

/** Representation of a `<project />` element within a `pom.xml` file. */
@Serializable
@XmlSerialName("project")
data class PomElement(
@XmlSerialName("xmlns") val namespace: String? = null,
@XmlSerialName("xmlns:xsi") val schema: String? = null,
@XmlSerialName("xsi:schemaLocation") val schemaLocation: String? = null,
@XmlElement val modelVersion: String,
@XmlElement val groupId: String,
@XmlElement val artifactId: String,
@XmlElement val version: String,
@XmlElement val packaging: String? = null,
@XmlChildrenName("licenses") val licenses: List<LicenseElement>? = null,
@XmlElement val scm: SourceControlManagement? = null,
@XmlElement val dependencyManagement: DependencyManagementElement? = null,
@XmlChildrenName("dependency") val dependencies: List<ArtifactDependency>? = null,
) {
/**
* Serializes this pom element into a valid XML element and saves it to the specified [file].
*
* @param file Where to save the serialized pom to
* @return The provided file, for chaining purposes.
* @see fromFile
*/
fun toFile(file: File): File {
val xmlWriter = XML {
indent = 2
xmlDeclMode = XmlDeclMode.None
}
file.writeText(xmlWriter.encodeToString(this))
return file
}

companion object {
/**
* Deserializes a [PomElement] from a `pom.xml` file.
*
* @param file The file that contains the pom element.
* @return The deserialized [PomElement]
* @see toFile
* @see fromElement
*/
fun fromFile(file: File): PomElement =
fromElement(
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file).documentElement
)

/**
* Deserializes a [PomElement] from a document [Element].
*
* @param element The HTML element representing the pom element.
* @return The deserialized [PomElement]
* @see fromFile
*/
fun fromElement(element: Element): PomElement =
XML.decodeFromReader(xmlStreaming.newReader(element))
}
}
Loading

0 comments on commit b17d041

Please sign in to comment.