diff --git a/modules/core/shared/src/main/scala/scaladex/core/model/ArtifactSelection.scala b/modules/core/shared/src/main/scala/scaladex/core/model/ArtifactSelection.scala index abf3f8218..a38c99bb0 100644 --- a/modules/core/shared/src/main/scala/scaladex/core/model/ArtifactSelection.scala +++ b/modules/core/shared/src/main/scala/scaladex/core/model/ArtifactSelection.scala @@ -7,37 +7,6 @@ case class ArtifactSelection( private def filterAll(artifact: Artifact.Reference): Boolean = binaryVersion.forall(_ == artifact.binaryVersion) && artifactNames.forall(_ == artifact.name) - def defaultArtifact(artifacts: Seq[Artifact.Reference], project: Project): Option[Artifact.Reference] = - val filteredArtifacts = artifacts.view.filter(filterAll) - - filteredArtifacts.maxByOption { artifact => - ( - // default artifact (ex: akka-actors is the default for akka/akka) - project.settings.defaultArtifact.contains(artifact.name), - // not deprecated - !project.settings.deprecatedArtifacts.contains(artifact.name), - // project repository (ex: shapeless) - project.repository.value == artifact.name.value, - // alphabetically - artifact.name, - // stable version first - project.settings.preferStableVersion && !artifact.version.isStable, - artifact.version, - artifact.binaryVersion - ) - }( - Ordering.Tuple7( - Ordering[Boolean], - Ordering[Boolean], - Ordering[Boolean], - Ordering[Artifact.Name].reverse, - Ordering[Boolean].reverse, - Ordering[Version], - Ordering[BinaryVersion] - ) - ) - end defaultArtifact - def filterArtifacts(artifacts: Seq[Artifact.Reference], project: Project): Seq[Artifact.Reference] = artifacts .filter(filterAll) @@ -70,6 +39,3 @@ case class ArtifactSelection( .reverse ) end ArtifactSelection - -object ArtifactSelection: - def empty = new ArtifactSelection(None, None) diff --git a/modules/core/shared/src/main/scala/scaladex/core/model/ProjectHeader.scala b/modules/core/shared/src/main/scala/scaladex/core/model/ProjectHeader.scala index 67854761f..50753885f 100644 --- a/modules/core/shared/src/main/scala/scaladex/core/model/ProjectHeader.scala +++ b/modules/core/shared/src/main/scala/scaladex/core/model/ProjectHeader.scala @@ -29,6 +29,8 @@ final case class ProjectHeader( def allArtifactNames: Seq[Artifact.Name] = artifacts.map(_.name).distinct.sorted def platforms(artifactName: Artifact.Name): Seq[Platform] = artifacts.filter(_.name == artifactName).map(_.platform).distinct.sorted(Platform.ordering.reverse) + def artifacts(artifactName: Artifact.Name, platform: Platform): Seq[Artifact] = + artifacts.filter(a => a.name == artifactName && a.platform == platform) def versionsUrl: String = artifactsUrl(getDefaultArtifact(None, None), withBinaryVersion = false) @@ -43,19 +45,30 @@ final case class ProjectHeader( val queryParams = if filters.nonEmpty then "?" + filters.mkString("&") else "" s"/$ref/artifacts/${defaultArtifact.name}$queryParams" + def getDefaultArtifact0(binaryVersion: Option[BinaryVersion], artifactName: Option[Artifact.Name]): Artifact = + getDefaultArtifact(binaryVersion.map(_.language), binaryVersion.map(_.platform), artifactName) + + def getDefaultArtifact(language: Option[Language], platform: Option[Platform]): Artifact = + getDefaultArtifact(language, platform, None) + /** getDefaultArtifact is split in two steps: first we get the default artifact name and then, the latest version. The * reason is, we cannot use the latest version of all artifacts to get the default artifact if they don't share the * same versioning. Instead we use the latest release date. But once we have the artifact with the latest release * date, we really want to get the latest version of that artifact, which is not necessarily the latest one released * because of back-publishing. */ - def getDefaultArtifact(language: Option[Language], platform: Option[Platform]): Artifact = - val artifactName = getDefaultArtifactName(language, platform) + def getDefaultArtifact( + language: Option[Language], + platform: Option[Platform], + artifactName: Option[Artifact.Name] + ): Artifact = + val defaultArtifactName = artifactName.getOrElse(getDefaultArtifactName(language, platform)) val filteredArtifacts = artifacts.filter { a => - a.name == artifactName && language.forall(_ == a.language) && platform.forall(_ == a.platform) + a.name == defaultArtifactName && language.forall(_ == a.language) && platform.forall(_ == a.platform) } if preferStableVersion then filteredArtifacts.maxBy(a => (a.version.isStable, a.version)) else filteredArtifacts.maxBy(_.version) + end getDefaultArtifact private def getDefaultArtifactName(language: Option[Language], platform: Option[Platform]): Artifact.Name = val filteredArtifacts = artifacts.filter(a => language.forall(_ == a.language) && platform.forall(_ == a.platform)) diff --git a/modules/core/shared/src/test/scala/scaladex/core/model/ArtifactSelectionTests.scala b/modules/core/shared/src/test/scala/scaladex/core/model/ArtifactSelectionTests.scala deleted file mode 100644 index c230416fb..000000000 --- a/modules/core/shared/src/test/scala/scaladex/core/model/ArtifactSelectionTests.scala +++ /dev/null @@ -1,30 +0,0 @@ -package scaladex.core.model - -import org.scalatest.funspec.AsyncFunSpec -import org.scalatest.matchers.should.Matchers - -class ArtifactSelectionTests extends AsyncFunSpec with Matchers: - it("latest version pre release scala") { - val project = Project.default(Project.Reference.from("typelevel", "cats")) - val artifactIds = Seq("cats-core_2.11", "cats-core_2.10", "cats-core_sjs0.6_2.11", "cats-core_sjs0.6_2.10") - val versions = Seq("0.6.0", "0.6.0-M2", "0.6.0-M1", "0.5.0", "0.4.1", "0.4.0") - val artifacts = for - artifactId <- artifactIds - version <- versions - yield Artifact.Reference.from("org.typelevel", artifactId, version) - val result = ArtifactSelection.empty.defaultArtifact(artifacts, project) - result should contain(artifacts.head) - } - - it("selected artifact") { - val project = Project.default(Project.Reference.from("akka", "akka")) - val groupdId = "com.typesafe.akka" - val artifacts = Seq( - Artifact.Reference.from(groupdId, "akka-distributed-data-experimental_2.11", "2.4.8"), - Artifact.Reference.from(groupdId, "akka-actors_2.11", "2.4.8") - ) - val selection = ArtifactSelection(None, Some(Artifact.Name("akka-distributed-data-experimental"))) - val result = selection.defaultArtifact(artifacts, project) - result should contain(artifacts.head) - } -end ArtifactSelectionTests diff --git a/modules/server/src/main/scala/scaladex/server/Server.scala b/modules/server/src/main/scala/scaladex/server/Server.scala index dd3bdeaad..52e09d7ef 100644 --- a/modules/server/src/main/scala/scaladex/server/Server.scala +++ b/modules/server/src/main/scala/scaladex/server/Server.scala @@ -150,7 +150,7 @@ object Server extends LazyLogging: val publishApi = new PublishApi(githubAuth, publishProcess) val apiEndpoints = new ApiEndpointsImpl(projectService, artifactService, searchEngine) val oldSearchApi = new OldSearchApi(searchEngine, webDatabase) - val badges = new Badges(webDatabase) + val badges = new Badges(projectService) val authentication = new AuthenticationApi(config.oAuth2.clientId, config.session, githubAuth, webDatabase) val route: Route = diff --git a/modules/server/src/main/scala/scaladex/server/route/Badges.scala b/modules/server/src/main/scala/scaladex/server/route/Badges.scala index 67b8e9b86..e3da728b1 100644 --- a/modules/server/src/main/scala/scaladex/server/route/Badges.scala +++ b/modules/server/src/main/scala/scaladex/server/route/Badges.scala @@ -3,29 +3,18 @@ package scaladex.server.route import scala.concurrent.ExecutionContext import scala.concurrent.Future -import scaladex.core.model.Artifact -import scaladex.core.model.ArtifactSelection -import scaladex.core.model.BinaryVersion -import scaladex.core.model.Jvm -import scaladex.core.model.Platform -import scaladex.core.model.Project -import scaladex.core.model.SbtPlugin -import scaladex.core.model.Scala -import scaladex.core.model.ScalaJs -import scaladex.core.model.ScalaNative -import scaladex.core.model.Version -import scaladex.core.model.Version.PreferStable -import scaladex.core.service.WebDatabase +import scaladex.core.model.* +import scaladex.core.service.ProjectService import org.apache.pekko.http.scaladsl.model.StatusCodes.* -import org.apache.pekko.http.scaladsl.model.headers.* +import org.apache.pekko.http.scaladsl.model.headers import org.apache.pekko.http.scaladsl.model.headers.CacheDirectives.* import org.apache.pekko.http.scaladsl.server.Directives.* import org.apache.pekko.http.scaladsl.server.RequestContext import org.apache.pekko.http.scaladsl.server.Route import org.apache.pekko.http.scaladsl.server.RouteResult -class Badges(database: WebDatabase)(using ExecutionContext): +class Badges(projectService: ProjectService)(using ExecutionContext): private val shields = parameters("color".?, "style".?, "logo".?, "logoWidth".as[Int].?) @@ -51,57 +40,38 @@ class Badges(database: WebDatabase)(using ExecutionContext): logo: Option[String], logoWidth: Option[Int] ) = - def shieldEscape(in: String): String = - in.replace("-", "--") - .replace("_", "__") - .replace(" ", "_") + in.replace("-", "--").replace("_", "__").replace(" ", "_") val subject = shieldEscape(rawSubject) val status = shieldEscape(rawStatus) - val color = rawColor.getOrElse("green") // we need a specific encoding val query = List( style.map(("style", _)), - logo.map(l => - ( - "logo", - java.net.URLEncoder - .encode(l, "ascii") - .replace("+", "%2B") - ) - ), + logo.map(l => ("logo", java.net.URLEncoder.encode(l, "ascii").replace("+", "%2B"))), logoWidth.map(w => ("logoWidth", w.toString)) ).flatten.map { case (k, v) => k + "=" + v }.mkString("?", "&", "") - respondWithHeaders(`Cache-Control`(`no-cache`), ETag(status)) { - redirect( - s"https://img.shields.io/badge/$subject-$status-$color.svg$query", - TemporaryRedirect - ) + respondWithHeaders(headers.`Cache-Control`(`no-cache`), headers.ETag(status)) { + redirect(s"https://img.shields.io/badge/$subject-$status-$color.svg$query", TemporaryRedirect) } end shieldsSvg - def latest( - ref: Project.Reference, - artifactName: Option[Artifact.Name] - ): RequestContext => Future[RouteResult] = + def latest(ref: Project.Reference, artifactName: Option[Artifact.Name]): RequestContext => Future[RouteResult] = parameter("target".?) { binaryVersion => shieldsOptionalSubject { (color, style, logo, logoWidth, subjectOpt) => val subject = subjectOpt.orElse(artifactName.map(_.value)).getOrElse(ref.repository.value) - def error(msg: String) = - shieldsSvg(subject, msg, color.orElse(Some("lightgrey")), style, logo, logoWidth) + def error(msg: String) = shieldsSvg(subject, msg, color.orElse(Some("lightgrey")), style, logo, logoWidth) - val res = database.getProject(ref).flatMap { + val res = projectService.getProject(ref).flatMap { case None => Future.successful(error("project not found")) case Some(project) => val bv = binaryVersion.flatMap(BinaryVersion.parse) getDefaultArtifact(project, bv, artifactName).map { case None => error("no published artifacts") - case Some(artifact) => - shieldsSvg(subject, artifact.version.toString, color, style, logo, logoWidth) + case Some(artifact) => shieldsSvg(subject, artifact.version.toString, color, style, logo, logoWidth) } } @@ -109,82 +79,63 @@ class Badges(database: WebDatabase)(using ExecutionContext): } } - def latestByScalaVersion( - reference: Project.Reference, - artifactName: Artifact.Name - ): RequestContext => Future[RouteResult] = - // targetType paramater is kept for forward compatibility + def latestByScalaVersion(ref: Project.Reference, artifactName: Artifact.Name): RequestContext => Future[RouteResult] = + // targetType parameter is kept for forward compatibility // in case targetType is defined we choose the most recent corresponding platform parameters("targetType".?, "platform".?) { (targetTypeParam, platformParam) => shields { (color, style, logo, logoWidth) => - // TODO use projectHeader - val artifactsF = database.getProjectArtifactRefs(reference, artifactName) - onSuccess(artifactsF) { artifacts => - val availablePlatforms = artifacts.map(_.binaryVersion.platform).distinct + val headerF = projectService.getHeader(ref).map(_.get) + onSuccess(headerF) { header => + val platforms = header.platforms(artifactName) val platform = platformParam .flatMap(Platform.parse) - .orElse { - targetTypeParam.map(_.toUpperCase).flatMap { - case "JVM" => Some(Jvm) - case "JS" => - val jsPlatforms = - availablePlatforms.collect { case p: ScalaJs => p } - Option.when(jsPlatforms.nonEmpty)(jsPlatforms.max[Platform]) - case "NATIVE" => - val nativePlatforms = - availablePlatforms.collect { case v: ScalaNative => v } - Option.when(nativePlatforms.nonEmpty)(nativePlatforms.max[Platform]) - case "SBT" => - val sbtPlatforms = - availablePlatforms.collect { case v: SbtPlugin => v } - Option.when(sbtPlatforms.nonEmpty)(sbtPlatforms.max[Platform]) - case _ => None - } - } - .getOrElse(availablePlatforms.max) - - val platformArtifacts = artifacts.filter(_.binaryVersion.platform == platform) - val summary = Badges.summaryOfLatestVersions(platformArtifacts) - + .orElse(targetTypeParam.flatMap(selectPlatformFromTargetType(_, platforms))) + .getOrElse(platforms.max) + val artifacts = header.artifacts(artifactName, platform) + val summary = Badges.summaryOfLatestVersions(artifacts.map(_.reference), platform) shieldsSvg(s"$artifactName - $platform", summary, color, style, logo, logoWidth) } } } + private def selectPlatformFromTargetType(targetType: String, platforms: Seq[Platform]): Option[Platform] = + targetType.toUpperCase match + case "JVM" => platforms.find(_ == Jvm) + case "JS" => platforms.collect { case p: ScalaJs => p }.maxOption + case "NATIVE" => platforms.collect { case v: ScalaNative => v }.maxOption + case "SBT" => platforms.collect { case v: SbtPlugin => v }.maxOption + case _ => None + private def getDefaultArtifact( project: Project, binaryVersion: Option[BinaryVersion], artifact: Option[Artifact.Name] ): Future[Option[Artifact.Reference]] = - val artifactSelection = ArtifactSelection(binaryVersion, artifact) - // TODO use projectHeader - database.getProjectArtifactRefs(project.reference, stableOnly = false).map { artifacts => - val (stableArtifacts, nonStableArtifacts) = artifacts.partition(_.version.isStable) - artifactSelection - .defaultArtifact(stableArtifacts, project) - .orElse(artifactSelection.defaultArtifact(nonStableArtifacts, project)) - } - end getDefaultArtifact + projectService.getHeader(project.reference).map(_.map(_.getDefaultArtifact0(binaryVersion, artifact).reference)) end Badges object Badges: - private def summaryOfLatestVersions(artifacts: Seq[Artifact.Reference]): String = - val versionsByScalaVersions = artifacts - .groupMap(_.binaryVersion.language)(_.version) - .collect { case (Scala(v), version) => Scala(v) -> version } - summaryOfLatestVersions(versionsByScalaVersions) - - private[route] def summaryOfLatestVersions(versionsByScalaVersions: Map[Scala, Seq[Version]]): String = - versionsByScalaVersions.view - .mapValues(_.max(PreferStable)) - .groupMap { case (_, latestVersion) => latestVersion } { case (scalaVersion, _) => scalaVersion } + private def summaryOfLatestVersions(artifacts: Seq[Artifact.Reference], platform: Platform): String = + platform match + case _: (SbtPlugin | MillPlugin) => + val latestVersion = artifacts.map(_.version).max + latestVersion.toString + case _ => + val latestVersions = artifacts.groupMapReduce(_.binaryVersion.language)(_.version)(Version.ordering.max) + summaryByLanguageVersion(latestVersions) + + private[route] def summaryByLanguageVersion(latestVersions: Map[Language, Version]): String = + latestVersions.toSeq + .groupMap { case (_, latestVersion) => latestVersion } { case (language, _) => language } .toSeq .sortBy(_._1)(Version.ordering.reverse) .map { - case (latestVersion, scalaVersions) => - val scalaVersionsStr = - scalaVersions.map(_.version).toSeq.sorted(Version.ordering.reverse).mkString(", ") - s"$latestVersion (Scala $scalaVersionsStr)" + case (version, Seq(Java)) => s"$version" + case (version, languages) => + // there is more than one language, we ignore Java + val scalaVersions = + languages.collect { case Scala(v) => v }.toSeq.sorted(Version.ordering.reverse).mkString(", ") + s"$version (Scala $scalaVersions)" } .mkString(", ") end Badges diff --git a/modules/server/src/main/scala/scaladex/server/route/api/OldSearchApi.scala b/modules/server/src/main/scala/scaladex/server/route/api/OldSearchApi.scala index 511a3d7a0..a11cea62d 100644 --- a/modules/server/src/main/scala/scaladex/server/route/api/OldSearchApi.scala +++ b/modules/server/src/main/scala/scaladex/server/route/api/OldSearchApi.scala @@ -168,10 +168,8 @@ class OldSearchApi(searchEngine: SearchEngine, database: WebDatabase)(using Exec binaryVersion: Option[BinaryVersion], artifact: Option[String] ): Future[Option[OldSearchApi.ArtifactOptions]] = - val selection = new ArtifactSelection( - binaryVersion = binaryVersion, - artifactNames = artifact.map(Artifact.Name.apply) - ) + val selection = + new ArtifactSelection(binaryVersion = binaryVersion, artifactNames = artifact.map(Artifact.Name.apply)) for projectOpt <- database.getProject(projectRef) stableOnly = projectOpt.map(_.settings.preferStableVersion).getOrElse(false) diff --git a/modules/server/src/test/scala/scaladex/server/route/BadgesTests.scala b/modules/server/src/test/scala/scaladex/server/route/BadgesTests.scala index 6e1e2c221..aedf22f7d 100644 --- a/modules/server/src/test/scala/scaladex/server/route/BadgesTests.scala +++ b/modules/server/src/test/scala/scaladex/server/route/BadgesTests.scala @@ -4,9 +4,9 @@ import scala.concurrent.Await import scala.concurrent.Future import scala.concurrent.duration.Duration -import scaladex.core.model.Scala.* +import scaladex.core.model.* import scaladex.core.test.Values.* -import scaladex.server.route.Badges.summaryOfLatestVersions +import scaladex.server.route.Badges.* import org.apache.pekko.http.scaladsl.model.StatusCodes import org.apache.pekko.http.scaladsl.model.Uri @@ -18,14 +18,14 @@ import org.scalatest.matchers.should.Matchers class BadgesTests extends ControllerBaseSuite with BeforeAndAfterAll: - val badgesRoute: Route = new Badges(database).route + val route: Route = new Badges(projectService).route override protected def beforeAll(): Unit = val f = Future.traverse(Cats.allArtifacts)(artifactService.insertArtifact(_, Seq.empty)) Await.result(f, Duration.Inf) it("fallback to JVM artifacts") { - Get(s"/${Cats.reference}/cats-core/latest-by-scala-version.svg") ~> badgesRoute ~> check { + Get(s"/${Cats.reference}/cats-core/latest-by-scala-version.svg") ~> route ~> check { status shouldEqual StatusCodes.TemporaryRedirect val redirection = headers.collectFirst { case Location(uri) => uri } redirection should contain( @@ -35,7 +35,7 @@ class BadgesTests extends ControllerBaseSuite with BeforeAndAfterAll: } it("fallback to sjs1 when targetType is js") { - Get(s"/${Cats.reference}/cats-core/latest-by-scala-version.svg?targetType=js") ~> badgesRoute ~> check { + Get(s"/${Cats.reference}/cats-core/latest-by-scala-version.svg?targetType=js") ~> route ~> check { status shouldEqual StatusCodes.TemporaryRedirect val redirection = headers.collectFirst { case Location(uri) => uri } redirection should contain( @@ -45,7 +45,7 @@ class BadgesTests extends ControllerBaseSuite with BeforeAndAfterAll: } it("latest version for Scala.js 0.6") { - Get(s"/${Cats.reference}/cats-core/latest-by-scala-version.svg?platform=sjs0.6") ~> badgesRoute ~> check { + Get(s"/${Cats.reference}/cats-core/latest-by-scala-version.svg?platform=sjs0.6") ~> route ~> check { status shouldEqual StatusCodes.TemporaryRedirect val redirection = headers.collectFirst { case Location(uri) => uri } redirection should contain( @@ -55,7 +55,7 @@ class BadgesTests extends ControllerBaseSuite with BeforeAndAfterAll: } it("latest version for Scala native 0.4") { - Get(s"/${Cats.reference}/cats-core/latest-by-scala-version.svg?platform=native0.4") ~> badgesRoute ~> check { + Get(s"/${Cats.reference}/cats-core/latest-by-scala-version.svg?platform=native0.4") ~> route ~> check { status shouldEqual StatusCodes.TemporaryRedirect val redirection = headers.collectFirst { case Location(uri) => uri } redirection should contain( @@ -67,23 +67,16 @@ end BadgesTests class BadgesUnitTests extends AnyFunSpec with Matchers: it("should provide a concise summary of latest versions") { - summaryOfLatestVersions( - Map( - `2.11` -> Seq(`7.0.0`, `7.1.0`), - `2.12` -> Seq(`7.0.0`, `7.1.0`, `7.2.0`), - `2.13` -> Seq(`7.0.0`, `7.1.0`, `7.2.0`, `7.3.0`), - `3` -> Seq(`7.2.0`, `7.3.0`) - ) - ) shouldBe "7.3.0 (Scala 3.x, 2.13), 7.2.0 (Scala 2.12), 7.1.0 (Scala 2.11)" + val versions: Map[Language, Version] = + Map(Scala.`2.11` -> `7.1.0`, Scala.`2.12` -> `7.2.0`, Scala.`2.13` -> `7.3.0`, Scala.`3` -> `7.3.0`) + summaryByLanguageVersion(versions) shouldBe "7.3.0 (Scala 3.x, 2.13), 7.2.0 (Scala 2.12), 7.1.0 (Scala 2.11)" } it("should prefer stable to pre-releases if both are available") { - summaryOfLatestVersions(Map(`2.13` -> Seq(`7.0.0`, `7.1.0`, `7.2.0-PREVIEW.1`))) shouldBe "7.1.0 (Scala 2.13)" + summaryByLanguageVersion(Map(Scala.`2.13` -> `7.1.0`)) shouldBe "7.1.0 (Scala 2.13)" } it("should display latest pre-release if no full release is available") { - summaryOfLatestVersions( - Map(`2.13` -> Seq(`7.2.0-PREVIEW.1`, `7.2.0-PREVIEW.2`)) - ) shouldBe s"${`7.2.0-PREVIEW.2`} (Scala 2.13)" + summaryByLanguageVersion(Map(Scala.`2.13` -> `7.2.0-PREVIEW.2`)) shouldBe s"7.2.0-PREVIEW.2 (Scala 2.13)" } end BadgesUnitTests