Skip to content
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
26 changes: 26 additions & 0 deletions api/app/controllers/BatchVersionsLatest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package controllers

import cats.data.Validated.{Invalid, Valid}
import io.apibuilder.api.v0.models.BatchVersionsLatestForm
import io.apibuilder.api.v0.models.json.*
import lib.Validation
import play.api.libs.json.Json
import play.api.mvc.Action
import services.BatchVersionsLatestService

import javax.inject.{Inject, Singleton}

@Singleton
class BatchVersionsLatest @Inject() (
override val apiBuilderControllerComponents: ApiBuilderControllerComponents,
service: BatchVersionsLatestService,
) extends ApiBuilderController {

def post(orgKey: String): Action[BatchVersionsLatestForm] = Anonymous(parse.json[BatchVersionsLatestForm]) { request =>
service.process(orgKey, request.body) match {
case Valid(result) => Ok(Json.toJson(result))
case Invalid(errors) => Conflict(Json.toJson(Validation.errors(errors)))
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning 409 Conflict for input validation failures (e.g., too many keys) is atypical; 400 Bad Request is usually the correct status for invalid request bodies. Consider switching to BadRequest(...) here (and documenting that code in the API spec) so clients can handle validation errors consistently.

Suggested change
case Invalid(errors) => Conflict(Json.toJson(Validation.errors(errors)))
case Invalid(errors) => BadRequest(Json.toJson(Validation.errors(errors)))

Copilot uses AI. Check for mistakes.
}
}

}
38 changes: 38 additions & 0 deletions api/app/db/InternalVersionsDao.scala
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,44 @@ class InternalVersionsDao @Inject()(
}).map(InternalVersion(_))
}

/**
* Efficient single-query lookup of the latest version string for multiple applications
* within an organization. Returns a map of application_key -> latest_version.
*/
def findLatestVersions(
orgKey: String,
applicationKeys: Seq[String],
): Map[String, String] = {
if (applicationKeys.isEmpty) {
Map.empty
} else {
val keyParams = applicationKeys.zipWithIndex.map { case (_, i) => s"{app_key_$i}" }.mkString(", ")
val namedParams = applicationKeys.zipWithIndex.map { case (key, i) =>
NamedParameter(s"app_key_$i", key)
}

dao.db.withConnection { implicit c =>
SQL(
s"""select distinct on (a.key) a.key as app_key, versions.version
| from versions
| join applications a on a.guid = versions.application_guid
| join organizations o on o.guid = a.organization_guid
| where o.key = {org_key}
| and a.key in ($keyParams)
| and versions.deleted_at is null
Comment on lines +223 to +236
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Building a long IN ({app_key_0}, {app_key_1}, ...) clause via string interpolation makes the query harder to read/maintain and grows linearly with key count. Consider switching to a single-array parameter approach (e.g., a.key = any({application_keys})) or an Anorm-supported list binding helper, which typically reduces SQL string construction and keeps the query shape stable.

Copilot uses AI. Check for mistakes.
| and a.deleted_at is null
| and o.deleted_at is null
| and $HasServiceJsonClause
| order by a.key, versions.version_sort_key desc""".stripMargin
).on(
(Seq(NamedParameter("org_key", orgKey)) ++ namedParams)*
).as(
(SqlParser.str("app_key") ~ SqlParser.str("version")).map { case key ~ version => key -> version }.*
).toMap
}
}
}

// Efficient query to fetch all versions of a given application
def findAllVersions(
authorization: Authorization,
Expand Down
35 changes: 35 additions & 0 deletions api/app/services/BatchVersionsLatestService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package services

import cats.data.ValidatedNec
import cats.implicits.*
import db.InternalVersionsDao
import io.apibuilder.api.v0.models.{BatchVersionLatest, BatchVersionsLatest, BatchVersionsLatestForm}

import javax.inject.Inject

class BatchVersionsLatestService @Inject() (
versionsDao: InternalVersionsDao,
) {

private val MaxApplicationKeys = 500

def process(
orgKey: String,
form: BatchVersionsLatestForm,
): ValidatedNec[String, BatchVersionsLatest] = {
if (form.applicationKeys.length > MaxApplicationKeys) {
s"Maximum of $MaxApplicationKeys application keys allowed, but ${form.applicationKeys.length} were provided".invalidNec
} else {
val latestVersions = versionsDao.findLatestVersions(orgKey, form.applicationKeys)

BatchVersionsLatest(
applications = form.applicationKeys.map { key =>
BatchVersionLatest(
applicationKey = key,
latestVersion = latestVersions.get(key),
)
},
).validNec
}
}
}
1 change: 1 addition & 0 deletions api/conf/routes
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ PUT /:orgKey/:applicationKey controllers
DELETE /:orgKey/:applicationKey controllers.Applications.deleteByApplicationKey(orgKey: String, applicationKey: String)
POST /:orgKey/:applicationKey/move controllers.Applications.postMoveByApplicationKey(orgKey: String, applicationKey: String)
POST /:orgKey/batch/download/applications controllers.BatchDownloadApplications.post(orgKey: String)
POST /:orgKey/batch/versions/latest controllers.BatchVersionsLatest.post(orgKey: String)
POST /:orgKey/:applicationKey/:version/form controllers.Code.postForm(orgKey: String, applicationKey: String, version: String)
GET /:orgKey/:applicationKey/:version/:generatorKey controllers.Code.getByGeneratorKey(orgKey: String, applicationKey: String, version: String, generatorKey: String)
POST /:orgKey/:applicationKey/:version/:generatorKey controllers.Code.postByGeneratorKey(orgKey: String, applicationKey: String, version: String, generatorKey: String)
Expand Down
77 changes: 77 additions & 0 deletions api/test/controllers/BatchVersionsLatestSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package controllers

import org.scalatestplus.play.guice.GuiceOneServerPerSuite
import org.scalatestplus.play.PlaySpec
import io.apibuilder.api.v0.models.BatchVersionsLatestForm

class BatchVersionsLatestSpec extends PlaySpec with MockClient with GuiceOneServerPerSuite {

implicit val ec: scala.concurrent.ExecutionContext = scala.concurrent.ExecutionContext.global

private lazy val org = createOrganization()
private lazy val version = createVersion(createApplication(org))

"post" must {
"returns latest version for existing application" in {
val result = await {
client.batchVersionsLatest.post(
org.key,
BatchVersionsLatestForm(applicationKeys = Seq(version.application.key))
)
}
result.applications.size must equal(1)
result.applications.head.applicationKey must equal(version.application.key)
result.applications.head.latestVersion must equal(Some(version.version))
}

"returns no version for non-existent application" in {
val result = await {
client.batchVersionsLatest.post(
org.key,
BatchVersionsLatestForm(applicationKeys = Seq(randomString()))
)
}
result.applications.size must equal(1)
Comment on lines +28 to +34
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test asserts that latestVersion is None, but it doesn’t assert that the returned applicationKey matches the requested random key. Adding that assertion would make the test more robust (it would catch cases where the endpoint returns a placeholder or mismatched key).

Suggested change
val result = await {
client.batchVersionsLatest.post(
org.key,
BatchVersionsLatestForm(applicationKeys = Seq(randomString()))
)
}
result.applications.size must equal(1)
val nonExistent = randomString()
val result = await {
client.batchVersionsLatest.post(
org.key,
BatchVersionsLatestForm(applicationKeys = Seq(nonExistent))
)
}
result.applications.size must equal(1)
result.applications.head.applicationKey must equal(nonExistent)

Copilot uses AI. Check for mistakes.
result.applications.head.latestVersion must equal(None)
}

"handles multiple applications" in {
val version2 = createVersion(createApplication(org))
val result = await {
client.batchVersionsLatest.post(
org.key,
BatchVersionsLatestForm(applicationKeys = Seq(version.application.key, version2.application.key))
)
}
result.applications.size must equal(2)
result.applications.map(_.applicationKey) must equal(Seq(version.application.key, version2.application.key))
result.applications.foreach { app =>
app.latestVersion mustBe defined
}
}

"handles mix of existing and non-existent applications" in {
val nonExistent = randomString()
val result = await {
client.batchVersionsLatest.post(
org.key,
BatchVersionsLatestForm(applicationKeys = Seq(version.application.key, nonExistent))
)
}
result.applications.size must equal(2)
result.applications.find(_.applicationKey == version.application.key).get.latestVersion must equal(Some(version.version))
result.applications.find(_.applicationKey == nonExistent).get.latestVersion must equal(None)
}

"handles empty list" in {
val result = await {
client.batchVersionsLatest.post(
org.key,
BatchVersionsLatestForm(applicationKeys = Nil)
)
}
result.applications must equal(Nil)
}
}

}
102 changes: 102 additions & 0 deletions generated/app/ApicollectiveApibuilderApiV0Client.scala
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,28 @@ package io.apibuilder.api.v0.models {
applications: Seq[io.apibuilder.api.v0.models.BatchDownloadApplicationForm]
)

/**
* The latest version for a single application, if any versions exist.
*/
final case class BatchVersionLatest(
applicationKey: String,
latestVersion: _root_.scala.Option[String] = None
)

/**
* Response containing the latest version for each requested application.
*/
final case class BatchVersionsLatest(
applications: Seq[io.apibuilder.api.v0.models.BatchVersionLatest]
)

/**
* Form to request the latest version for multiple applications in a single API call.
*/
final case class BatchVersionsLatestForm(
applicationKeys: Seq[String]
)

/**
* Represents a single change from one version of a service to another
*
Expand Down Expand Up @@ -1425,6 +1447,57 @@ package io.apibuilder.api.v0.models {
}
}

implicit def jsonReadsApibuilderApiBatchVersionLatest: play.api.libs.json.Reads[io.apibuilder.api.v0.models.BatchVersionLatest] = {
for {
applicationKey <- (__ \ "application_key").read[String]
latestVersion <- (__ \ "latest_version").readNullable[String]
} yield BatchVersionLatest(applicationKey = applicationKey, latestVersion = latestVersion)
}

def jsObjectBatchVersionLatest(obj: io.apibuilder.api.v0.models.BatchVersionLatest): play.api.libs.json.JsObject = {
play.api.libs.json.Json.obj(
"application_key" -> play.api.libs.json.JsString(obj.applicationKey)
) ++ obj.latestVersion.fold(play.api.libs.json.Json.obj()) { v => play.api.libs.json.Json.obj("latest_version" -> play.api.libs.json.JsString(v)) }
}

implicit def jsonWritesApibuilderApiBatchVersionLatest: play.api.libs.json.Writes[BatchVersionLatest] = {
(obj: io.apibuilder.api.v0.models.BatchVersionLatest) => {
io.apibuilder.api.v0.models.json.jsObjectBatchVersionLatest(obj)
}
}

implicit def jsonReadsApibuilderApiBatchVersionsLatest: play.api.libs.json.Reads[io.apibuilder.api.v0.models.BatchVersionsLatest] = {
(__ \ "applications").read[Seq[io.apibuilder.api.v0.models.BatchVersionLatest]].map { x => BatchVersionsLatest(applications = x) }
}

def jsObjectBatchVersionsLatest(obj: io.apibuilder.api.v0.models.BatchVersionsLatest): play.api.libs.json.JsObject = {
play.api.libs.json.Json.obj(
"applications" -> play.api.libs.json.Json.toJson(obj.applications)
)
}

implicit def jsonWritesApibuilderApiBatchVersionsLatest: play.api.libs.json.Writes[BatchVersionsLatest] = {
(obj: io.apibuilder.api.v0.models.BatchVersionsLatest) => {
io.apibuilder.api.v0.models.json.jsObjectBatchVersionsLatest(obj)
}
}

implicit def jsonReadsApibuilderApiBatchVersionsLatestForm: play.api.libs.json.Reads[io.apibuilder.api.v0.models.BatchVersionsLatestForm] = {
(__ \ "application_keys").read[Seq[String]].map { x => BatchVersionsLatestForm(applicationKeys = x) }
}

def jsObjectBatchVersionsLatestForm(obj: io.apibuilder.api.v0.models.BatchVersionsLatestForm): play.api.libs.json.JsObject = {
play.api.libs.json.Json.obj(
"application_keys" -> play.api.libs.json.Json.toJson(obj.applicationKeys)
)
}

implicit def jsonWritesApibuilderApiBatchVersionsLatestForm: play.api.libs.json.Writes[BatchVersionsLatestForm] = {
(obj: io.apibuilder.api.v0.models.BatchVersionsLatestForm) => {
io.apibuilder.api.v0.models.json.jsObjectBatchVersionsLatestForm(obj)
}
}

implicit def jsonReadsApibuilderApiChange: play.api.libs.json.Reads[io.apibuilder.api.v0.models.Change] = {
for {
guid <- (__ \ "guid").read[_root_.java.util.UUID]
Expand Down Expand Up @@ -2548,6 +2621,8 @@ package io.apibuilder.api.v0 {

def batchDownloadApplications: BatchDownloadApplications = BatchDownloadApplications

def batchVersionsLatest: BatchVersionsLatest = BatchVersionsLatest

def changes: Changes = Changes

def code: Code = Code
Expand Down Expand Up @@ -2795,6 +2870,21 @@ package io.apibuilder.api.v0 {
}
}

object BatchVersionsLatest extends BatchVersionsLatest {
override def post(
orgKey: String,
batchVersionsLatestForm: io.apibuilder.api.v0.models.BatchVersionsLatestForm,
requestHeaders: Seq[(String, String)] = Nil
)(implicit ec: scala.concurrent.ExecutionContext): scala.concurrent.Future[io.apibuilder.api.v0.models.BatchVersionsLatest] = {
val payload = play.api.libs.json.Json.toJson(batchVersionsLatestForm)

_executeRequest("POST", s"/${play.utils.UriEncoding.encodePathSegment(orgKey, "UTF-8")}/batch/versions/latest", body = Some(payload), requestHeaders = requestHeaders).map {
case r if r.status == 200 => _root_.io.apibuilder.api.v0.Client.parseJson("io.apibuilder.api.v0.models.BatchVersionsLatest", r, _.validate[io.apibuilder.api.v0.models.BatchVersionsLatest])
case r => throw io.apibuilder.api.v0.errors.FailedRequest(r.status, s"Unsupported response code[${r.status}]. Expected: 200")
}
}
}

object Changes extends Changes {
override def get(
orgKey: _root_.scala.Option[String] = None,
Expand Down Expand Up @@ -3853,6 +3943,7 @@ package io.apibuilder.api.v0 {
def attributes: io.apibuilder.api.v0.Attributes
def authentications: io.apibuilder.api.v0.Authentications
def batchDownloadApplications: io.apibuilder.api.v0.BatchDownloadApplications
def batchVersionsLatest: io.apibuilder.api.v0.BatchVersionsLatest
def changes: io.apibuilder.api.v0.Changes
def code: io.apibuilder.api.v0.Code
def domains: io.apibuilder.api.v0.Domains
Expand Down Expand Up @@ -4026,6 +4117,17 @@ package io.apibuilder.api.v0 {
)(implicit ec: scala.concurrent.ExecutionContext): scala.concurrent.Future[io.apibuilder.api.v0.models.BatchDownloadApplications]
}

trait BatchVersionsLatest {
/**
* Retrieve the latest version for multiple applications in one API call.
*/
def post(
orgKey: String,
batchVersionsLatestForm: io.apibuilder.api.v0.models.BatchVersionsLatestForm,
requestHeaders: Seq[(String, String)] = Nil
)(implicit ec: scala.concurrent.ExecutionContext): scala.concurrent.Future[io.apibuilder.api.v0.models.BatchVersionsLatest]
}

trait Changes {
/**
* @param orgKey Filter changes to those made for the organization with this key.
Expand Down
37 changes: 37 additions & 0 deletions spec/apibuilder-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,29 @@
{ "name": "application_key", "type": "string" },
{ "name": "version", "type": "string", "default": "latest", "required": false }
]
},

"batch_versions_latest_form": {
"description": "Form to request the latest version for multiple applications in a single API call.",
"fields": [
{ "name": "application_keys", "type": "[string]", "description": "List of application keys to look up" }
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server enforces a max of 500 application_keys, but that limit isn’t documented in the form/field description. Please document the 500-key maximum in the description (or field description) so API consumers know the constraint before hitting runtime validation.

Suggested change
{ "name": "application_keys", "type": "[string]", "description": "List of application keys to look up" }
{ "name": "application_keys", "type": "[string]", "description": "List of application keys to look up (maximum 500 keys per request)" }

Copilot uses AI. Check for mistakes.
]
},

"batch_version_latest": {
"description": "The latest version for a single application, if any versions exist.",
"fields": [
{ "name": "application_key", "type": "string" },
{ "name": "latest_version", "type": "string", "required": false, "description": "The latest version string, or absent if no versions exist for this application" }
]
},

"batch_versions_latest": {
"plural": "batch_versions_latest",
"description": "Response containing the latest version for each requested application.",
"fields": [
{ "name": "applications", "type": "[batch_version_latest]" }
]
}
},

Expand Down Expand Up @@ -1009,6 +1032,20 @@
]
},

"batch_versions_latest": {
"path": "/:orgKey/batch/versions/latest",
"operations": [
{
"method": "POST",
"description": "Retrieve the latest version for multiple applications in one API call.",
"body": { "type": "batch_versions_latest_form" },
"responses": {
"200": { "type": "batch_versions_latest" }
}
}
]
},
Comment on lines +1035 to +1047
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec declares only a 200 response, but the controller can return a non-200 on validation failure (currently 409). This is an API-contract mismatch for generated clients. Either (a) add the error response code(s) and type(s) to the spec (e.g., 400/409 with the standard error model), or (b) change the controller to only return documented status codes.

Copilot uses AI. Check for mistakes.

"application": {
"path": "/:orgKey",
"operations": [
Expand Down
Loading