Extend extension API to support auto-updating extensions (#9929)

Marshall Bowers and Max created

This PR extends the extension API with some additional features to
support auto-updating extensions:

- The `GET /extensions` endpoint now accepts an optional `ids` parameter
that can be used to filter the results down to just the extensions with
the specified IDs.
- This should be a comma-delimited list of extension IDs (e.g.,
`wgsl,gleam,tokyo-night`).
- A new `GET /extensions/:extension_id` endpoint that returns all of the
extension versions for a particular extension.

Extracted from #9890, as these changes can be landed and deployed
independently.

Release Notes:

- N/A

Co-authored-by: Max <max@zed.dev>

Change summary

crates/collab/src/api/extensions.rs        | 54 +++++++++++---
crates/collab/src/db/queries/extensions.rs | 89 ++++++++++++++++++-----
2 files changed, 110 insertions(+), 33 deletions(-)

Detailed changes

crates/collab/src/api/extensions.rs 🔗

@@ -18,6 +18,7 @@ use util::ResultExt;
 pub fn router() -> Router {
     Router::new()
         .route("/extensions", get(get_extensions))
+        .route("/extensions/:extension_id", get(get_extension_versions))
         .route(
             "/extensions/:extension_id/download",
             get(download_latest_extension),
@@ -32,29 +33,52 @@ pub fn router() -> Router {
 struct GetExtensionsParams {
     filter: Option<String>,
     #[serde(default)]
+    ids: Option<String>,
+    #[serde(default)]
     max_schema_version: i32,
 }
 
-#[derive(Debug, Deserialize)]
-struct DownloadLatestExtensionParams {
-    extension_id: String,
+async fn get_extensions(
+    Extension(app): Extension<Arc<AppState>>,
+    Query(params): Query<GetExtensionsParams>,
+) -> Result<Json<GetExtensionsResponse>> {
+    let extension_ids = params
+        .ids
+        .as_ref()
+        .map(|s| s.split(',').map(|s| s.trim()).collect::<Vec<_>>());
+
+    let extensions = if let Some(extension_ids) = extension_ids {
+        app.db
+            .get_extensions_by_ids(&extension_ids, params.max_schema_version)
+            .await?
+    } else {
+        app.db
+            .get_extensions(params.filter.as_deref(), params.max_schema_version, 500)
+            .await?
+    };
+
+    Ok(Json(GetExtensionsResponse { data: extensions }))
 }
 
 #[derive(Debug, Deserialize)]
-struct DownloadExtensionParams {
+struct GetExtensionVersionsParams {
     extension_id: String,
-    version: String,
 }
 
-async fn get_extensions(
+async fn get_extension_versions(
     Extension(app): Extension<Arc<AppState>>,
-    Query(params): Query<GetExtensionsParams>,
+    Path(params): Path<GetExtensionVersionsParams>,
 ) -> Result<Json<GetExtensionsResponse>> {
-    let extensions = app
-        .db
-        .get_extensions(params.filter.as_deref(), params.max_schema_version, 500)
-        .await?;
-    Ok(Json(GetExtensionsResponse { data: extensions }))
+    let extension_versions = app.db.get_extension_versions(&params.extension_id).await?;
+
+    Ok(Json(GetExtensionsResponse {
+        data: extension_versions,
+    }))
+}
+
+#[derive(Debug, Deserialize)]
+struct DownloadLatestExtensionParams {
+    extension_id: String,
 }
 
 async fn download_latest_extension(
@@ -76,6 +100,12 @@ async fn download_latest_extension(
     .await
 }
 
+#[derive(Debug, Deserialize)]
+struct DownloadExtensionParams {
+    extension_id: String,
+    version: String,
+}
+
 async fn download_extension(
     Extension(app): Extension<Arc<AppState>>,
     Path(params): Path<DownloadExtensionParams>,

crates/collab/src/db/queries/extensions.rs 🔗

@@ -1,4 +1,5 @@
 use chrono::Utc;
+use sea_orm::sea_query::IntoCondition;
 
 use super::*;
 
@@ -10,37 +11,83 @@ impl Database {
         limit: usize,
     ) -> Result<Vec<ExtensionMetadata>> {
         self.transaction(|tx| async move {
-            let mut condition = Condition::all().add(
-                extension::Column::LatestVersion
-                    .into_expr()
-                    .eq(extension_version::Column::Version.into_expr()),
-            );
+            let mut condition = Condition::all()
+                .add(
+                    extension::Column::LatestVersion
+                        .into_expr()
+                        .eq(extension_version::Column::Version.into_expr()),
+                )
+                .add(extension_version::Column::SchemaVersion.lte(max_schema_version));
             if let Some(filter) = filter {
                 let fuzzy_name_filter = Self::fuzzy_like_string(filter);
                 condition = condition.add(Expr::cust_with_expr("name ILIKE $1", fuzzy_name_filter));
             }
 
-            let extensions = extension::Entity::find()
-                .inner_join(extension_version::Entity)
-                .select_also(extension_version::Entity)
-                .filter(condition)
-                .filter(extension_version::Column::SchemaVersion.lte(max_schema_version))
-                .order_by_desc(extension::Column::TotalDownloadCount)
-                .order_by_asc(extension::Column::Name)
-                .limit(Some(limit as u64))
-                .all(&*tx)
-                .await?;
+            self.get_extensions_where(condition, Some(limit as u64), &tx)
+                .await
+        })
+        .await
+    }
 
-            Ok(extensions
-                .into_iter()
-                .filter_map(|(extension, version)| {
-                    Some(metadata_from_extension_and_version(extension, version?))
-                })
-                .collect())
+    pub async fn get_extensions_by_ids(
+        &self,
+        ids: &[&str],
+        max_schema_version: i32,
+    ) -> Result<Vec<ExtensionMetadata>> {
+        self.transaction(|tx| async move {
+            let condition = Condition::all()
+                .add(
+                    extension::Column::LatestVersion
+                        .into_expr()
+                        .eq(extension_version::Column::Version.into_expr()),
+                )
+                .add(extension::Column::ExternalId.is_in(ids.iter().copied()))
+                .add(extension_version::Column::SchemaVersion.lte(max_schema_version));
+
+            self.get_extensions_where(condition, None, &tx).await
         })
         .await
     }
 
+    /// Returns all of the versions for the extension with the given ID.
+    pub async fn get_extension_versions(
+        &self,
+        extension_id: &str,
+    ) -> Result<Vec<ExtensionMetadata>> {
+        self.transaction(|tx| async move {
+            let condition = extension::Column::ExternalId
+                .eq(extension_id)
+                .into_condition();
+
+            self.get_extensions_where(condition, None, &tx).await
+        })
+        .await
+    }
+
+    async fn get_extensions_where(
+        &self,
+        condition: Condition,
+        limit: Option<u64>,
+        tx: &DatabaseTransaction,
+    ) -> Result<Vec<ExtensionMetadata>> {
+        let extensions = extension::Entity::find()
+            .inner_join(extension_version::Entity)
+            .select_also(extension_version::Entity)
+            .filter(condition)
+            .order_by_desc(extension::Column::TotalDownloadCount)
+            .order_by_asc(extension::Column::Name)
+            .limit(limit)
+            .all(tx)
+            .await?;
+
+        Ok(extensions
+            .into_iter()
+            .filter_map(|(extension, version)| {
+                Some(metadata_from_extension_and_version(extension, version?))
+            })
+            .collect())
+    }
+
     pub async fn get_extension(&self, extension_id: &str) -> Result<Option<ExtensionMetadata>> {
         self.transaction(|tx| async move {
             let extension = extension::Entity::find()