-
Notifications
You must be signed in to change notification settings - Fork 23
feat(app): add settings support for app registration #216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
cfca3b7
2f4a3a2
8279cab
8f86a59
bcb594a
9a9f409
9edaff1
3fe7f9d
2f77e88
5d85182
cd8502a
f7df051
c4c0115
b298b7a
eb6a857
4e41ff2
2a71e2b
6c752c6
969de5c
71f9038
d876c90
4f63143
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| # Application settings | ||
|
|
||
| Also available at the terminal, without a repo checkout, via `gddy guide platform-settings` (and `gddy guide platform-overview` for the full app lifecycle). | ||
|
|
||
| An application-settings capability lets a GoDaddy Platform Application (GPA) contribute a form to a Commerce-owned settings surface (e.g. `tax-center`) — merchants fill it out, the GPA's own `load`/`save`/`validate` endpoints own the data. `app-registry-api` stores and validates only the registration and presentation metadata; it never sees merchant values. This doc covers the `gddy platform app` side of registering one. For the platform contract itself (lifecycle endpoints, signing, `settings-api` composition), see `app-registry-api`'s `docs/GPA-SETTINGS-REGISTRATION.md` and `docs/SETTINGS.md`. | ||
|
|
||
| ## Workflow | ||
|
|
||
| 1. **Add the placement** — `gddy platform app add settings` writes the group/slug/entryPath/order/capabilities/icon fields into `godaddy.toml`: | ||
|
|
||
| ```bash | ||
| gddy platform app add settings \ | ||
| --group tax-center \ | ||
| --slug godaddy-tax \ | ||
| --title "GoDaddy Tax" \ | ||
| --description "Choose manual tax rules or automatic U.S. ZIP-code tax rates." \ | ||
| --entry-path /settings/godaddy-tax \ | ||
| --order 10 \ | ||
| --capability read --capability write --capability validate \ | ||
| --icon-name percent --icon-library lucide | ||
| ``` | ||
|
|
||
| `entryPath` is relative to the application's registered `proxy_url`, same as action URLs. `--capability` defaults to `read`+`write` server-side if omitted. `--icon-name`/`--icon-library` must be given together or not at all. | ||
|
|
||
| 2. **Author the form** — this command only writes placement metadata; the actual field/section definitions (`presentation`) aren't flag-driven. Either hand-add a `[settings.presentation]` block directly into the entry `add settings` just wrote, or point it at a JSON file with `--presentation-file <path>` (or by editing `presentationFile` into the entry afterward). The two are mutually exclusive — see Presentation shape below. | ||
|
|
||
| 3. **Release** — `gddy platform app release --application-id <id> --version <version>` resends every setting in `godaddy.toml`, same as it does for actions/subscriptions/extensions. It rejects any settings entry with neither `presentation` nor `presentationFile`: | ||
|
|
||
| ```json | ||
| { "error": { "code": "VALIDATION_ERROR", "message": "settings 'godaddy-tax' has no presentation — add a [settings.presentation] block or a presentationFile before releasing" } } | ||
| ``` | ||
|
|
||
| 4. **Enable/backfill** — `gddy platform app enable <name> --store-id <storeId>` makes the placement discoverable for a store. Settings (like actions/subscriptions/uiExtensions) are keyed per-release with no inheritance — a store already enabled against an older release doesn't pick up settings added in a newer one until `enable` is re-run for that store. | ||
|
|
||
| ## Presentation shape | ||
|
|
||
| `presentation` is a `settings-form-v1` form: one or more sections, each with one or more fields. Add it as nested tables under the setting's own `[[settings]]` entry: | ||
|
|
||
| ```toml | ||
| [[settings.presentation.sections]] | ||
| key = "defaults" | ||
| label = "Calculation defaults" | ||
|
|
||
| [[settings.presentation.sections.fields]] | ||
| type = "select" | ||
| key = "calculateUsing" | ||
| label = "Calculate using" | ||
| required = true | ||
| defaultValue = "destination" | ||
|
|
||
| [[settings.presentation.sections.fields.options]] | ||
| label = "Customer destination" | ||
| value = "destination" | ||
|
|
||
| [[settings.presentation.sections.fields.options]] | ||
| label = "Order origin" | ||
| value = "origin" | ||
| ``` | ||
|
|
||
| A field `key` becomes a property in the merchant's saved `values` document and must stay stable once merchants have data — don't rename a field key without a GPA-owned migration. Section keys are display-only and safe to change freely. | ||
|
|
||
| Supported field types (`type` discriminates the shape — every field needs `type`, `key`, `label`): | ||
|
|
||
| - **`text`** / **`textarea`** — string value. | ||
| - Optional: `placeholder`, `minLength`, `maxLength`, `defaultValue` (string). | ||
| - **`number`** — numeric value. | ||
| - Optional: `min`, `max`, `step`, `suffix`, `defaultValue` (number). | ||
| - **`boolean`** — true/false value. | ||
| - Optional: `defaultValue` (bool). | ||
| - **`select`** — one value chosen from `options` (required, non-empty). | ||
| - Each option: `value`, `label`, optional `description`. Add more by repeating the `[[...options]]` array-of-tables header — one block per option, at whatever nesting depth the field sits (e.g. `[[settings.presentation.sections.fields.item.fields.options]]` inside a `list-group` item field): | ||
| ```toml | ||
| [[settings.presentation.sections.fields.options]] | ||
| label = "United States" | ||
| value = "US" | ||
|
|
||
| [[settings.presentation.sections.fields.options]] | ||
| label = "Canada" | ||
| value = "CA" | ||
| ``` | ||
| - Optional: `defaultValue` must match one option's `value`. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like the Markdown parser hit a snag starting on this line. You probably need to fix indentation or some other kind of white space.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can see rendering fine 🤔 what do you see when you open the file ?
|
||
| - **`multi-select`** — array of values chosen from `options` (required, non-empty). | ||
| - Optional: `minItems`, `maxItems`, `defaultValue` (array, each entry must match an option). | ||
| - **`list-group`** — array of objects, one merchant-added item per array entry. | ||
| - `item.idField` names the item's reserved stable-UUID property (renderer-generated, GPA echoes it back unchanged — it can't also be an editable field key). | ||
| - `item.titleField` (optional) names a `text`/`textarea`/`number`/`select` field used to label each item in the UI. | ||
| - `item.fields` is a list of fields using the same types above — `list-group` may nest one level further (max depth 2), but not deeper. | ||
|
|
||
| Every field, section, and `list-group` item field also accepts `description`. Sections additionally accept `visibleWhen = { field = "...", equals = ... }` to show/hide based on another top-level field's value. | ||
|
|
||
| Worked example — the full `godaddy.toml` shape for a manual-tax-style GPA with a nested `list-group`: | ||
|
|
||
| ```toml | ||
| [[settings]] | ||
| group = "tax-center" | ||
| slug = "manual-tax" | ||
| title = "Manual Tax" | ||
| entryPath = "/settings/manual-tax" | ||
| order = 10 | ||
| capabilities = ["read", "write", "validate"] | ||
|
|
||
| [settings.icon] | ||
| name = "percent" | ||
| library = "lucide" | ||
|
|
||
| [[settings.presentation.sections]] | ||
| key = "rules" | ||
| label = "Tax rules" | ||
|
|
||
| [[settings.presentation.sections.fields]] | ||
| type = "list-group" | ||
| key = "rules" | ||
| label = "Rules" | ||
| minItems = 1 | ||
|
|
||
| [settings.presentation.sections.fields.item] | ||
| idField = "id" | ||
| titleField = "displayName" | ||
|
|
||
| [[settings.presentation.sections.fields.item.fields]] | ||
| type = "select" | ||
| key = "country" | ||
| label = "Country" | ||
|
|
||
| [[settings.presentation.sections.fields.item.fields.options]] | ||
| label = "United States" | ||
| value = "US" | ||
|
|
||
| [[settings.presentation.sections.fields.item.fields]] | ||
| type = "text" | ||
| key = "displayName" | ||
| label = "Display at checkout" | ||
| ``` | ||
|
|
||
| ### Referencing a JSON file instead of inline TOML | ||
|
|
||
| For a GPA that already keeps its presentation as a JSON fixture (a common shape for existing registry examples), point `presentationFile` at it instead of re-authoring the same form as TOML: | ||
|
|
||
| ```toml | ||
| [[settings]] | ||
| group = "tax-center" | ||
| slug = "manual-tax" | ||
| entryPath = "/settings/manual-tax" | ||
| 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. | ||
|
|
||
| ## 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`. | ||
| - `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. | ||
| - `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. | ||
|
|
||
| ## Gotchas | ||
|
|
||
| - **No release inheritance.** `release` resends every current setting from `godaddy.toml`; leaving one out doesn't archive it globally, but any store enabled against the *new* release loses it. | ||
| - **Existing stores don't auto-upgrade.** Adding settings to a release only affects stores enabled *after* that release goes active — re-run `gddy platform app enable <name> --store-id <storeId>` per store to backfill. | ||
| - **`presentation`/`presentationFile` is mandatory before release, not before `add settings`.** A placement-only entry parses and works fine for every other command (`add action`, `info`, `validate`, `deploy`) — it only fails at `release`, with the message shown above. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| //! Mock `app-registry-api` for `rust/scripts/smoke-test.sh`. Dev-only — | ||
| //! must stay a Cargo example, never a `[[bin]]` (uses the `httpmock` dev-dependency). | ||
| #![allow(clippy::print_stdout)] | ||
|
|
||
| use std::thread; | ||
| use std::time::Duration; | ||
|
|
||
| use httpmock::{HttpMockRequest, HttpMockResponse, MockServer}; | ||
| use serde_json::{Value, json}; | ||
|
|
||
| const GRAPHQL_PATH: &str = "/v1/apps/app-registry-subgraph"; | ||
|
|
||
| /// `applicationId` values that select a canned error response instead of | ||
| /// the normal success echo, so the smoke test can drive real HTTP/GraphQL | ||
| /// error handling without a second mock process. | ||
| const GRAPHQL_ERROR_APPLICATION_ID: &str = "smoke-graphql-error-id"; | ||
| const HTTP_500_APPLICATION_ID: &str = "smoke-http-500-id"; | ||
| const HTTP_401_APPLICATION_ID: &str = "smoke-http-401-id"; | ||
|
|
||
| fn create_release_response(req: &HttpMockRequest) -> HttpMockResponse { | ||
| let body: Value = match serde_json::from_slice(&req.body_bytes()) { | ||
| Ok(v) => v, | ||
| Err(e) => { | ||
| return HttpMockResponse::builder() | ||
| .status(400) | ||
| .body(format!("smoke mock: request body is not JSON: {e}")) | ||
| .build(); | ||
| } | ||
| }; | ||
| let query = body["query"].as_str().unwrap_or_default(); | ||
| if !query.contains("CreateRelease") { | ||
| return HttpMockResponse::builder() | ||
| .status(400) | ||
| .body(format!( | ||
| "smoke mock: only CreateRelease is mocked, got query: {query}" | ||
| )) | ||
| .build(); | ||
| } | ||
|
|
||
| let input = &body["variables"]["input"]; | ||
| match input["applicationId"].as_str() { | ||
| Some(HTTP_500_APPLICATION_ID) => { | ||
| return HttpMockResponse::builder() | ||
| .status(500) | ||
| .body("smoke mock: internal server error") | ||
| .build(); | ||
| } | ||
| Some(HTTP_401_APPLICATION_ID) => { | ||
| return HttpMockResponse::builder() | ||
| .status(401) | ||
| .body("smoke mock: unauthorized") | ||
| .build(); | ||
| } | ||
| Some(GRAPHQL_ERROR_APPLICATION_ID) => { | ||
| return HttpMockResponse::builder() | ||
| .status(200) | ||
| .header("content-type", "application/json") | ||
| .body( | ||
| json!({ "data": null, "errors": [{ "message": "release not found" }] }) | ||
| .to_string(), | ||
| ) | ||
| .build(); | ||
| } | ||
| _ => {} | ||
| } | ||
| let release = json!({ | ||
| "id": "smoke-release-id", | ||
| "version": input.get("version").cloned().unwrap_or(json!("0.0.0")), | ||
| "description": input.get("description").cloned().unwrap_or(Value::Null), | ||
| "createdAt": "2026-01-01T00:00:00Z", | ||
| "uiExtensions": input.get("uiExtensions").cloned().unwrap_or(json!([])), | ||
| "settings": input.get("settings").cloned().unwrap_or(json!([])), | ||
| }); | ||
| HttpMockResponse::builder() | ||
| .status(200) | ||
| .header("content-type", "application/json") | ||
| .body(json!({ "data": { "createRelease": release } }).to_string()) | ||
| .build() | ||
| } | ||
|
|
||
| fn main() { | ||
| let server = MockServer::start(); | ||
| server.mock(|when, then| { | ||
| when.method("POST").path(GRAPHQL_PATH); | ||
| then.respond_with(create_release_response); | ||
| }); | ||
| println!("PORT={}", server.port()); | ||
| loop { | ||
| thread::sleep(Duration::from_secs(3600)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| #!/usr/bin/env bash | ||
| # Run check/clippy/fmt/module-size (no tests), then build and run the `gddy` | ||
| # binary. Any arguments passed to this script are forwarded to `gddy` at the | ||
| # end (e.g. `./scripts/build-and-verify.sh platform app --help`); with no | ||
| # arguments it just runs `gddy --help` as a smoke check. | ||
| set -euo pipefail | ||
|
|
||
| rust_root="$(cd "$(dirname "$0")/.." && pwd)" | ||
| cd "$rust_root" | ||
|
|
||
| step() { | ||
| echo | ||
| echo "==> $1" | ||
| } | ||
|
|
||
| step "cargo check" | ||
| cargo check | ||
|
|
||
| step "cargo clippy -- -D warnings" | ||
| cargo clippy -- -D warnings | ||
|
|
||
| step "cargo fmt --check" | ||
| cargo fmt --check | ||
|
|
||
| step "check-module-size.sh" | ||
| ./scripts/check-module-size.sh | ||
|
|
||
| step "cargo build" | ||
| cargo build | ||
|
|
||
| step "gddy ${*:-\"--help\"}" | ||
| if [ "$#" -gt 0 ]; then | ||
| ./target/debug/gddy "$@" | ||
| else | ||
| ./target/debug/gddy --help | ||
| fi |

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'd be better if this was embedded in a guide so that LLMs can discover these instructions without needing to do a web search. I think the whole
gddy platformmodule probably needs a holistic guide with application settings included (or I guess multiple guides is also fine).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, sounds like a good idea, I will do my best effort to implement it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
b298b7a what do you think? CC @wcole1-godaddy