Skip to content
Merged
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
25 changes: 25 additions & 0 deletions rust/src/application/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ output_schema!(ApplicationInit {
"name": "string";
"status": "string";
"clientId": "string";
"orgId": "string";
"url": "string";
"proxyUrl": "string";
"authorizationScopes": "[]string";
Expand Down Expand Up @@ -390,10 +391,24 @@ fn init_command() -> RuntimeCommandSpec {
.short('c')
.value_name("PATH")
.help("Read defaults from this config file"),
)
.with_arg(
clap::Arg::new("accept-agreements")
.long("accept-agreements")
.action(clap::ArgAction::SetTrue)
.help(
"Accept GoDaddy Developer agreements non-interactively when \
onboarding is still pending (required for non-TTY)",
),
),
|ctx| async move {
let env = ctx.middleware.env.clone();
let config_path = crate::config::config_path(Some(&env));
let accept_agreements = ctx
.args
.get("accept-agreements")
.and_then(|value| value.as_bool())
.unwrap_or(false);

// Seed defaults only from an explicit --config; a bad/missing --config is fatal.
let existing = match ctx.args.get("config").and_then(|v| v.as_str()) {
Expand Down Expand Up @@ -459,6 +474,14 @@ fn init_command() -> RuntimeCommandSpec {
}
}

let credential = ctx.credential().await?;
let onboarding = crate::onboarding::ensure_ready_for_app_init(
&credential.token,
&env,
accept_agreements,
)
.await?;

let client = make_client(&ctx).await?;
let data = client
.create_application(json!({
Expand All @@ -467,6 +490,7 @@ fn init_command() -> RuntimeCommandSpec {
"description": description,
"url": url,
"proxyUrl": proxy_url,
"organizationId": &onboarding.org_id,
"authorizationScopes": scopes,
}))
.await
Expand Down Expand Up @@ -537,6 +561,7 @@ fn init_command() -> RuntimeCommandSpec {
"name": name,
"status": app["status"].as_str().unwrap_or("").to_owned(),
"clientId": client_id,
"orgId": onboarding.org_id,
"url": url,
"proxyUrl": proxy_url,
"authorizationScopes": scopes,
Expand Down
66 changes: 66 additions & 0 deletions rust/src/environments/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ struct Builtin {
name: &'static str,
api_url: &'static str,
client_id: &'static str,
devx_core_url: &'static str,
}

/// Public-safe, compiled-in environments. `api.ote-godaddy.com` is public, and
Expand All @@ -78,11 +79,13 @@ const BUILTINS: &[Builtin] = &[
name: "ote",
api_url: "https://api.ote-godaddy.com",
client_id: "91660d79-c909-426c-b5c8-e0f575e8fcd2",
devx_core_url: "https://api.developer.commerce.ote-godaddy.com",
},
Builtin {
name: "prod",
api_url: "https://api.godaddy.com",
client_id: "bc87f347-af82-4892-833f-818f54a0e79e",
devx_core_url: "https://api.developer.commerce.godaddy.com",
},
];

Expand Down Expand Up @@ -160,6 +163,28 @@ fn clean_url(raw: &str) -> Option<String> {
(!host.is_empty()).then(|| trimmed.to_owned())
}

fn devx_core_url_with(name: &str, var: impl Fn(&str) -> Option<String>) -> Option<String> {
let prefix = env_prefix(name);
var(&format!("{prefix}_DEVX_CORE_URL"))
.and_then(|value| clean_url(&value))
.or_else(|| var("DEVX_CORE_URL").and_then(|value| clean_url(&value)))
.or_else(|| {
BUILTINS
.iter()
.find(|env| env.name == name)
.map(|env| env.devx_core_url.to_owned())
})
}

/// Base URL for the DevX Core API gateway for the given environment.
///
/// Custom environments must set `<PREFIX>_DEVX_CORE_URL` (for example,
/// `DEV_DEVX_CORE_URL`) or the global `DEVX_CORE_URL`. `prod` and `ote` use
/// their compiled-in endpoints unless either variable overrides them.
pub fn devx_core_url(name: &str) -> Option<String> {
devx_core_url_with(name, |key| std::env::var(key).ok())
}

/// Scans the raw process argv for a `--env <value>` / `--env=<value>` token.
///
/// cli-engine's built-in global `--env` flag (and any command-local `--env`
Expand Down Expand Up @@ -768,4 +793,45 @@ mod tests {
Some("-foo".to_owned())
);
}

#[test]
fn devx_core_url_uses_prod_and_ote_builtins() {
assert_eq!(
devx_core_url_with("prod", |_| None).as_deref(),
Some("https://api.developer.commerce.godaddy.com")
);
assert_eq!(
devx_core_url_with("ote", |_| None).as_deref(),
Some("https://api.developer.commerce.ote-godaddy.com")
);
}

#[test]
fn devx_core_url_global_override_wins() {
assert_eq!(
devx_core_url_with("prod", |key| {
(key == "DEVX_CORE_URL").then(|| " http://localhost:4000/ ".to_owned())
})
.as_deref(),
Some("http://localhost:4000")
);
}

#[test]
fn devx_core_url_per_environment_override_wins_over_global() {
assert_eq!(
devx_core_url_with("dev", |key| match key {
"DEV_DEVX_CORE_URL" => Some("https://dev-core.example.test/".to_owned()),
"DEVX_CORE_URL" => Some("https://shared-core.example.test".to_owned()),
_ => None,
})
.as_deref(),
Some("https://dev-core.example.test")
);
}

#[test]
fn devx_core_url_custom_env_requires_override() {
assert_eq!(devx_core_url_with("dev", |_| None), None);
}
}
1 change: 1 addition & 0 deletions rust/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod environments;
mod extension;
mod hosting;
mod next_action;
pub mod onboarding;
mod output_schema;
mod pat;
mod payment_methods;
Expand Down
186 changes: 186 additions & 0 deletions rust/src/onboarding/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
use serde::de::DeserializeOwned;

use super::types::{ApiEnvelope, CliData, CliOnboardingResult, OnboardingStatus, StatusData};

#[derive(Debug, thiserror::Error)]
pub enum OnboardingError {
#[error("onboarding request could not be sent")]
Request(#[source] reqwest::Error),
#[error("onboarding service returned HTTP {0}")]
Http(reqwest::StatusCode),
#[error("onboarding service returned an invalid response")]
Decode(#[source] reqwest::Error),
#[error("onboarding service rejected the request")]
Unsuccessful,
}

pub struct OnboardingClient {
base_url: String,
http: reqwest::Client,
}

impl OnboardingClient {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_owned(),
http: reqwest::Client::new(),
}
}

pub async fn status(&self, token: &str) -> Result<OnboardingStatus, OnboardingError> {
let envelope: ApiEnvelope<StatusData> =
self.post("/api/v1/onboarding/status", token).await?;
if !envelope.success {
return Err(OnboardingError::Unsuccessful);
}

Ok(OnboardingStatus {
org_id: envelope.data.id,
status: envelope.data.status,
})
}

pub async fn complete(&self, token: &str) -> Result<CliOnboardingResult, OnboardingError> {
let envelope: ApiEnvelope<CliData> = self.post("/api/v1/onboarding/cli", token).await?;
if !envelope.success {
return Err(OnboardingError::Unsuccessful);
}

Ok(CliOnboardingResult {
organization_id: envelope.data.organization_id,
status: envelope.data.status,
})
}

async fn post<T: DeserializeOwned>(
&self,
path: &str,
token: &str,
) -> Result<T, OnboardingError> {
let response = self
.http
.post(format!("{}{path}", self.base_url))
.bearer_auth(token)
.json(&serde_json::json!({}))
.send()
.await
.map_err(OnboardingError::Request)?;
if !response.status().is_success() {
return Err(OnboardingError::Http(response.status()));
}

response.json().await.map_err(OnboardingError::Decode)
}
}

#[cfg(test)]
mod tests {
use httpmock::{Method::POST, MockServer};
use serde_json::json;

use super::OnboardingClient;
use crate::onboarding::{CliOnboardingResult, OnboardingStatus};

#[tokio::test]
async fn status_posts_bearer_and_parses_response() {
let server = MockServer::start_async().await;
let mock = server
.mock_async(|when, then| {
when.method(POST)
.path("/api/v1/onboarding/status")
.header("authorization", "Bearer test-token")
.json_body(json!({}));
then.status(200).json_body(json!({
"success": true,
"data": { "id": "org-1", "status": "PENDING" }
}));
})
.await;

let result = OnboardingClient::new(server.base_url())
.status("test-token")
.await
.expect("status response");

assert_eq!(
result,
OnboardingStatus {
org_id: "org-1".to_owned(),
status: "PENDING".to_owned(),
}
);
mock.assert_async().await;
}

#[tokio::test]
async fn complete_posts_bearer_and_parses_response() {
let server = MockServer::start_async().await;
let mock = server
.mock_async(|when, then| {
when.method(POST)
.path("/api/v1/onboarding/cli")
.header("authorization", "Bearer test-token")
.json_body(json!({}));
then.status(200).json_body(json!({
"success": true,
"data": { "organizationId": "org-1", "status": "ACTIVE" }
}));
})
.await;

let result = OnboardingClient::new(server.base_url())
.complete("test-token")
.await
.expect("completion response");

assert_eq!(
result,
CliOnboardingResult {
organization_id: "org-1".to_owned(),
status: "ACTIVE".to_owned(),
}
);
mock.assert_async().await;
}

#[tokio::test]
async fn errors_do_not_include_response_body_or_token() {
let server = MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method(POST).path("/api/v1/onboarding/status");
then.status(500).body("secret backend detail");
})
.await;

let error = OnboardingClient::new(server.base_url())
.status("test-token")
.await
.expect_err("status must fail");
let message = error.to_string();

assert!(message.contains("500"));
assert!(!message.contains("secret backend detail"));
assert!(!message.contains("test-token"));
}

#[tokio::test]
async fn success_false_is_rejected() {
let server = MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method(POST).path("/api/v1/onboarding/status");
then.status(200).json_body(json!({
"success": false,
"data": { "id": "org-1", "status": "PENDING" }
}));
})
.await;

let error = OnboardingClient::new(server.base_url())
.status("test-token")
.await
.expect_err("unsuccessful envelope");
assert_eq!(error.to_string(), "onboarding service rejected the request");
}
}
Loading