Skip to content

Commit

Permalink
refactor(bower): Use data classes for deserializing the package list
Browse files Browse the repository at this point in the history
Signed-off-by: Frank Viernau <[email protected]>
  • Loading branch information
fviernau committed Jul 18, 2024
1 parent f7ff51a commit f4aa253
Show file tree
Hide file tree
Showing 3 changed files with 163 additions and 79 deletions.
2 changes: 2 additions & 0 deletions plugins/package-managers/bower/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ dependencies {
implementation(projects.utils.ortUtils)
implementation(projects.utils.spdxUtils)

implementation(libs.jackson.core)
implementation(libs.jackson.databind)
implementation(libs.jackson.module.kotlin)

funTestImplementation(testFixtures(projects.analyzer))
}
142 changes: 63 additions & 79 deletions plugins/package-managers/bower/src/main/kotlin/Bower.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@

package org.ossreviewtoolkit.plugins.packagemanagers.bower

import com.fasterxml.jackson.databind.JsonNode

import java.io.File
import java.util.Stack

Expand All @@ -41,13 +39,9 @@ import org.ossreviewtoolkit.model.VcsInfo
import org.ossreviewtoolkit.model.VcsType
import org.ossreviewtoolkit.model.config.AnalyzerConfiguration
import org.ossreviewtoolkit.model.config.RepositoryConfiguration
import org.ossreviewtoolkit.model.jsonMapper
import org.ossreviewtoolkit.utils.common.CommandLineTool
import org.ossreviewtoolkit.utils.common.Os
import org.ossreviewtoolkit.utils.common.fieldNamesOrEmpty
import org.ossreviewtoolkit.utils.common.fieldsOrEmpty
import org.ossreviewtoolkit.utils.common.stashDirectories
import org.ossreviewtoolkit.utils.common.textValueOrEmpty

import org.semver4j.RangesList
import org.semver4j.RangesListFactory
Expand Down Expand Up @@ -84,22 +78,23 @@ class Bower(
stashDirectories(workingDir.resolve("bower_components")).use {
installDependencies(workingDir)
val dependenciesJson = listDependencies(workingDir)
val rootNode = jsonMapper.readTree(dependenciesJson)
val packages = parsePackages(rootNode)
val packageInfo = parsePackageInfoJson(dependenciesJson)

val packages = parsePackages(packageInfo)
val dependenciesScope = Scope(
name = SCOPE_NAME_DEPENDENCIES,
dependencies = parseDependencyTree(rootNode, SCOPE_NAME_DEPENDENCIES)
dependencies = parseDependencyTree(packageInfo, SCOPE_NAME_DEPENDENCIES)
)
val devDependenciesScope = Scope(
name = SCOPE_NAME_DEV_DEPENDENCIES,
dependencies = parseDependencyTree(rootNode, SCOPE_NAME_DEV_DEPENDENCIES)
dependencies = parseDependencyTree(packageInfo, SCOPE_NAME_DEV_DEPENDENCIES)
)

val projectPackage = parsePackage(rootNode)
val projectPackage = parsePackage(packageInfo)
val project = Project(
id = projectPackage.id,
definitionFilePath = VersionControlSystem.getPathInfo(definitionFile).path,
authors = projectPackage.authors,
authors = parseAuthors(packageInfo),
declaredLicenses = projectPackage.declaredLicenses,
vcs = projectPackage.vcs,
vcsProcessed = processProjectVcs(workingDir, projectPackage.vcs, projectPackage.homepageUrl),
Expand All @@ -119,142 +114,124 @@ class Bower(
private const val SCOPE_NAME_DEPENDENCIES = "dependencies"
private const val SCOPE_NAME_DEV_DEPENDENCIES = "devDependencies"

private fun parsePackageId(node: JsonNode) =
private fun parsePackageId(info: PackageInfo) =
Identifier(
type = "Bower",
namespace = "",
name = node["pkgMeta"]["name"].textValueOrEmpty(),
version = node["pkgMeta"]["version"].textValueOrEmpty()
name = info.pkgMeta.name.orEmpty(),
version = info.pkgMeta.version.orEmpty()
)

private fun parseRepositoryType(node: JsonNode) =
VcsType.forName(node["pkgMeta"]["repository"]?.get("type").textValueOrEmpty())
private fun parseRepositoryType(info: PackageInfo) = VcsType.forName(info.pkgMeta.repository?.type.orEmpty())

private fun parseRepositoryUrl(node: JsonNode) =
node["pkgMeta"]["repository"]?.get("url")?.textValue()
?: node["pkgMeta"]["_source"].textValueOrEmpty()
private fun parseRepositoryUrl(info: PackageInfo) = info.pkgMeta.repository?.url ?: info.pkgMeta.source.orEmpty()

private fun parseRevision(node: JsonNode): String =
node["pkgMeta"]["_resolution"]?.get("commit")?.textValue()
?: node["pkgMeta"]["_resolution"]?.get("tag").textValueOrEmpty()
private fun parseRevision(info: PackageInfo): String =
info.pkgMeta.resolution?.commit ?: info.pkgMeta.resolution?.tag.orEmpty()

private fun parseVcsInfo(node: JsonNode) =
private fun parseVcsInfo(info: PackageInfo) =
VcsInfo(
type = parseRepositoryType(node),
url = parseRepositoryUrl(node),
revision = parseRevision(node)
type = parseRepositoryType(info),
url = parseRepositoryUrl(info),
revision = parseRevision(info)
)

private fun parseDeclaredLicenses(node: JsonNode): Set<String> =
buildSet {
val license = node["pkgMeta"]["license"].textValueOrEmpty()
if (license.isNotEmpty()) add(license)
}

/**
* Parse information about the author. According to https://github.com/bower/spec/blob/master/json.md#authors,
* there are two formats to specify the authors of a package (similar to NPM). The difference is that the
* strings or objects are inside an array.
*/
private fun parseAuthors(node: JsonNode): Set<String> =
buildSet {
node["pkgMeta"]["authors"]?.mapNotNull { authorNode ->
when {
authorNode.isObject -> authorNode["name"]?.textValue()
authorNode.isTextual -> parseAuthorString(authorNode.textValue(), '<', '(')
else -> null
}
}?.let { addAll(it) }
}
private fun parseAuthors(info: PackageInfo): Set<String> =
info.pkgMeta.authors.mapNotNullTo(mutableSetOf()) { parseAuthorString(it.name, '<', '(') }

private fun parsePackage(node: JsonNode) =
private fun parsePackage(info: PackageInfo) =
Package(
id = parsePackageId(node),
authors = parseAuthors(node),
declaredLicenses = parseDeclaredLicenses(node),
description = node["pkgMeta"]["description"].textValueOrEmpty(),
homepageUrl = node["pkgMeta"]["homepage"].textValueOrEmpty(),
id = parsePackageId(info),
authors = parseAuthors(info),
declaredLicenses = setOfNotNull(info.pkgMeta.license?.takeUnless { it.isEmpty() }),
description = info.pkgMeta.description.orEmpty(),
homepageUrl = info.pkgMeta.homepage.orEmpty(),
binaryArtifact = RemoteArtifact.EMPTY,
sourceArtifact = RemoteArtifact.EMPTY, // TODO: implement me!
vcs = parseVcsInfo(node)
vcs = parseVcsInfo(info)
)

private fun getDependencyNodes(node: JsonNode): Sequence<JsonNode> =
node["dependencies"].fieldsOrEmpty().asSequence().map { it.value }
private fun getDependencyInfos(info: PackageInfo): Sequence<PackageInfo> =
info.dependencies.asSequence().map { it.value }

private fun parsePackages(node: JsonNode): Map<String, Package> {
private fun parsePackages(info: PackageInfo): Map<String, Package> {
val result = mutableMapOf<String, Package>()

val stack = Stack<JsonNode>()
stack += getDependencyNodes(node)
val stack = Stack<PackageInfo>()
stack += getDependencyInfos(info)

while (!stack.empty()) {
val currentNode = stack.pop()
val pkg = parsePackage(currentNode)
val currentInfo = stack.pop()
val pkg = parsePackage(currentInfo)
result["${pkg.id.name}:${pkg.id.version}"] = pkg

stack += getDependencyNodes(currentNode)
stack += getDependencyInfos(currentInfo)
}

return result
}

private fun hasCompleteDependencies(node: JsonNode, scopeName: String): Boolean {
val dependencyKeys = node["dependencies"].fieldNamesOrEmpty().asSequence().toSet()
val dependencyRefKeys = node["pkgMeta"][scopeName].fieldNamesOrEmpty().asSequence().toSet()
private fun hasCompleteDependencies(info: PackageInfo, scopeName: String): Boolean {
val dependencyKeys = info.dependencies.keys
val dependencyRefKeys = info.pkgMeta.getDependencies(scopeName).keys

return dependencyKeys.containsAll(dependencyRefKeys)
}

private fun dependencyKeyOf(node: JsonNode): String? {
private fun dependencyKeyOf(info: PackageInfo): String? {
// As non-null dependency keys are supposed to define an equivalence relation for parsing 'missing' nodes,
// only the name and version attributes can be used. Typically, those attributes should be not null
// however in particular for root projects the null case also happens.
val name = node["pkgMeta"]["name"].textValueOrEmpty()
val version = node["pkgMeta"]["version"].textValueOrEmpty()
val name = info.pkgMeta.name.orEmpty()
val version = info.pkgMeta.version.orEmpty()
return "$name:$version".takeUnless { name.isEmpty() || version.isEmpty() }
}

private fun getNodesWithCompleteDependencies(node: JsonNode): Map<String, JsonNode> {
val result = mutableMapOf<String, JsonNode>()
private fun getNodesWithCompleteDependencies(info: PackageInfo): Map<String, PackageInfo> {
val result = mutableMapOf<String, PackageInfo>()

val stack = Stack<JsonNode>().apply { push(node) }
val stack = Stack<PackageInfo>().apply { push(info) }
while (!stack.empty()) {
val currentNode = stack.pop()
val currentInfo = stack.pop()

dependencyKeyOf(currentNode)?.let { key ->
if (hasCompleteDependencies(node, SCOPE_NAME_DEPENDENCIES) &&
hasCompleteDependencies(node, SCOPE_NAME_DEV_DEPENDENCIES)
dependencyKeyOf(currentInfo)?.let { key ->
if (hasCompleteDependencies(info, SCOPE_NAME_DEPENDENCIES) &&
hasCompleteDependencies(info, SCOPE_NAME_DEV_DEPENDENCIES)
) {
result[key] = currentNode
result[key] = currentInfo
}
}

stack += getDependencyNodes(currentNode)
stack += getDependencyInfos(currentInfo)
}

return result
}

private fun parseDependencyTree(
node: JsonNode,
info: PackageInfo,
scopeName: String,
alternativeNodes: Map<String, JsonNode> = getNodesWithCompleteDependencies(node)
alternativeNodes: Map<String, PackageInfo> = getNodesWithCompleteDependencies(info)
): Set<PackageReference> {
val result = mutableSetOf<PackageReference>()

if (!hasCompleteDependencies(node, scopeName)) {
if (!hasCompleteDependencies(info, scopeName)) {
// Bower leaves out a dependency entry for a child if there exists a similar node to its parent node
// with the exact same name and resolved target. This makes it necessary to retrieve the information
// about the subtree rooted at the parent from that other node containing the full dependency
// information.
// See https://github.com/bower/bower/blob/6bc778d/lib/core/Manager.js#L557 and below.
val alternativeNode = checkNotNull(alternativeNodes[dependencyKeyOf(node)])
val alternativeNode = checkNotNull(alternativeNodes[dependencyKeyOf(info)])
return parseDependencyTree(alternativeNode, scopeName, alternativeNodes)
}

node["pkgMeta"][scopeName].fieldNamesOrEmpty().forEach {
val childNode = node["dependencies"][it]
info.pkgMeta.getDependencies(scopeName).keys.forEach {
val childNode = info.dependencies.getValue(it)
val childScope = SCOPE_NAME_DEPENDENCIES
val childDependencies = parseDependencyTree(childNode, childScope, alternativeNodes)
val packageReference = PackageReference(
Expand All @@ -266,3 +243,10 @@ private fun parseDependencyTree(

return result
}

private fun PackageMeta.getDependencies(scopeName: String) =
when (scopeName) {
SCOPE_NAME_DEPENDENCIES -> dependencies
SCOPE_NAME_DEV_DEPENDENCIES -> devDependencies
else -> error("Invalid scope name: '$scopeName'.")
}
98 changes: 98 additions & 0 deletions plugins/package-managers/bower/src/main/kotlin/Model.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright (C) 2024 The ORT Project Authors (see <https://github.com/oss-review-toolkit/ort/blob/main/NOTICE>)
*
* 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.plugins.packagemanagers.bower

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.fasterxml.jackson.module.kotlin.readValue

import org.ossreviewtoolkit.model.jsonMapper
import org.ossreviewtoolkit.plugins.packagemanagers.bower.PackageMeta.Author
import org.ossreviewtoolkit.utils.common.textValueOrEmpty

@JsonIgnoreProperties(ignoreUnknown = true)
internal data class PackageInfo(
val pkgMeta: PackageMeta,
val dependencies: Map<String, PackageInfo> = emptyMap()
)

/**
* See https://github.com/bower/spec/blob/master/json.md.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
internal data class PackageMeta(
val name: String? = null,
val authors: List<Author> = emptyList(),
val description: String? = null,
val license: String? = null,
val homepage: String? = null,
val dependencies: Map<String, String> = emptyMap(),
val devDependencies: Map<String, String> = emptyMap(),
val version: String? = null,
@JsonProperty("_resolution")
val resolution: Resolution? = null,
val repository: Repository? = null,
@JsonProperty("_source")
val source: String?
) {
@JsonIgnoreProperties(ignoreUnknown = true)
data class Resolution(
val type: String? = null,
val tag: String? = null,
val commit: String? = null
)

@JsonDeserialize(using = AuthorDeserializer::class)
data class Author(
val name: String,
val email: String? = null
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class Repository(
val type: String,
val url: String
)
}

private val MAPPER = jsonMapper.copy().setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE)

internal fun parsePackageInfoJson(json: String): PackageInfo = MAPPER.readValue<PackageInfo>(json)

/**
* Parse information about the author. According to https://github.com/bower/spec/blob/master/json.md#authors,
* there are two formats to specify the authors of a package (similar to NPM). The difference is that the
* strings or objects are inside an array.
*/
private class AuthorDeserializer : StdDeserializer<Author>(Author::class.java) {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Author {
val node = p.codec.readTree<JsonNode>(p)
return when {
node.isTextual -> Author(node.textValue())
else -> Author(node["name"].textValueOrEmpty(), node["email"]?.textValue())
}
}
}

0 comments on commit f4aa253

Please sign in to comment.