diff --git a/lib/backend-api/schema.graphql b/lib/backend-api/schema.graphql index a43d1b64fff..ee38b06e560 100644 --- a/lib/backend-api/schema.graphql +++ b/lib/backend-api/schema.graphql @@ -16,6 +16,9 @@ interface Node { type PublicKey implements Node { """The ID of the object""" id: ID! + createdAt: DateTime! + updatedAt: DateTime! + deletedAt: DateTime owner: User! keyId: String! key: String! @@ -25,6 +28,13 @@ type PublicKey implements Node { revoked: Boolean! } +""" +The `DateTime` scalar type represents a DateTime +value as specified by +[iso8601](https://en.wikipedia.org/wiki/ISO_8601). +""" +scalar DateTime + type User implements Node & PackageOwner & Owner { firstName: String! lastName: String! @@ -84,13 +94,6 @@ interface Owner { globalId: ID! } -""" -The `DateTime` scalar type represents a DateTime -value as specified by -[iso8601](https://en.wikipedia.org/wiki/ISO_8601). -""" -scalar DateTime - type ActivityEventConnection { """Pagination data for this connection.""" pageInfo: PageInfo! @@ -190,6 +193,7 @@ type NamespaceEdge { type Namespace implements Node & PackageOwner & Owner { """The ID of the object""" id: ID! + deletedAt: DateTime name: String! displayName: String description: String! @@ -249,6 +253,8 @@ type NamespaceCollaboratorInviteEdge { type NamespaceCollaboratorInvite implements Node { """The ID of the object""" id: ID! + updatedAt: DateTime! + deletedAt: DateTime requestedBy: User! user: User inviteEmail: String @@ -279,6 +285,7 @@ enum RegistryNamespaceMaintainerInviteRoleChoices { type NamespaceCollaborator implements Node { """The ID of the object""" id: ID! + deletedAt: DateTime user: User! role: RegistryNamespaceMaintainerRoleChoices! namespace: Namespace! @@ -344,6 +351,7 @@ type PackageEdge { type Package implements Likeable & Node & PackageOwner { """The ID of the object""" id: ID! + deletedAt: DateTime name: String! private: Boolean! createdAt: DateTime! @@ -939,6 +947,9 @@ type BindingsGeneratorEdge { type BindingsGenerator implements Node { """The ID of the object""" id: ID! + createdAt: DateTime! + updatedAt: DateTime! + deletedAt: DateTime packageVersion: PackageVersion! active: Boolean! commandName: String! @@ -1103,6 +1114,7 @@ type PackageVersionFilesystem { type InterfaceVersion implements Node { """The ID of the object""" id: ID! + deletedAt: DateTime interface: Interface! version: String! content: String! @@ -1115,6 +1127,7 @@ type InterfaceVersion implements Node { type Interface implements Node { """The ID of the object""" id: ID! + deletedAt: DateTime name: String! displayName: String! description: String! @@ -1235,11 +1248,13 @@ type AppTemplate implements Node { updatedAt: DateTime! readme: String! useCases: JSONString! - framework: String! - language: String! repoLicense: String! usingPackage: Package defaultImage: String + framework: String! + templateFramework: TemplateFramework + language: String! + templateLanguage: TemplateLanguage } type AppTemplateCategory implements Node { @@ -1253,6 +1268,24 @@ type AppTemplateCategory implements Node { appTemplates(offset: Int, before: String, after: String, first: Int, last: Int): AppTemplateConnection! } +type TemplateFramework implements Node { + """The ID of the object""" + id: ID! + createdAt: DateTime! + updatedAt: DateTime! + name: String! + slug: String! +} + +type TemplateLanguage implements Node { + """The ID of the object""" + id: ID! + createdAt: DateTime! + updatedAt: DateTime! + name: String! + slug: String! +} + type Collection { slug: String! displayName: String! @@ -1340,6 +1373,7 @@ type PackageCollaboratorEdge { type PackageCollaborator implements Node { """The ID of the object""" id: ID! + deletedAt: DateTime user: User! role: RegistryPackageMaintainerRoleChoices! package: Package! @@ -1365,6 +1399,8 @@ enum RegistryPackageMaintainerRoleChoices { type PackageCollaboratorInvite implements Node { """The ID of the object""" id: ID! + updatedAt: DateTime! + deletedAt: DateTime requestedBy: User! user: User inviteEmail: String @@ -1422,6 +1458,8 @@ enum GrapheneRole { type PackageTransferRequest implements Node { """The ID of the object""" id: ID! + updatedAt: DateTime! + deletedAt: DateTime requestedBy: User! previousOwnerObjectId: Int! newOwnerObjectId: Int! @@ -1540,14 +1578,14 @@ type DNSDomainEdge { } type DNSDomain implements Node { + createdAt: DateTime! + updatedAt: DateTime! + deletedAt: DateTime name: String! """This zone will be accessible at /dns/{slug}/.""" slug: String! zoneFile: String! - createdAt: DateTime! - updatedAt: DateTime! - deletedAt: DateTime """The ID of the object""" id: ID! @@ -1860,6 +1898,8 @@ type APITokenEdge { type APIToken { id: ID! + updatedAt: DateTime! + deletedAt: DateTime user: User! identifier: String createdAt: DateTime! @@ -1976,6 +2016,8 @@ type SocialAuth implements Node { type Signature { id: ID! + updatedAt: DateTime! + deletedAt: DateTime publicKey: PublicKey! data: String! createdAt: DateTime! @@ -2258,7 +2300,9 @@ type Query { getAppByGlobalAlias(alias: String!): DeployApp getDeployApps(sortBy: DeployAppsSortBy, updatedAfter: DateTime, offset: Int, before: String, after: String, first: Int, last: Int): DeployAppConnection! getAppVersions(sortBy: DeployAppVersionsSortBy, updatedAfter: DateTime, offset: Int, before: String, after: String, first: Int, last: Int): DeployAppVersionConnection! - getAppTemplates(categorySlug: String, sortBy: AppTemplatesSortBy, offset: Int, before: String, after: String, first: Int, last: Int): AppTemplateConnection + getTemplateFrameworks(offset: Int, before: String, after: String, first: Int, last: Int): TemplateFrameworkConnection + getTemplateLanguages(offset: Int, before: String, after: String, first: Int, last: Int): TemplateLanguageConnection + getAppTemplates(categorySlug: String, frameworkSlug: String, languageSlug: String, sortBy: AppTemplatesSortBy, offset: Int, before: String, after: String, first: Int, last: Int): AppTemplateConnection getAppTemplate(slug: String!): AppTemplate getAppTemplateCategories(offset: Int, before: String, after: String, first: Int, last: Int): AppTemplateCategoryConnection viewer: User @@ -2357,6 +2401,46 @@ enum DNSRecordsSortBy { OLDEST } +type TemplateFrameworkConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [TemplateFrameworkEdge]! + + """Total number of items in the connection.""" + totalCount: Int +} + +"""A Relay edge containing a `TemplateFramework` and its cursor.""" +type TemplateFrameworkEdge { + """The item at the end of the edge""" + node: TemplateFramework + + """A cursor for use in pagination""" + cursor: String! +} + +type TemplateLanguageConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [TemplateLanguageEdge]! + + """Total number of items in the connection.""" + totalCount: Int +} + +"""A Relay edge containing a `TemplateLanguage` and its cursor.""" +type TemplateLanguageEdge { + """The item at the end of the edge""" + node: TemplateLanguage + + """A cursor for use in pagination""" + cursor: String! +} + enum AppTemplatesSortBy { NEWEST OLDEST @@ -3066,6 +3150,8 @@ type RequestAppTransferPayload { type AppTransferRequest implements Node { """The ID of the object""" id: ID! + updatedAt: DateTime! + deletedAt: DateTime requestedBy: User! previousOwnerObjectId: Int! newOwnerObjectId: Int! diff --git a/lib/backend-api/src/query.rs b/lib/backend-api/src/query.rs index 341761e184b..f42eeff86a0 100644 --- a/lib/backend-api/src/query.rs +++ b/lib/backend-api/src/query.rs @@ -13,10 +13,12 @@ use crate::{ types::{ self, CreateNamespaceVars, DeployApp, DeployAppConnection, DeployAppVersion, DeployAppVersionConnection, DnsDomain, GetAppTemplateFromSlugVariables, - GetAppTemplatesVars, GetCurrentUserWithAppsVars, GetDeployAppAndVersion, - GetDeployAppVersionsVars, GetNamespaceAppsVars, GetSignedUrlForPackageUploadVariables, Log, - LogStream, PackageVersionConnection, PublishDeployAppVars, PushPackageReleasePayload, - SignedUrl, TagPackageReleasePayload, UpsertDomainFromZoneFileVars, + GetAppTemplatesFromFrameworkVars, GetAppTemplatesFromLanguageVars, GetAppTemplatesVars, + GetCurrentUserWithAppsVars, GetDeployAppAndVersion, GetDeployAppVersionsVars, + GetNamespaceAppsVars, GetSignedUrlForPackageUploadVariables, GetTemplateFrameworksVars, + GetTemplateLanguagesVars, Log, LogStream, PackageVersionConnection, PublishDeployAppVars, + PushPackageReleasePayload, SignedUrl, TagPackageReleasePayload, + UpsertDomainFromZoneFileVars, }, GraphQLApiFailure, WasmerClient, }; @@ -69,6 +71,27 @@ pub async fn fetch_app_template_from_slug( .map(|v| v.get_app_template) } +/// Fetch app templates. +pub async fn fetch_app_templates_from_framework( + client: &WasmerClient, + framework_slug: String, + first: i32, + after: Option, + sort_by: Option, +) -> Result, anyhow::Error> { + client + .run_graphql_strict(types::GetAppTemplatesFromFramework::build( + GetAppTemplatesFromFrameworkVars { + framework_slug, + first, + after, + sort_by, + }, + )) + .await + .map(|r| r.get_app_templates) +} + /// Fetch app templates. pub async fn fetch_app_templates( client: &WasmerClient, @@ -145,6 +168,270 @@ pub fn fetch_all_app_templates( ) } +/// Fetch all app templates by paginating through the responses. +/// +/// Will fetch at most `max` templates. +pub fn fetch_all_app_templates_from_language( + client: &WasmerClient, + page_size: i32, + sort_by: Option, + language: String, +) -> impl futures::Stream, anyhow::Error>> + '_ { + let vars = GetAppTemplatesFromLanguageVars { + language_slug: language.clone().to_string(), + first: page_size, + sort_by, + after: None, + }; + + futures::stream::try_unfold( + Some(vars), + move |vars: Option| async move { + let vars = match vars { + Some(vars) => vars, + None => return Ok(None), + }; + + let con = client + .run_graphql_strict(types::GetAppTemplatesFromLanguage::build(vars.clone())) + .await? + .get_app_templates + .context("backend did not return any data")?; + + let items = con + .edges + .into_iter() + .flatten() + .filter_map(|edge| edge.node) + .collect::>(); + + let next_cursor = con + .page_info + .end_cursor + .filter(|_| con.page_info.has_next_page); + + let next_vars = next_cursor.map(|after| types::GetAppTemplatesFromLanguageVars { + after: Some(after), + ..vars + }); + + #[allow(clippy::type_complexity)] + let res: Result< + Option<( + Vec, + Option, + )>, + anyhow::Error, + > = Ok(Some((items, next_vars))); + + res + }, + ) +} + +/// Fetch languages from available app templates. +pub async fn fetch_app_template_languages( + client: &WasmerClient, + after: Option, + first: Option, +) -> Result, anyhow::Error> { + client + .run_graphql_strict(types::GetTemplateLanguages::build( + GetTemplateLanguagesVars { after, first }, + )) + .await + .map(|r| r.get_template_languages) +} + +/// Fetch all languages from available app templates by paginating through the responses. +/// +/// Will fetch at most `max` templates. +pub fn fetch_all_app_template_languages( + client: &WasmerClient, + page_size: Option, +) -> impl futures::Stream, anyhow::Error>> + '_ { + let vars = GetTemplateLanguagesVars { + after: None, + first: page_size, + }; + + futures::stream::try_unfold( + Some(vars), + move |vars: Option| async move { + let vars = match vars { + Some(vars) => vars, + None => return Ok(None), + }; + + let con = client + .run_graphql_strict(types::GetTemplateLanguages::build(vars.clone())) + .await? + .get_template_languages + .context("backend did not return any data")?; + + let items = con + .edges + .into_iter() + .flatten() + .filter_map(|edge| edge.node) + .collect::>(); + + let next_cursor = con + .page_info + .end_cursor + .filter(|_| con.page_info.has_next_page); + + let next_vars = next_cursor.map(|after| types::GetTemplateLanguagesVars { + after: Some(after), + ..vars + }); + + #[allow(clippy::type_complexity)] + let res: Result< + Option<( + Vec, + Option, + )>, + anyhow::Error, + > = Ok(Some((items, next_vars))); + + res + }, + ) +} + +/// Fetch all app templates by paginating through the responses. +/// +/// Will fetch at most `max` templates. +pub fn fetch_all_app_templates_from_framework( + client: &WasmerClient, + page_size: i32, + sort_by: Option, + framework: String, +) -> impl futures::Stream, anyhow::Error>> + '_ { + let vars = GetAppTemplatesFromFrameworkVars { + framework_slug: framework.clone().to_string(), + first: page_size, + sort_by, + after: None, + }; + + futures::stream::try_unfold( + Some(vars), + move |vars: Option| async move { + let vars = match vars { + Some(vars) => vars, + None => return Ok(None), + }; + + let con = client + .run_graphql_strict(types::GetAppTemplatesFromFramework::build(vars.clone())) + .await? + .get_app_templates + .context("backend did not return any data")?; + + let items = con + .edges + .into_iter() + .flatten() + .filter_map(|edge| edge.node) + .collect::>(); + + let next_cursor = con + .page_info + .end_cursor + .filter(|_| con.page_info.has_next_page); + + let next_vars = next_cursor.map(|after| types::GetAppTemplatesFromFrameworkVars { + after: Some(after), + ..vars + }); + + #[allow(clippy::type_complexity)] + let res: Result< + Option<( + Vec, + Option, + )>, + anyhow::Error, + > = Ok(Some((items, next_vars))); + + res + }, + ) +} + +/// Fetch frameworks from available app templates. +pub async fn fetch_app_template_frameworks( + client: &WasmerClient, + after: Option, + first: Option, +) -> Result, anyhow::Error> { + client + .run_graphql_strict(types::GetTemplateFrameworks::build( + GetTemplateFrameworksVars { after, first }, + )) + .await + .map(|r| r.get_template_frameworks) +} + +/// Fetch all frameworks from available app templates by paginating through the responses. +/// +/// Will fetch at most `max` templates. +pub fn fetch_all_app_template_frameworks( + client: &WasmerClient, + page_size: Option, +) -> impl futures::Stream, anyhow::Error>> + '_ { + let vars = GetTemplateFrameworksVars { + after: None, + first: page_size, + }; + + futures::stream::try_unfold( + Some(vars), + move |vars: Option| async move { + let vars = match vars { + Some(vars) => vars, + None => return Ok(None), + }; + + let con = client + .run_graphql_strict(types::GetTemplateFrameworks::build(vars.clone())) + .await? + .get_template_frameworks + .context("backend did not return any data")?; + + let items = con + .edges + .into_iter() + .flatten() + .filter_map(|edge| edge.node) + .collect::>(); + + let next_cursor = con + .page_info + .end_cursor + .filter(|_| con.page_info.has_next_page); + + let next_vars = next_cursor.map(|after| types::GetTemplateFrameworksVars { + after: Some(after), + ..vars + }); + + #[allow(clippy::type_complexity)] + let res: Result< + Option<( + Vec, + Option, + )>, + anyhow::Error, + > = Ok(Some((items, next_vars))); + + res + }, + ) +} + /// Get a signed URL to upload packages. pub async fn get_signed_url_for_package_upload( client: &WasmerClient, diff --git a/lib/backend-api/src/types.rs b/lib/backend-api/src/types.rs index e4ac2c38b75..ef3f1cb07b8 100644 --- a/lib/backend-api/src/types.rs +++ b/lib/backend-api/src/types.rs @@ -158,6 +158,46 @@ mod queries { Popular, } + #[derive(cynic::QueryVariables, Debug, Clone)] + pub struct GetAppTemplatesFromFrameworkVars { + pub framework_slug: String, + pub first: i32, + pub after: Option, + pub sort_by: Option, + } + + #[derive(cynic::QueryFragment, Debug)] + #[cynic(graphql_type = "Query", variables = "GetAppTemplatesFromFrameworkVars")] + pub struct GetAppTemplatesFromFramework { + #[arguments( + frameworkSlug: $framework_slug, + first: $first, + after: $after, + sortBy: $sort_by + )] + pub get_app_templates: Option, + } + + #[derive(cynic::QueryVariables, Debug, Clone)] + pub struct GetAppTemplatesFromLanguageVars { + pub language_slug: String, + pub first: i32, + pub after: Option, + pub sort_by: Option, + } + + #[derive(cynic::QueryFragment, Debug)] + #[cynic(graphql_type = "Query", variables = "GetAppTemplatesFromLanguageVars")] + pub struct GetAppTemplatesFromLanguage { + #[arguments( + languageSlug: $language_slug, + first: $first, + after: $after, + sortBy: $sort_by + )] + pub get_app_templates: Option, + } + #[derive(cynic::QueryVariables, Debug, Clone)] pub struct GetAppTemplatesVars { pub category_slug: String, @@ -215,6 +255,80 @@ mod queries { pub use_cases: Jsonstring, } + #[derive(cynic::QueryVariables, Debug, Clone)] + pub struct GetTemplateFrameworksVars { + pub after: Option, + pub first: Option, + } + + #[derive(cynic::QueryFragment, Debug)] + #[cynic(graphql_type = "Query", variables = "GetTemplateFrameworksVars")] + pub struct GetTemplateFrameworks { + #[arguments(after: $after, first: $first)] + pub get_template_frameworks: Option, + } + + #[derive(cynic::QueryFragment, Debug)] + pub struct TemplateFrameworkConnection { + pub edges: Vec>, + pub page_info: PageInfo, + pub total_count: Option, + } + + #[derive(cynic::QueryFragment, Debug)] + pub struct TemplateFrameworkEdge { + pub cursor: String, + pub node: Option, + } + + #[derive(serde::Serialize, cynic::QueryFragment, PartialEq, Eq, Debug)] + pub struct TemplateFramework { + #[serde(rename = "createdAt")] + pub created_at: DateTime, + pub id: cynic::Id, + pub name: String, + pub slug: String, + #[serde(rename = "updatedAt")] + pub updated_at: DateTime, + } + + #[derive(cynic::QueryVariables, Debug, Clone)] + pub struct GetTemplateLanguagesVars { + pub after: Option, + pub first: Option, + } + + #[derive(cynic::QueryFragment, Debug)] + #[cynic(graphql_type = "Query", variables = "GetTemplateLanguagesVars")] + pub struct GetTemplateLanguages { + #[arguments(after: $after, first: $first)] + pub get_template_languages: Option, + } + + #[derive(cynic::QueryFragment, Debug)] + pub struct TemplateLanguageConnection { + pub edges: Vec>, + pub page_info: PageInfo, + pub total_count: Option, + } + + #[derive(cynic::QueryFragment, Debug)] + pub struct TemplateLanguageEdge { + pub cursor: String, + pub node: Option, + } + + #[derive(serde::Serialize, cynic::QueryFragment, PartialEq, Eq, Debug)] + pub struct TemplateLanguage { + #[serde(rename = "createdAt")] + pub created_at: DateTime, + pub id: cynic::Id, + pub name: String, + pub slug: String, + #[serde(rename = "updatedAt")] + pub updated_at: DateTime, + } + #[derive(cynic::Scalar, Debug, Clone, PartialEq, Eq)] #[cynic(graphql_type = "JSONString")] pub struct Jsonstring(pub String); diff --git a/lib/cli/src/commands/app/create.rs b/lib/cli/src/commands/app/create.rs index 6c635cc3ec6..41bdfa9a2ae 100644 --- a/lib/cli/src/commands/app/create.rs +++ b/lib/cli/src/commands/app/create.rs @@ -14,7 +14,10 @@ use colored::Colorize; use dialoguer::{theme::ColorfulTheme, Confirm, Select}; use futures::stream::TryStreamExt; use is_terminal::IsTerminal; -use wasmer_api::{types::AppTemplate, WasmerClient}; +use wasmer_api::{ + types::{AppTemplate, TemplateLanguage}, + WasmerClient, +}; use wasmer_config::{app::AppConfigV1, package::PackageSource}; use super::{deploy::CmdAppDeploy, util::login_user}; @@ -313,31 +316,12 @@ impl CmdAppCreate { Ok(false) } - /// Load cached templates from a file. - /// - /// Returns an error if the cache file is older than the max age. - fn load_cached_templates( - path: &Path, - ) -> Result<(Vec, std::time::Duration), anyhow::Error> { - let modified = path.metadata()?.modified()?; - let age = modified.elapsed()?; - - let data = std::fs::read_to_string(path)?; - match serde_json::from_str::>(&data) { - Ok(v) => Ok((v, age)), - Err(err) => { - std::fs::remove_file(path).ok(); - Err(err).context("could not deserialize cached file") - } - } - } - - fn persist_template_cache(path: &Path, templates: &[AppTemplate]) -> Result<(), anyhow::Error> { + fn persist_in_cache(path: &Path, data: &S) -> Result<(), anyhow::Error> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).context("could not create cache dir")?; } - let data = serde_json::to_vec(templates)?; + let data = serde_json::to_vec(data)?; std::fs::write(path, data)?; tracing::trace!(path=%path.display(), "persisted app template cache"); @@ -351,14 +335,15 @@ impl CmdAppCreate { async fn fetch_templates_cached( client: &WasmerClient, cache_dir: &Path, + language: &str, ) -> Result, anyhow::Error> { const MAX_CACHE_AGE: Duration = Duration::from_secs(60 * 60); const MAX_COUNT: usize = 100; - const CACHE_FILENAME: &str = "app_templates.json"; + let cache_filename = format!("app_templates_{language}.json"); - let cache_path = cache_dir.join(CACHE_FILENAME); + let cache_path = cache_dir.join(cache_filename); - let cached_items = match Self::load_cached_templates(&cache_path) { + let cached_items = match Self::load_cached::>(&cache_path) { Ok((items, age)) => { if age <= MAX_CACHE_AGE { return Ok(items); @@ -374,10 +359,96 @@ impl CmdAppCreate { // Either no cache present, or cache has exceeded max age. // Fetch the first page. // If first item matches, then no need to re-fetch. - let mut stream = Box::pin(wasmer_api::query::fetch_all_app_templates( + // + let stream = wasmer_api::query::fetch_all_app_templates_from_language( client, 10, Some(wasmer_api::types::AppTemplatesSortBy::Newest), + language.to_string(), + ); + + futures_util::pin_mut!(stream); + + let first_page = match stream.try_next().await? { + Some(items) => items, + None => return Ok(Vec::new()), + }; + + if let (Some(a), Some(b)) = (cached_items.first(), first_page.first()) { + if a == b { + // Cached items are up to date, no need to query more. + return Ok(cached_items); + } + } + + let mut items = first_page; + while let Some(next) = stream.try_next().await? { + items.extend(next); + + if items.len() >= MAX_COUNT { + break; + } + } + + // Persist to cache. + if let Err(err) = Self::persist_in_cache(&cache_path, &items) { + tracing::trace!(error = &*err, "could not persist template cache"); + } + + // TODO: sort items by popularity! + // Since we can't rely on backend sorting because of the cache + // preservation logic, the backend needs to add a popluarity field. + + Ok(items) + } + + /// Load cached data from a file. + /// + /// Returns an error if the cache file is older than the max age. + fn load_cached( + path: &Path, + ) -> Result<(D, std::time::Duration), anyhow::Error> { + let modified = path.metadata()?.modified()?; + let age = modified.elapsed()?; + + let data = std::fs::read_to_string(path)?; + match serde_json::from_str::(data.as_str()) { + Ok(v) => Ok((v, age)), + Err(err) => { + std::fs::remove_file(path).ok(); + Err(err).context("could not deserialize cached file") + } + } + } + + async fn fetch_template_languages_cached( + client: &WasmerClient, + cache_dir: &Path, + ) -> anyhow::Result> { + const MAX_CACHE_AGE: Duration = Duration::from_secs(60 * 60); + const MAX_COUNT: usize = 100; + const CACHE_FILENAME: &str = "app_languages.json"; + + let cache_path = cache_dir.join(CACHE_FILENAME); + + let cached_items = match Self::load_cached::>(&cache_path) { + Ok((items, age)) => { + if age <= MAX_CACHE_AGE { + return Ok(items); + } + items + } + Err(e) => { + tracing::trace!(error = &*e, "could not load templates from local cache"); + Vec::new() + } + }; + // + // Either no cache present, or cache has exceeded max age. + // Fetch the first page. + // If first item matches, then no need to re-fetch. + let mut stream = Box::pin(wasmer_api::query::fetch_all_app_template_languages( + client, None, )); let first_page = match stream.try_next().await? { @@ -402,7 +473,7 @@ impl CmdAppCreate { } // Persist to cache. - if let Err(err) = Self::persist_template_cache(&cache_path, &items) { + if let Err(err) = Self::persist_in_cache(&cache_path, &items) { tracing::trace!(error = &*err, "could not persist template cache"); } @@ -430,29 +501,44 @@ impl CmdAppCreate { anyhow::bail!("No template selected") } - let templates = Self::fetch_templates_cached(client, &self.env.cache_dir).await?; - let theme = ColorfulTheme::default(); + let languages = + Self::fetch_template_languages_cached(client, &self.env.cache_dir).await?; + + let items = languages.iter().map(|t| t.name.clone()).collect::>(); + + // Note: this should really use `dialoger::FuzzySelect`, but that + // breaks the formatting. + let dialog = dialoguer::Select::with_theme(&theme) + .with_prompt(format!("Select a language ({} available)", items.len())) + .items(&items) + .max_length(10) + .clear(true) + .report(true) + .default(0); + + let selection = dialog.interact()?; + + let selected_language = languages + .get(selection) + .ok_or(anyhow::anyhow!("Invalid selection!"))?; + + let templates = + Self::fetch_templates_cached(client, &self.env.cache_dir, &selected_language.slug) + .await?; let items = templates .iter() .map(|t| { format!( - "{}{}\n {} {}", + "{} - {} {}", t.name.bold(), - if t.language.is_empty() { - String::new() - } else { - format!(" {}", t.language.dimmed()) - }, "demo:".bold().dimmed(), t.demo_url.dimmed() ) }) .collect::>(); - // Note: this should really use `dialoger::FuzzySelect`, but that - // breaks the formatting. let dialog = dialoguer::Select::with_theme(&theme) .with_prompt(format!("Select a template ({} available)", items.len())) .items(&items) @@ -469,7 +555,7 @@ impl CmdAppCreate { if !self.quiet { eprintln!( - "{} {} {} {} ({} {})", + "{} {} {} {} - {} {}", "✔".green().bold(), "Selected template".bold(), "·".dimmed(),