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
3 changes: 3 additions & 0 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ jobs:
- name: Test
run: cargo test

- name: Smoke test
run: ./scripts/smoke-test.sh

- name: Check module size limits
working-directory: .
run: ./rust/scripts/check-module-size.sh
Expand Down
21 changes: 20 additions & 1 deletion docs/application-settings.md

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do we need to maintain this file or is the guide sufficient?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think it is nice to have, specially GPA settings usage is a bit complex at the beginning

Original file line number Diff line number Diff line change
Expand Up @@ -146,15 +146,34 @@ presentationFile = "fixtures/manual-tax-registry-presentation.json"

The referenced file must be the complete API presentation object — `type` (`"form"`), `schemaVersion` (`"settings-form-v1"`), and `sections` — the same shape `createRelease.settings[].presentation` expects, so an existing fixture can be reused verbatim. The path is relative to the directory containing the `godaddy.toml` being released, not the shell's working directory. `presentation` and `presentationFile` are mutually exclusive; both forms run through the same field/section validation and produce an identical release payload. The file itself is only opened at `release` — like inline `presentation`, it's optional at `add settings`/`config validate` time — and a missing, unreadable, malformed, or wrong-`type`/`schemaVersion` file fails the release with a `VALIDATION_ERROR` naming the resolved path.

## Link presentation (`settings-link-v1`)

For a GPA that must own its own configuration page or provider authorization flow instead of a native form, `presentation` can instead be a link:

```toml
[[settings]]
group = "payment-methods"
slug = "paypal-payments"
entryPath = "/settings/paypal"
capabilities = ["read", "open"]

[settings.presentation]
label = "Configure PayPal"
openMode = "new-window"
```

A link presentation requires exactly the `read` and `open` capabilities — no other combination is valid, and `open` is rejected on a form presentation. `label` must be non-empty; `openMode` currently only accepts `"new-window"`. `--presentation-file` also accepts a link's full API object (`type: "link"`, `schemaVersion: "settings-link-v1"`, `label`, `openMode`). See `app-registry-api`'s `docs/SETTINGS.md` for the full lifecycle contract this registers into.

## What the CLI validates locally vs. server-side

`gddy platform app add settings`/`release` catch cheap, structural problems before any network call:

- `group`/`slug` match the platform's slug pattern (`lowercase-with-dashes`).
- `entryPath` is a route-safe path (`/`-prefixed, no query string/fragment/`..`), and doesn't overlap another setting's `entryPath` in the same manifest.
- `capabilities` are a subset of `read`, `write`, `validate`, `test`, `delete`.
- `capabilities` are a subset of `read`, `write`, `validate`, `test`, `delete`, `open`, with `open` only valid — and required — alongside `read` on a link presentation.
- `icon.library` is one of `ux`, `lucide`, `commerce`.
- Every field/section `key` matches the platform's key pattern, `select`/`multi-select` have at least one option, and no two fields/sections share a key.
- A link presentation's `label` is non-empty and `openMode` is `"new-window"`.
- `presentation` and `presentationFile` aren't both set on the same entry — checked as soon as the manifest is touched, not just at release.

Deeper semantics stay server-validated — bounds consistency (`maxLength ≥ minLength`), a `defaultValue` actually matching a registered option or satisfying bounds, and `list-group` nesting depth. A rejection there surfaces as a `release` API error, not a local one.
Expand Down
96 changes: 96 additions & 0 deletions rust/scripts/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,76 @@ else
fi
check_valid "A22 config validate accepts the CLI-added placement-only entry"

write_manifest <<'EOF'
[[settings]]
group = "payment-methods"
slug = "paypal-payments"
entryPath = "/settings/paypal"
capabilities = ["read", "write"]

[settings.presentation]
label = "Configure PayPal"
openMode = "new-window"
EOF
check_invalid "A23 rejects settings-link-v1 without exactly read+open capabilities" "requires exactly the read and open capabilities"

write_manifest <<'EOF'
[[settings]]
group = "payment-methods"
slug = "paypal-payments"
entryPath = "/settings/paypal"
capabilities = ["read", "open"]

[settings.presentation]
label = ""
openMode = "new-window"
EOF
check_invalid "A24 rejects a settings-link-v1 presentation with an empty label" "label must not be empty"

write_manifest <<'EOF'
[[settings]]
group = "payment-methods"
slug = "paypal-payments"
entryPath = "/settings/paypal"
capabilities = ["read", "open"]

[settings.presentation]
label = "Configure PayPal"
openMode = "same-window"
EOF
check_invalid "A25 rejects a settings-link-v1 presentation with a non-new-window openMode" "openMode must be"

write_manifest <<'EOF'
[[settings]]
group = "tax-center"
slug = "manual-tax"
entryPath = "/settings/manual-tax"
capabilities = ["read", "write", "open"]

[[settings.presentation.sections]]
key = "defaults"
label = "Defaults"

[[settings.presentation.sections.fields]]
type = "boolean"
key = "flag"
label = "Flag"
EOF
check_invalid "A26 rejects the open capability on a settings-form-v1 presentation" "only valid for a settings-link-v1"

write_manifest <<'EOF'
[[settings]]
group = "payment-methods"
slug = "paypal-payments"
entryPath = "/settings/paypal"
capabilities = ["read", "open"]

[settings.presentation]
label = "Configure PayPal"
openMode = "new-window"
EOF
check_valid "A27 config validate accepts a well-formed settings-link-v1 entry"

echo "=== Group B: release-time behavior (mocked network) ==="

cat >fixtures/manual-tax.json <<'EOF'
Expand Down Expand Up @@ -812,6 +882,32 @@ else
fail "C3b release did not echo extended field options as expected: $release_out"
fi

write_manifest <<'EOF'

[[settings]]
group = "payment-methods"
slug = "paypal-payments"
title = "PayPal Payments"
entryPath = "/settings/paypal"
capabilities = ["read", "open"]

[settings.presentation]
label = "Configure PayPal"
openMode = "new-window"
EOF
release_out="$(gddy platform app release --application-id smoke-app-id --version 0.0.1 2>&1)"
if echo "$release_out" | jq -e '
.data.settings[0].presentation.type == "link"
and .data.settings[0].presentation.schemaVersion == "settings-link-v1"
and .data.settings[0].presentation.label == "Configure PayPal"
and .data.settings[0].presentation.openMode == "new-window"
and .data.settings[0].capabilities == ["read","open"]
' >/dev/null 2>&1; then
pass "C4 release echoes a settings-link-v1 entry with type/schemaVersion/label/openMode"
else
fail "C4 release did not echo the link entry as expected: $release_out"
fi

echo "=== Group D: server error responses (mocked network) ==="

write_manifest <<'EOF'
Expand Down
11 changes: 6 additions & 5 deletions rust/src/application/commands/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ struct SettingsArgs {
order: Option<i64>,

/// One or more lifecycle capabilities (read, write, validate, test,
/// delete). Defaults to read+write server-side when omitted.
/// delete, open) — a link presentation requires exactly read+open.
Comment thread
nmolham-godaddy marked this conversation as resolved.
#[arg(long = "capability", value_name = "CAPABILITY", num_args = 1..)]
capabilities: Vec<String>,

Expand Down Expand Up @@ -181,10 +181,11 @@ pub(super) fn group() -> RuntimeGroupSpec {
"Register the placement metadata for an application-settings \
capability in the godaddy.toml manifest in the current directory. \
This command only writes group/slug/entryPath/order/capabilities/icon \
— it cannot author the settings-form-v1 form itself. After running \
it, hand-add a [settings.presentation] block (sections and fields) \
to the written entry; `gddy platform app release` rejects a \
settings entry with no presentation.",
— it cannot author the settings-form-v1 form or settings-link-v1 \
link itself. After running it, hand-add a [settings.presentation] \
block to the written entry — sections and fields for a form, or a \
label and openMode for a link; `gddy platform app release` rejects \
a settings entry with no presentation.",
)
.with_system("applications")
.with_tier(Tier::Mutate)
Expand Down
93 changes: 80 additions & 13 deletions rust/src/application/commands/release.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use serde_json::{Value, json};

use super::schemas::ApplicationRelease;
use crate::config::settings_form::{
SettingsFormV1Presentation, presentation_from_json, validate_presentation,
SettingPresentation, presentation_from_json, validate_presentation,
};
use crate::next_action::next_action;
use crate::scopes::{APP_REGISTRY_READ, APP_REGISTRY_WRITE};
Expand Down Expand Up @@ -38,7 +38,7 @@ fn ui_extension_entry(
fn resolve_presentation(
setting: &crate::config::SettingConfig,
manifest_dir: &Path,
) -> cli_engine::Result<SettingsFormV1Presentation> {
) -> cli_engine::Result<SettingPresentation> {
match (&setting.presentation, &setting.presentation_file) {
(Some(_), Some(_)) => Err(crate::error::GddyError::validation(format!(
"setting '{}' has both presentation and presentationFile — provide only one",
Expand Down Expand Up @@ -81,7 +81,12 @@ fn setting_entry(
) -> cli_engine::Result<Value> {
let presentation = resolve_presentation(setting, manifest_dir)?;
let mut errors = Vec::new();
validate_presentation(&presentation, &mut errors, "presentation");
validate_presentation(
&presentation,
&setting.capabilities,
&mut errors,
"presentation",
);
if !errors.is_empty() {
return Err(crate::error::GddyError::validation(format!(
"setting '{}' presentation is invalid: {}",
Expand All @@ -93,8 +98,16 @@ fn setting_entry(
let mut presentation_json = serde_json::to_value(&presentation)
.map_err(|e| cli_engine::CliCoreError::message(e.to_string()))?;
if let Value::Object(map) = &mut presentation_json {
map.insert("type".to_owned(), json!("form"));
map.insert("schemaVersion".to_owned(), json!("settings-form-v1"));
match &presentation {
SettingPresentation::Form(_) => {
map.insert("type".to_owned(), json!("form"));
map.insert("schemaVersion".to_owned(), json!("settings-form-v1"));
}
SettingPresentation::Link(_) => {
map.insert("type".to_owned(), json!("link"));
map.insert("schemaVersion".to_owned(), json!("settings-link-v1"));
}
}
}

let mut entry = json!({
Expand Down Expand Up @@ -342,9 +355,12 @@ mod tests {
}
}

fn boolean_presentation() -> crate::config::settings_form::SettingsFormV1Presentation {
use crate::config::settings_form::{SettingsFormV1Field, SettingsFormV1Section};
crate::config::settings_form::SettingsFormV1Presentation {
fn boolean_presentation() -> crate::config::settings_form::SettingPresentation {
use crate::config::settings_form::{
SettingPresentation, SettingsFormV1Field, SettingsFormV1Presentation,
SettingsFormV1Section,
};
SettingPresentation::Form(SettingsFormV1Presentation {
sections: vec![SettingsFormV1Section {
key: "defaults".to_owned(),
label: "Defaults".to_owned(),
Expand All @@ -358,14 +374,23 @@ mod tests {
default_value: Some(true),
}],
}],
}
})
}

fn link_presentation() -> crate::config::settings_form::SettingPresentation {
use crate::config::settings_form::{SettingPresentation, SettingsLinkV1Presentation};
SettingPresentation::Link(SettingsLinkV1Presentation {
label: "Configure PayPal".to_owned(),
open_mode: "new-window".to_owned(),
})
}

fn list_group_presentation() -> crate::config::settings_form::SettingsFormV1Presentation {
fn list_group_presentation() -> crate::config::settings_form::SettingPresentation {
use crate::config::settings_form::{
ChoiceOption, ListGroupItem, SelectValue, SettingsFormV1Field, SettingsFormV1Section,
ChoiceOption, ListGroupItem, SelectValue, SettingPresentation, SettingsFormV1Field,
SettingsFormV1Presentation, SettingsFormV1Section,
};
crate::config::settings_form::SettingsFormV1Presentation {
SettingPresentation::Form(SettingsFormV1Presentation {
sections: vec![SettingsFormV1Section {
key: "rules".to_owned(),
label: "Rules".to_owned(),
Expand Down Expand Up @@ -396,7 +421,7 @@ mod tests {
},
}],
}],
}
})
}

#[test]
Expand Down Expand Up @@ -442,6 +467,48 @@ mod tests {
);
}

#[test]
fn setting_entry_maps_link_placement_and_presentation() {
let mut setting = crate::config::SettingConfig {
group: "payment-methods".to_owned(),
slug: "paypal-payments".to_owned(),
title: None,
description: None,
entry_path: "/settings/paypal".to_owned(),
order: None,
capabilities: vec!["read".to_owned(), "open".to_owned()],
icon: None,
metadata: None,
presentation_file: None,
presentation: None,
};
setting.presentation = Some(link_presentation());
let entry = super::setting_entry(&setting, std::path::Path::new("")).expect("entry builds");
assert_eq!(entry["groupSlug"], "payment-methods");
assert_eq!(entry["appSettingSlug"], "paypal-payments");
assert_eq!(entry["entryPath"], "/settings/paypal");
assert_eq!(entry["presentation"]["type"], "link");
assert_eq!(entry["presentation"]["schemaVersion"], "settings-link-v1");
assert_eq!(entry["presentation"]["label"], "Configure PayPal");
assert_eq!(entry["presentation"]["openMode"], "new-window");
assert_eq!(entry["capabilities"], serde_json::json!(["read", "open"]));
}

#[test]
fn setting_entry_rejects_link_with_wrong_capabilities() {
let mut setting = placement_only_setting();
setting.entry_path = "/settings/paypal".to_owned();
setting.capabilities = vec!["read".to_owned(), "write".to_owned()];
setting.presentation = Some(link_presentation());
let err = super::setting_entry(&setting, std::path::Path::new(""))
.expect_err("wrong capabilities must be rejected");
assert!(
err.to_string()
.contains("requires exactly the read and open capabilities"),
"{err}"
);
}

#[test]
fn setting_entry_includes_optional_fields_when_present() {
let mut setting = placement_only_setting();
Expand Down
13 changes: 7 additions & 6 deletions rust/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ pub fn write_env_file(
#[cfg(test)]
mod tests {
use super::settings_form::{
SettingsFormV1Field, SettingsFormV1Presentation, SettingsFormV1Section,
SettingPresentation, SettingsFormV1Field, SettingsFormV1Presentation, SettingsFormV1Section,
};
use super::*;

Expand Down Expand Up @@ -763,7 +763,7 @@ mod tests {
}),
metadata: None,
presentation_file: None,
presentation: Some(SettingsFormV1Presentation {
presentation: Some(SettingPresentation::Form(SettingsFormV1Presentation {
sections: vec![SettingsFormV1Section {
key: "defaults".to_owned(),
label: "Defaults".to_owned(),
Expand All @@ -777,19 +777,20 @@ mod tests {
default_value: Some(true),
}],
}],
}),
})),
});
write_config(&path, &config).expect("write config with setting");
let read_back = read_config(&path).expect("read config with setting");
assert_eq!(read_back.settings.len(), 1);
assert_eq!(read_back.settings[0].entry_path, "/settings/godaddy-tax");
let SettingsFormV1Field::Boolean { default_value, .. } = &read_back.settings[0]
let SettingPresentation::Form(form) = read_back.settings[0]
.presentation
.as_ref()
.expect("presentation")
.sections[0]
.fields[0]
else {
unreachable!("expected form presentation");
};
let SettingsFormV1Field::Boolean { default_value, .. } = &form.sections[0].fields[0] else {
unreachable!("expected boolean field");
};
assert_eq!(default_value, &Some(true));
Expand Down
Loading
Loading