From 2fdadecd5621980b984c0f16f28162d950dcb0c5 Mon Sep 17 00:00:00 2001 From: Michael Bryzek Date: Mon, 9 Mar 2026 15:45:48 -0400 Subject: [PATCH] Add batch versions latest API endpoint POST /:orgKey/batch/versions/latest returns the latest version for multiple applications in a single request, replacing N sequential HTTP calls with one efficient DISTINCT ON query. Co-Authored-By: Claude Opus 4.6 --- api/app/controllers/BatchVersionsLatest.scala | 26 +++++ api/app/db/InternalVersionsDao.scala | 38 +++++++ .../services/BatchVersionsLatestService.scala | 35 ++++++ api/conf/routes | 1 + .../controllers/BatchVersionsLatestSpec.scala | 77 +++++++++++++ .../ApicollectiveApibuilderApiV0Client.scala | 102 ++++++++++++++++++ spec/apibuilder-api.json | 37 +++++++ 7 files changed, 316 insertions(+) create mode 100644 api/app/controllers/BatchVersionsLatest.scala create mode 100644 api/app/services/BatchVersionsLatestService.scala create mode 100644 api/test/controllers/BatchVersionsLatestSpec.scala diff --git a/api/app/controllers/BatchVersionsLatest.scala b/api/app/controllers/BatchVersionsLatest.scala new file mode 100644 index 000000000..7daeafd8e --- /dev/null +++ b/api/app/controllers/BatchVersionsLatest.scala @@ -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))) + } + } + +} diff --git a/api/app/db/InternalVersionsDao.scala b/api/app/db/InternalVersionsDao.scala index 835d2807b..6c0a95b0f 100644 --- a/api/app/db/InternalVersionsDao.scala +++ b/api/app/db/InternalVersionsDao.scala @@ -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 + | 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, diff --git a/api/app/services/BatchVersionsLatestService.scala b/api/app/services/BatchVersionsLatestService.scala new file mode 100644 index 000000000..de80625b7 --- /dev/null +++ b/api/app/services/BatchVersionsLatestService.scala @@ -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 + } + } +} diff --git a/api/conf/routes b/api/conf/routes index b9f6fe126..bc15ace6d 100644 --- a/api/conf/routes +++ b/api/conf/routes @@ -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) diff --git a/api/test/controllers/BatchVersionsLatestSpec.scala b/api/test/controllers/BatchVersionsLatestSpec.scala new file mode 100644 index 000000000..0533dcb9d --- /dev/null +++ b/api/test/controllers/BatchVersionsLatestSpec.scala @@ -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) + 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) + } + } + +} diff --git a/generated/app/ApicollectiveApibuilderApiV0Client.scala b/generated/app/ApicollectiveApibuilderApiV0Client.scala index 2d9a409c2..9694ac74c 100644 --- a/generated/app/ApicollectiveApibuilderApiV0Client.scala +++ b/generated/app/ApicollectiveApibuilderApiV0Client.scala @@ -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 * @@ -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] @@ -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 @@ -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, @@ -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 @@ -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. diff --git a/spec/apibuilder-api.json b/spec/apibuilder-api.json index 0691db6c5..28c9a9aeb 100644 --- a/spec/apibuilder-api.json +++ b/spec/apibuilder-api.json @@ -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" } + ] + }, + + "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]" } + ] } }, @@ -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" } + } + } + ] + }, + "application": { "path": "/:orgKey", "operations": [