Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding /api/artifacts/<group_id>/<artifact_id> endpoint #1008

Merged
merged 3 commits into from
Apr 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,14 @@ trait ArtifactEndpointSchema extends PaginationSchema {
.xmap[ArtifactResponse] { case (groupId, artifactId) => ArtifactResponse(groupId, artifactId) } {
case ArtifactResponse(groupId, artifactId) => (groupId, artifactId)
}

implicit val artifactMetadataResponseSchema: JsonSchema[ArtifactMetadataResponse] =
field[String]("version")
.zip(optField[String]("projectReference"))
.zip(field[String]("releaseDate"))
.zip(field[String]("language"))
.zip(field[String]("platform"))
.xmap[ArtifactMetadataResponse](ArtifactMetadataResponse.tupled)(
Function.unlift(ArtifactMetadataResponse.unapply)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,29 @@ trait ArtifactEndpoints
docs = Some("Filter the results matching the given platform only (e.g., 'jvm', 'sjs1', 'native0.4', 'sbt1.0')")
)).xmap((ArtifactParams.apply _).tupled)(Function.unlift(ArtifactParams.unapply))

val artifactMetadataEndpointParams: Path[ArtifactMetadataParams] = (segment[String](
name = "group_id",
docs = Some(
"Filter the results matching the given group id only (e.g., 'org.typelevel', 'org.scala-lang', 'org.apache.spark')"
)
) / segment[String](
name = "artifact_id",
docs = Some(
"Filter the results matching the given artifact id only (e.g., 'cats-core_3', 'cats-core_sjs0.6_2.13')"
)
)).xmap(ArtifactMetadataParams.tupled)(Function.unlift(ArtifactMetadataParams.unapply))

// Artifact endpoint definition
val artifact: Endpoint[ArtifactParams, Page[ArtifactResponse]] =
endpoint(
get(path / "api" / "artifacts" /? artifactEndpointParams),
ok(jsonResponse[Page[ArtifactResponse]])
)

// Artifact metadata endpoint definition
val artifactMetadata: Endpoint[ArtifactMetadataParams, Page[ArtifactMetadataResponse]] =
endpoint(
get(path / "api" / "artifacts" / artifactMetadataEndpointParams),
ok(jsonResponse[Page[ArtifactMetadataResponse]])
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package scaladex.core.api.artifact

final case class ArtifactMetadataParams(groupId: String, artifactId: String)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package scaladex.core.api.artifact

final case class ArtifactMetadataResponse(
version: String,
projectReference: Option[String],
jyoo980 marked this conversation as resolved.
Show resolved Hide resolved
releaseDate: String,
language: String,
platform: String
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import java.time.Instant
import fastparse.P
import fastparse.Start
import fastparse._
import scaladex.core.api.artifact.ArtifactMetadataResponse
import scaladex.core.model.PatchVersion
import scaladex.core.model.Project.DocumentationLink
import scaladex.core.util.Parsers._
Expand Down Expand Up @@ -243,4 +244,12 @@ object Artifact {
s"http://search.maven.org/#artifactdetails|$groupId|$artifactId|$version|jar"
}

def toMetadataResponse(artifact: Artifact): ArtifactMetadataResponse =
ArtifactMetadataResponse(
version = artifact.version.toString,
projectReference = Some(artifact.projectRef.toString),
releaseDate = artifact.releaseDate.toString,
language = artifact.language.toString,
platform = artifact.platform.toString
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,12 @@ case class Page[A](pagination: Pagination, items: Seq[A]) {

def flatMap[B](f: A => Iterable[B]): Page[B] = Page(pagination, items.flatMap(f))
}

object Page {

def empty[A]: Page[A] =
Page(
pagination = Pagination(current = 1, pageCount = 1, totalSize = 0),
items = Seq.empty
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ trait WebDatabase {
def getProject(projectRef: Project.Reference): Future[Option[Project]]
def getFormerReferences(projectRef: Project.Reference): Future[Seq[Project.Reference]]
def countInverseProjectDependencies(projectRef: Project.Reference): Future[Int]
def getArtifacts(groupId: Artifact.GroupId, artifactId: Artifact.ArtifactId): Future[Seq[Artifact]]
def getArtifacts(projectRef: Project.Reference): Future[Seq[Artifact]]
def getArtifactsByName(projectRef: Project.Reference, artifactName: Artifact.Name): Future[Seq[Artifact]]
def getArtifactByMavenReference(mavenRef: Artifact.MavenReference): Future[Option[Artifact]]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ class InMemoryDatabase extends SchedulerDatabase {
override def getProject(projectRef: Project.Reference): Future[Option[Project]] =
Future.successful(projects.get(projectRef))

override def getArtifacts(groupId: Artifact.GroupId, artifactId: Artifact.ArtifactId): Future[Seq[Artifact]] =
Future.successful {
artifacts.values.flatten.filter { artifact: Artifact =>
artifact.groupId == groupId && artifact.artifactId == artifactId.value
}.toSeq
}

override def getArtifacts(projectRef: Project.Reference): Future[Seq[Artifact]] =
Future.successful(artifacts.getOrElse(projectRef, Nil))

Expand Down
3 changes: 3 additions & 0 deletions modules/infra/src/main/scala/scaladex/infra/SqlDatabase.scala
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ class SqlDatabase(datasource: HikariDataSource, xa: doobie.Transactor[IO]) exten
override def getProject(projectRef: Project.Reference): Future[Option[Project]] =
run(ProjectTable.selectByReference.option(projectRef))

override def getArtifacts(groupId: Artifact.GroupId, artifactId: Artifact.ArtifactId): Future[Seq[Artifact]] =
run(ArtifactTable.selectArtifactByGroupIdAndArtifactId.to[Seq](groupId, artifactId))

override def getArtifacts(projectRef: Project.Reference): Future[Seq[Artifact]] =
run(ArtifactTable.selectArtifactByProject.to[Seq](projectRef))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ object ArtifactTable {
val selectArtifactByLanguageAndPlatform: Query[(Language, Platform), Artifact] =
selectRequest(table, fields, keys = Seq("language_version", "platform"))

val selectArtifactByGroupIdAndArtifactId: Query[(Artifact.GroupId, Artifact.ArtifactId), Artifact] =
selectRequest(table, fields, Seq("group_id", "artifact_id"))

val selectArtifactByProject: Query[Project.Reference, Artifact] = {
val where = projectReferenceFields.map(f => s"$f=?").mkString(" AND ")
selectRequest1(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ object DoobieUtils {
Meta[String].timap(_.split(",").filter(_.nonEmpty).map(Artifact.Name.apply).toSet)(_.mkString(","))
implicit val semanticVersionMeta: Meta[SemanticVersion] =
Meta[String].timap(SemanticVersion.parse(_).get)(_.encode)
implicit val artifactIdMeta: Meta[Artifact.ArtifactId] =
Meta[String].timap(Artifact.ArtifactId.parse(_).get)(_.value)
implicit val binaryVersionMeta: Meta[BinaryVersion] =
Meta[String].timap { x =>
BinaryVersion
Expand Down
40 changes: 40 additions & 0 deletions modules/infra/src/test/scala/scaladex/infra/SqlDatabaseTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -334,4 +334,44 @@ class SqlDatabaseTests extends AsyncFunSpec with BaseDatabaseSuite with Matchers
)
} yield storedArtifacts.size shouldBe 0
}

it("should not return any artifacts when the database is empty, given a group id and artifact id") {
val testArtifact = Cats.`core_3:4`
val testArtifactId = Artifact.ArtifactId
.parse(testArtifact.artifactId)
.getOrElse(fail("Parsing an artifact id should not have failed"))
for {
retrievedArtifacts <- database.getArtifacts(testArtifact.groupId, testArtifactId)
} yield retrievedArtifacts.size shouldBe 0
}

it("should return an artifact, given a group id an artifact id of a stored artifact") {
val testArtifact = Cats.`core_3:4`
val testArtifactId = Artifact.ArtifactId
.parse(testArtifact.artifactId)
.getOrElse(fail("Parsing an artifact id should not have failed"))
for {
isStoredSuccessfully <- database.insertArtifact(testArtifact, dependencies = Cats.dependencies, now)
retrievedArtifacts <- database.getArtifacts(testArtifact.groupId, testArtifactId)
} yield {
isStoredSuccessfully shouldBe true
retrievedArtifacts.size shouldBe 1
retrievedArtifacts.headOption shouldBe Some(testArtifact)
}
}

it("should return all versions of an artifact given a group id and an artifact id") {
val testArtifacts = Seq(Cats.`core_3:4`, Cats.`core_3:2.7.0`)
val groupId = Artifact.GroupId("org.typelevel")
val artifactId = Artifact.ArtifactId
.parse("cats-core_3")
.getOrElse(fail("Parsing an artifact id should not have failed"))
for {
_ <- database.insertArtifacts(testArtifacts)
retrievedArtifacts <- database.getArtifacts(groupId, artifactId)
} yield {
retrievedArtifacts.size shouldBe 2
retrievedArtifacts should contain theSameElementsAs testArtifacts
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class ArtifactTableTests extends AnyFunSpec with BaseDatabaseSuite with Matchers
it("check selectArtifactByLanguage")(check(ArtifactTable.selectArtifactByLanguage))
it("check selectArtifactByPlatform")(check(ArtifactTable.selectArtifactByPlatform))
it("check selectArtifactByLanguageAndPlatform")(check(ArtifactTable.selectArtifactByLanguageAndPlatform))
it("check selectArtifactByGroupIdAndArtifactId")(check(ArtifactTable.selectArtifactByGroupIdAndArtifactId))
it("check selectArtifactByProject")(check(ArtifactTable.selectArtifactByProject))
it("check selectArtifactByProjectAndName")(check(ArtifactTable.selectArtifactByProjectAndName))
it("check findOldestArtifactsPerProjectReference")(check(ArtifactTable.selectOldestByProject))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ object ApiDocumentation
title = "Scaladex API",
version = "0.1.0"
)
)(autocomplete, artifact)
)(autocomplete, artifact, artifactMetadata)
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package scaladex.server.route.api

import scala.concurrent.ExecutionContext
import scala.concurrent.Future

import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import ch.megard.akka.http.cors.scaladsl.CorsDirectives.cors
import endpoints4s.akkahttp.server
import scaladex.core.api.artifact.ArtifactEndpoints
import scaladex.core.api.artifact.ArtifactMetadataParams
import scaladex.core.api.artifact.ArtifactMetadataResponse
import scaladex.core.api.artifact.ArtifactParams
import scaladex.core.api.artifact.ArtifactResponse
import scaladex.core.model.Artifact
import scaladex.core.model.Language
import scaladex.core.model.Platform
import scaladex.core.model.search.Page
Expand Down Expand Up @@ -41,6 +46,26 @@ class ArtifactApi(database: WebDatabase)(
items = distinctArtifacts
)
}
} ~ artifactMetadata.implementedByAsync {
case ArtifactMetadataParams(groupId, artifactId) =>
val parsedGroupId = Artifact.GroupId(groupId)
Artifact.ArtifactId.parse(artifactId).fold(Future.successful(Page.empty[ArtifactMetadataResponse])) {
parsedArtifactId =>
val futureArtifacts = database.getArtifacts(parsedGroupId, parsedArtifactId)
val futureResponses = futureArtifacts.map(_.map(Artifact.toMetadataResponse))
futureResponses.map { resp =>
// TODO: The values below are placeholders, will need to populate them w. real data.
// See: https://github.com/scalacenter/scaladex/pull/992#discussion_r841500215
Page(
pagination = Pagination(
current = 1,
pageCount = 1,
totalSize = resp.size
),
items = resp
)
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import akka.http.scaladsl.server.Route
import cats.implicits.toTraverseOps
import org.scalatest.BeforeAndAfterEach
import play.api.libs.json.Reads
import scaladex.core.api.artifact.ArtifactMetadataResponse
import scaladex.core.api.artifact.ArtifactResponse
import scaladex.core.model.Artifact
import scaladex.core.model.Language
Expand All @@ -25,7 +26,10 @@ class ArtifactApiTests extends ControllerBaseSuite with BeforeAndAfterEach with
val artifactRoute: Route = ArtifactApi(database).routes

implicit val jsonPaginationReader: Reads[Pagination] = PlayJsonCodecs.paginationSchema.reads
implicit def jsonPageReader: Reads[Page[ArtifactResponse]] = PlayJsonCodecs.pageSchema[ArtifactResponse].reads
implicit def jsonArtifactResponsePageReader: Reads[Page[ArtifactResponse]] =
PlayJsonCodecs.pageSchema[ArtifactResponse].reads
implicit def jsonArtifactMetadataResponsePageReader: Reads[Page[ArtifactMetadataResponse]] =
PlayJsonCodecs.pageSchema[ArtifactMetadataResponse].reads

override protected def beforeAll(): Unit = Await.result(insertAllCatsArtifacts(), Duration.Inf)

Expand Down Expand Up @@ -114,16 +118,50 @@ class ArtifactApiTests extends ControllerBaseSuite with BeforeAndAfterEach with
}
}

it("should not return any artifacts given a group id and artifact id for an artifact not stored") {
Get("/api/artifacts/ca.ubc.cs/test-package_3") ~> artifactRoute ~> check {
status shouldBe StatusCodes.OK
val response = responseAs[Page[ArtifactMetadataResponse]]
response shouldBe Page.empty[ArtifactMetadataResponse]
}
}

it("should not return any artifacts given a valid group id and an artifact id that does not parse") {
val malformedArtifactId = "badArtifactId"
Get(s"/api/artifacts/org.apache.spark/$malformedArtifactId") ~> artifactRoute ~> check {
status shouldBe StatusCodes.OK
val response = responseAs[Page[ArtifactMetadataResponse]]
response shouldBe Page.empty[ArtifactMetadataResponse]
}
}

it("should not return any artifacts given an invalid group id and a valid artifact id") {
Get("/api/artifacts/ca.ubc.cs/cats-core_3") ~> artifactRoute ~> check {
status shouldBe StatusCodes.OK
val response = responseAs[Page[ArtifactMetadataResponse]]
response shouldBe Page.empty[ArtifactMetadataResponse]
}
}

it("should return artifacts with the given group id and artifact id") {
Get("/api/artifacts/org.typelevel/cats-core_3") ~> artifactRoute ~> check {
status shouldBe StatusCodes.OK
val response = responseAs[Page[ArtifactMetadataResponse]]
val expectedArtifactMetadata = Seq(Cats.`core_3:2.6.1`, Cats.`core_3:2.7.0`).map(Artifact.toMetadataResponse)
response match {
case Page(_, artifacts) =>
artifacts.size shouldBe 2
artifacts should contain theSameElementsAs expectedArtifactMetadata
}
}
}

it("should not return artifacts if the database is empty") {
database.reset()
Get(s"/api/artifacts") ~> artifactRoute ~> check {
status shouldBe StatusCodes.OK
val response = responseAs[Page[ArtifactResponse]]
response match {
case Page(Pagination(_, _, numStoredArtifacts), artifacts) =>
numStoredArtifacts shouldBe 0
artifacts.size shouldBe 0
}
response shouldBe Page.empty[ArtifactResponse]
}
}
}
Expand Down