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

Add API doc #1485

Merged
merged 2 commits into from
Oct 23, 2024
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
3 changes: 2 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ lazy val server = project
"org.webjars.npm" % "chartjs-adapter-date-fns" % "3.0.0",
"org.webjars" % "font-awesome" % "6.5.2",
"org.webjars" % "jquery" % "3.7.1",
"org.webjars.bower" % "select2" % "4.0.13"
"org.webjars.bower" % "select2" % "4.0.13",
"org.webjars" % "swagger-ui" % "5.17.14"
),
Compile / unmanagedResourceDirectories += (Assets / WebKeys.public).value,
Compile / resourceGenerators += (Assets / WebKeys.assets).map(Seq(_)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,7 @@ case class Artifact(
s"${fullHttpUrl(env)}/latest-by-scala-version.svg?platform=${platform.map(_.value).getOrElse(this.platform.value)}"

// TODO move this out
def fullHttpUrl(env: Env): String =
env match {
case Env.Prod => s"https://index.scala-lang.org$artifactHttpPath"
case Env.Dev =>
s"https://index-dev.scala-lang.org$artifactHttpPath" // todo: fix locally
case Env.Local =>
s"http://localhost:8080$artifactHttpPath" // todo: fix locally
}
def fullHttpUrl(env: Env): String = env.rootUrl + artifactHttpPath

private def artifactHttpPath: String = s"/${projectRef.organization}/${projectRef.repository}/$name"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@ sealed trait Env {
def isDev: Boolean = false
def isLocal: Boolean = false
def isDevOrProd: Boolean = isDev || isProd
def rootUrl: String
}
object Env {
case object Local extends Env {
override def isLocal: Boolean = true
override def rootUrl: String = "http://localhost:8080"
}
case object Dev extends Env {
override def isDev: Boolean = true
override def rootUrl: String = "https://index-dev.scala-lang.org"
}
case object Prod extends Env {
override def isProd: Boolean = true
override def rootUrl: String = "https://index.scala-lang.org"
}

def from(s: String): Env =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,14 @@ object Project {
def from(org: String, repo: String): Reference =
Reference(Organization(org), Repository(repo))

def unsafe(string: String): Reference =
string.split('/') match {
case Array(org, repo) => from(org, repo)
def parse(value: String): Option[Reference] =
value.split('/') match {
case Array(org, repo) => Some(from(org, repo))
case _ => None
}

def unsafe(value: String): Reference = parse(value).get

implicit val ordering: Ordering[Reference] =
Ordering.by(ref => (ref.organization.value, ref.repository.value))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,12 @@ import scala.concurrent.ExecutionContext
import scala.concurrent.Future

import scaladex.core.model._
import scaladex.core.model.search.PageParams
import scaladex.core.model.search.SearchParams
import scaladex.core.util.ScalaExtensions._

class ProjectService(database: WebDatabase, searchEngine: SearchEngine)(implicit context: ExecutionContext) {
def getProjects(languages: Seq[Language], platforms: Seq[Platform]): Future[Seq[Project.Reference]] = {
val searchParams = SearchParams(languages = languages, platforms = platforms)
for {
firstPage <- searchEngine.find(searchParams, PageParams(0, 10000))
p = firstPage.pagination
otherPages <- 1.until(p.pageCount).map(PageParams(_, 10000)).mapSync(p => searchEngine.find(searchParams, p))
} yield (firstPage +: otherPages).flatMap(_.items).map(_.document.reference)
searchEngine.findRefs(searchParams)
}

def getProject(ref: Project.Reference): Future[Option[Project]] = database.getProject(ref)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,6 @@ trait SearchEngine {
def getMostDependedUpon(limit: Int): Future[Seq[ProjectDocument]]
def getLatest(limit: Int): Future[Seq[ProjectDocument]]

// Old Search API
def find(
query: String,
binaryVersion: Option[BinaryVersion],
cli: Boolean,
page: PageParams
): Future[Page[ProjectDocument]]

// Search Page
def find(params: SearchParams, page: PageParams): Future[Page[ProjectHit]]
def autocomplete(params: SearchParams, limit: Int): Future[Seq[ProjectDocument]]
Expand All @@ -47,4 +39,15 @@ trait SearchEngine {
def find(category: Category, params: AwesomeParams, page: PageParams): Future[Page[ProjectDocument]]
def countByLanguages(category: Category, params: AwesomeParams): Future[Seq[(Language, Int)]]
def countByPlatforms(category: Category, params: AwesomeParams): Future[Seq[(Platform, Int)]]

// Old Search API
def find(
query: String,
binaryVersion: Option[BinaryVersion],
cli: Boolean,
page: PageParams
): Future[Page[ProjectDocument]]

// API
def findRefs(params: SearchParams): Future[Seq[Project.Reference]]
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,24 @@ class InMemorySearchEngine extends SearchEngine {
page: PageParams
): Future[Page[ProjectDocument]] = ???

override def find(params: SearchParams, page: PageParams): Future[Page[ProjectHit]] =
Future.successful {
val hits = allDocuments.values
.filter(doc => (params.languages.toSet -- doc.languages).isEmpty)
.filter(doc => (params.platforms.toSet -- doc.platforms).isEmpty)
.toSeq
.map(ProjectHit(_, Seq.empty))
Page(Pagination(1, 1, hits.size), hits)
}
override def find(params: SearchParams, page: PageParams): Future[Page[ProjectHit]] = {
val hits = allDocuments.values
.filter(doc => (params.languages.toSet -- doc.languages).isEmpty)
.filter(doc => (params.platforms.toSet -- doc.platforms).isEmpty)
.toSeq
.map(ProjectHit(_, Seq.empty))
val res = Page(Pagination(1, 1, hits.size), hits)
Future.successful(res)
}

override def findRefs(params: SearchParams): Future[Seq[Project.Reference]] = {
val res = allDocuments.values
.filter(doc => (params.languages.toSet -- doc.languages).isEmpty)
.filter(doc => (params.platforms.toSet -- doc.platforms).isEmpty)
.toSeq
.map(_.reference)
Future.successful(res)
}

override def autocomplete(params: SearchParams, limit: Int): Future[Seq[ProjectDocument]] = ???

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import java.io.Closeable
import scala.annotation.nowarn
import scala.concurrent.ExecutionContext
import scala.concurrent.Future
import scala.concurrent.duration._

import com.sksamuel.elastic4s.ElasticClient
import com.sksamuel.elastic4s.ElasticDsl._
import com.sksamuel.elastic4s.ElasticProperties
import com.sksamuel.elastic4s.Hit
import com.sksamuel.elastic4s.Response
import com.sksamuel.elastic4s.analysis.Analysis
import com.sksamuel.elastic4s.http.JavaClient
Expand Down Expand Up @@ -173,6 +175,30 @@ class ElasticsearchEngine(esClient: ElasticClient, index: String)(implicit ec: E
findPage(request, page).map(_.flatMap(toProjectHit))
}

override def findRefs(params: SearchParams): Future[Seq[Project.Reference]] = {
val request = searchRequest(filteredSearchQuery(params), params.sorting)
.sourceInclude("organization", "repository")
.limit(10000)
scroll(request, 30.seconds).map(_.flatMap(hit => Project.Reference.parse(hit.id)))
}

private def scroll(request: SearchRequest, timeout: FiniteDuration): Future[Seq[Hit]] = {
val r0 = request.keepAlive(timeout)
val keepAlive = r0.keepAlive.get
def recur(resp: Response[SearchResponse]): Future[Seq[Hit]] = {
val hits = resp.result.hits.hits.toSeq
resp.result.scrollId match {
case None => Future.successful(hits)
case Some(id) =>
for {
r <- esClient.execute(searchScroll(id, keepAlive))
nextHits <- recur(r)
} yield hits ++ nextHits
}
}
esClient.execute(request).flatMap(recur)
}

private def findPage(request: SearchRequest, page: PageParams): Future[Page[SearchHit]] = {
val clamp = if (page.page <= 0) 1 else page.page
val pagedRequest = request.from(page.size * (clamp - 1)).size(page.size)
Expand Down Expand Up @@ -212,7 +238,10 @@ class ElasticsearchEngine(esClient: ElasticClient, index: String)(implicit ec: E
case _ => scoreSort().order(SortOrder.Desc)
}

search(index).query(scoringQuery).sortBy(sortQuery)
search(index)
.sourceExclude("githubInfo.readme")
.query(scoringQuery)
.sortBy(sortQuery)
}

private def extractDocuments(response: Response[SearchResponse]): Seq[ProjectDocument] =
Expand Down
16 changes: 9 additions & 7 deletions modules/server/src/main/assets/css/partials/_header.scss
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,15 @@

.logo {
margin-bottom: 15px;
margin-left: 10px;

@media screen and (min-width: $screen-md-min) {
margin-bottom: 0;
}
}

.awesome {
font-family: Caveat;
font-size: 21px;
padding: 3px 10px;
}

.btn-default {
padding: 7px 8px;
background-color: #224951;
color: white;
&:hover, &:focus {
Expand All @@ -75,8 +71,14 @@
}
}

.awesome {
padding: 3px 8px;
font-family: Caveat;
font-size: 21px;
}

.btn {
margin-left: 16px;
margin-right: 8px;
min-height: 39px;
i {
margin-right: 8px;
Expand Down
18 changes: 18 additions & 0 deletions modules/server/src/main/resources/lib/swagger-initializer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
window.onload = function() {
window.ui = SwaggerUIBundle({
urls: [
{ name: "Scaladex API v1", url: "/api/v1/open-api.json" },
{ name: "Scaladex API v0", url: "/api/open-api.json" }
],
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
});
};
11 changes: 7 additions & 4 deletions modules/server/src/main/scala/scaladex/server/route/Assets.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package scaladex.server.route

import org.apache.pekko.http.scaladsl.model.StatusCodes
import org.apache.pekko.http.scaladsl.server.Directives._
import org.apache.pekko.http.scaladsl.server.Route

Expand All @@ -8,10 +9,12 @@ object Assets {
pathPrefix("assets") {
get(
concat(
path("lib" / Remaining)(path => getFromResource("lib/" + path)),
path("img" / Remaining)(path => getFromResource("img/" + path)),
path("css" / Remaining)(path => getFromResource("css/" + path)),
path("js" / Remaining)(path => getFromResource("js/" + path)),
pathPrefix("lib" / "swagger-ui")(redirect("/api/doc", StatusCodes.PermanentRedirect)),
// be explicit on what we can get to avoid security leak
pathPrefix("lib")(getFromResourceDirectory("lib")),
pathPrefix("img")(getFromResourceDirectory("img")),
pathPrefix("css")(getFromResourceDirectory("css")),
pathPrefix("js")(getFromResourceDirectory("js")),
path("webclient-opt.js")(
getFromResource("webclient-opt.js")
),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
package scaladex.server.route.api

import endpoints4s.openapi.model.OpenApi
import endpoints4s.pekkohttp.server
import endpoints4s.Encoder
import org.apache.pekko.http.cors.scaladsl.CorsDirectives.cors
import org.apache.pekko.http.scaladsl.server.Directives.concat
import org.apache.pekko.http.scaladsl.marshalling.Marshaller
import org.apache.pekko.http.scaladsl.marshalling.ToEntityMarshaller
import org.apache.pekko.http.scaladsl.model.MediaTypes
import org.apache.pekko.http.scaladsl.model.StatusCodes
import org.apache.pekko.http.scaladsl.server.Directives._
import org.apache.pekko.http.scaladsl.server.Route

/**
* Akka-Http routes serving the documentation of the public HTTP API of Scaladex
*/
object DocumentationRoute extends server.Endpoints with server.JsonEntitiesFromEncodersAndDecoders {
object DocumentationRoute {
implicit def marshallerFromEncoder[T](implicit encoder: Encoder[T, String]): ToEntityMarshaller[T] =
Marshaller
.stringMarshaller(MediaTypes.`application/json`)
.compose(c => encoder.encode(c))

val route: Route = cors() {
concat(
endpoint(
get(path / "api" / "open-api.json"),
ok(jsonResponse[OpenApi])
).implementedBy(_ => ApiDocumentation.apiV0),
endpoint(
get(path / "api" / "v1" / "open-api.json"),
ok(jsonResponse[OpenApi])
).implementedBy(_ => ApiDocumentation.apiV1)
)
get {
concat(
pathPrefix("api" / "doc")(
pathEnd(redirect("/api/doc/", StatusCodes.PermanentRedirect)) ~
pathSingleSlash(getFromResource("lib/swagger-ui/index.html")) ~
// override default swagger-initializer
path("swagger-initializer.js")(getFromResource("lib/swagger-initializer.js")) ~
getFromResourceDirectory("lib/swagger-ui")
),
path("api" / "open-api.json")(complete(StatusCodes.OK, ApiDocumentation.apiV0)),
path("api" / "v1" / "open-api.json")(complete(StatusCodes.OK, ApiDocumentation.apiV1))
)
}
}
}
Loading