Skip to content
Open
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
41 changes: 39 additions & 2 deletions rust/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,15 @@ impl Config {
validate_dependency(&mut errors, &format!("dependencies[{i}].app[{j}]"), dep);
}
for (j, dep) in deps.feature.iter().enumerate() {
validate_dependency(&mut errors, &format!("dependencies[{i}].feature[{j}]"), dep);
require_min_len(
&mut errors,
&format!("dependencies[{i}].feature[{j}].name"),
&dep.name,
MIN_IDENT_LEN,
);
}
}
validate_unique_feature_names(&mut errors, &self.dependencies);

if let Some(extensions) = &self.extensions {
for (i, ext) in extensions.embed.iter().enumerate() {
Expand Down Expand Up @@ -278,6 +284,20 @@ fn validate_dependency(errors: &mut Vec<String>, path: &str, dep: &DependencyCon
}
}

/// The API rejects a release that declares the same feature twice, across
/// every `[[dependencies]]` block.
fn validate_unique_feature_names(errors: &mut Vec<String>, dependencies: &[DependenciesConfig]) {
let mut seen = std::collections::HashSet::new();
for dep in dependencies.iter().flat_map(|d| &d.feature) {
if !seen.insert(dep.name.as_str()) {
errors.push(format!(
"dependencies feature {:?} is declared more than once",
dep.name
));
}
}
}

fn validate_named_extension(
errors: &mut Vec<String>,
path: &str,
Expand Down Expand Up @@ -325,7 +345,7 @@ pub struct DependenciesConfig {
#[serde(default)]
pub app: Vec<DependencyConfig>,
#[serde(default)]
pub feature: Vec<DependencyConfig>,
pub feature: Vec<FeatureDependencyConfig>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand All @@ -334,6 +354,23 @@ pub struct DependencyConfig {
pub version: Option<String>,
}

/// A feature the release declares, sent as `createRelease.featureDependencies`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeatureDependencyConfig {
pub name: String,
pub state: FeatureDependencyState,
}

/// Mirrors the App Registry `FeatureDependencyState` enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum FeatureDependencyState {
Required,
Excluded,
Enabled,
Disabled,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionsConfig {
#[serde(default)]
Expand Down
29 changes: 29 additions & 0 deletions rust/src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,35 @@ fn validate_rejects_subscription_without_events() {
assert!(err.to_string().contains("events"), "{err}");
}

#[test]
fn validate_rejects_duplicate_feature_dependency_across_blocks() {
let mut config = valid_config();
for _ in 0..2 {
config.dependencies.push(DependenciesConfig {
app: vec![],
feature: vec![FeatureDependencyConfig {
name: "automations".to_owned(),
state: FeatureDependencyState::Enabled,
}],
});
}
let err = config.validate().expect_err("duplicate feature");
assert!(
err.to_string()
.contains("feature \"automations\" is declared more than once"),
"{err}"
);
}

#[test]
fn parse_rejects_unknown_feature_dependency_state() {
let err = toml::from_str::<DependenciesConfig>(
r#"feature = [{ name = "automations", state = "ON" }]"#,
)
.expect_err("unknown state");
assert!(err.to_string().contains("unknown variant"), "{err}");
}

#[test]
fn validate_rejects_dependency_with_bad_semver() {
let mut config = valid_config();
Expand Down
167 changes: 122 additions & 45 deletions rust/src/platform/app/commands/release.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ use std::path::Path;
use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier};
use platform_app_client::create_release::{
ApplicationActionCreateInput, ApplicationSettingCreateInput,
ApplicationSubscriptionCreateInput, ApplicationUiExtensionCreateInput,
MutationCreateReleaseInput,
ApplicationSubscriptionCreateInput, ApplicationUiExtensionCreateInput, FeatureDependencyInput,
FeatureDependencyState, MutationCreateReleaseInput,
};
use serde_json::{Value, json};

Expand Down Expand Up @@ -148,6 +148,26 @@ fn build_settings(
.collect()
}

/// Map godaddy.toml `[[dependencies]]` feature entries to the release
/// `featureDependencies` input.
fn build_feature_dependencies(config: &crate::config::Config) -> Vec<FeatureDependencyInput> {
use crate::config::FeatureDependencyState as State;
config
.dependencies
.iter()
.flat_map(|d| &d.feature)
.map(|f| FeatureDependencyInput {
name: f.name.clone(),
state: match f.state {
State::Required => FeatureDependencyState::REQUIRED,
State::Excluded => FeatureDependencyState::EXCLUDED,
State::Enabled => FeatureDependencyState::ENABLED,
State::Disabled => FeatureDependencyState::DISABLED,
},
})
.collect()
}

/// Missing manifest returns `Ok(None)`; a manifest that exists but fails to
/// read, parse, or validate is an error rather than a silent empty fallback.
fn load_manifest(path: &Path) -> cli_engine::Result<Option<crate::config::Config>> {
Expand Down Expand Up @@ -237,48 +257,63 @@ pub(super) fn command() -> RuntimeCommandSpec {

let config_path = crate::config::config_path(Some(&ctx.middleware.env));
let manifest_dir = config_path.parent().unwrap_or_else(|| Path::new(""));
// Pulls actions/subscriptions/uiExtensions/settings from godaddy.toml; see load_manifest.
let (actions, subscriptions, ui_extensions, settings, native_extension) =
match load_manifest(&config_path)? {
Some(config) => {
let actions: Vec<ApplicationActionCreateInput> = config
.actions
.iter()
.map(|a| ApplicationActionCreateInput {
name: a.name.clone(),
url: a.url.clone(),
})
.collect();
let subscriptions: Vec<ApplicationSubscriptionCreateInput> = config
.subscriptions
.as_ref()
.map(|s| {
s.webhook
.iter()
.map(|w| ApplicationSubscriptionCreateInput {
name: w.name.clone(),
events: w.events.clone(),
url: w.url.clone(),
})
.collect()
})
.unwrap_or_default();
let ui_extensions = build_ui_extensions(&config)?;
let settings = build_settings(&config, manifest_dir)?;
let native_extension = native_extension::native_extension_draft_if_visible(
&config,
&ctx.middleware.flag_policy,
);
(
actions,
subscriptions,
ui_extensions,
settings,
native_extension,
)
}
None => (Vec::new(), Vec::new(), Vec::new(), Vec::new(), None),
};
// Pulls actions/subscriptions/uiExtensions/settings/feature deps from godaddy.toml; see load_manifest.
let (
actions,
subscriptions,
ui_extensions,
settings,
feature_dependencies,
native_extension,
) = match load_manifest(&config_path)? {
Some(config) => {
let actions: Vec<ApplicationActionCreateInput> = config
.actions
.iter()
.map(|a| ApplicationActionCreateInput {
name: a.name.clone(),
url: a.url.clone(),
})
.collect();
let subscriptions: Vec<ApplicationSubscriptionCreateInput> = config
.subscriptions
.as_ref()
.map(|s| {
s.webhook
.iter()
.map(|w| ApplicationSubscriptionCreateInput {
name: w.name.clone(),
events: w.events.clone(),
url: w.url.clone(),
})
.collect()
})
.unwrap_or_default();
let ui_extensions = build_ui_extensions(&config)?;
let settings = build_settings(&config, manifest_dir)?;
let feature_dependencies = build_feature_dependencies(&config);
let native_extension = native_extension::native_extension_draft_if_visible(
&config,
&ctx.middleware.flag_policy,
);
(
actions,
subscriptions,
ui_extensions,
settings,
feature_dependencies,
native_extension,
)
}
None => (
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
None,
),
};

let mut input = MutationCreateReleaseInput {
application_id: app_id,
Expand All @@ -288,7 +323,7 @@ pub(super) fn command() -> RuntimeCommandSpec {
subscriptions: Some(subscriptions),
settings: Some(settings),
application_dependencies: None,
feature_dependencies: None,
feature_dependencies: Some(feature_dependencies),
mcp_servers: None,
native_extensions: None,
ui_extensions: None,
Expand Down Expand Up @@ -761,6 +796,48 @@ authorization_scopes = []
);
}

#[test]
fn build_feature_dependencies_maps_manifest_entries() {
use platform_app_client::create_release::FeatureDependencyState;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("godaddy.toml");
std::fs::write(
&path,
r#"
name = "my-app"
client_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6"
version = "1.0.0"
url = "https://example.com"
proxy_url = "https://example.com/proxy"
authorization_scopes = ["openid"]

[[dependencies]]
feature = [
{ name = "automations", state = "ENABLED" },
{ name = "payouts", state = "EXCLUDED" },
]
"#,
)
.expect("write manifest");
let config = super::load_manifest(&path)
.expect("manifest loads")
.expect("manifest exists");

let deps = super::build_feature_dependencies(&config);

let mapped: Vec<_> = deps.iter().map(|d| (d.name.as_str(), &d.state)).collect();
assert!(
matches!(
mapped.as_slice(),
[
("automations", FeatureDependencyState::ENABLED),
("payouts", FeatureDependencyState::EXCLUDED),
]
),
"unexpected mapping: {deps:?}"
);
}

#[test]
fn ui_extension_entry_rejects_multiple_targets() {
use crate::config::ExtensionTarget;
Expand Down
Loading