Skip to content
Draft
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
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ pub mod network;
pub mod notebooks;
pub mod obs_pipelines;
pub mod on_call;
pub mod onboarding;
pub mod organizations;
pub mod processes;
pub mod product_analytics;
Expand Down
353 changes: 353 additions & 0 deletions src/commands/onboarding.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,353 @@
use anyhow::Result;

use crate::client::HttpError;
use crate::config::Config;
use crate::formatter;
use crate::useragent;

const SKILLS_PATH: &str = "/api/v2/onboarding/skills";
const SESSIONS_PATH: &str = "/api/v2/onboarding/sessions";

const VALID_STATUSES: &[&str] = &["in_progress", "completed", "failed", "abandoned"];
// Terminal statuses require at least one skill_id; the backend allows in_progress to be empty.
const TERMINAL_STATUSES: &[&str] = &["completed", "failed", "abandoned"];
const VALID_INTENTS: &[&str] = &["explore", "install", "reference"];

/// List the available Datadog onboarding skills (`GET /api/v2/onboarding/skills`).
pub async fn skills_list(cfg: &Config, org_id: Option<i64>) -> Result<()> {
let org = org_id.map(|o| o.to_string());
let query: Vec<(&str, &str)> = match &org {
Some(o) => vec![("org_id", o.as_str())],
None => vec![],
};
let resp = get(cfg, SKILLS_PATH, &query).await?;
formatter::output(cfg, &resp)
}

/// Fetch a single onboarding skill and its content
/// (`GET /api/v2/onboarding/skills/{skill_id}`).
pub async fn skills_get(
cfg: &Config,
skill_id: &str,
intent: Option<&str>,
onboarding_run_id: Option<&str>,
org_id: Option<i64>,
) -> Result<()> {
if let Some(intent) = intent {
if !VALID_INTENTS.contains(&intent) {
anyhow::bail!(
"invalid intent '{intent}'; expected one of: {}",
VALID_INTENTS.join(", ")
);
}
}

let org = org_id.map(|o| o.to_string());
let mut query: Vec<(&str, &str)> = Vec::new();
if let Some(intent) = intent {
query.push(("intent", intent));
}
if let Some(run_id) = onboarding_run_id {
query.push(("onboarding_run_id", run_id));
}
if let Some(o) = &org {
query.push(("org_id", o.as_str()));
}

let path = format!("{SKILLS_PATH}/{skill_id}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Percent-encode skill IDs in the request path

If a skill ID returned by skills list contains reserved URL characters such as / or ?, interpolating it directly into the path changes the route or starts a query string before reqwest sends the request, so pup onboarding skills get can fetch the wrong URL or 404. Other raw path construction in this repo uses util::percent_encode for IDs with reserved characters; apply the same encoding before appending skill_id to /skills/.

Useful? React with 👍 / 👎.

let resp = get(cfg, &path, &query).await?;
formatter::output(cfg, &resp)
}

/// Record an onboarding session outcome (`POST /api/v2/onboarding/sessions`).
pub async fn sessions_create(
cfg: &Config,
session_id: &str,
skill_ids: &[String],
summary: &str,
status: &str,
org_id: Option<i64>,
) -> Result<()> {
if !VALID_STATUSES.contains(&status) {
anyhow::bail!(
"invalid status '{status}'; expected one of: {}",
VALID_STATUSES.join(", ")
);
}

if TERMINAL_STATUSES.contains(&status) && skill_ids.is_empty() {
anyhow::bail!("at least one --skill-id is required when status is '{status}'");
}

let mut attributes = serde_json::json!({
"skill_ids": skill_ids,
"summary": summary,
"status": status,
});
if let Some(org_id) = org_id {
if let Some(obj) = attributes.as_object_mut() {
obj.insert("org_id".to_string(), serde_json::json!(org_id));
}
}

let body = serde_json::json!({
"data": {
"type": "onboarding_session",
"id": session_id,
"attributes": attributes,
},
});

let resp = post(cfg, SESSIONS_PATH, body).await?;
formatter::output(cfg, &resp)
}

// Onboarding routes are OpenAuth, so unlike `client::apply_auth` this attaches
// credentials when present but never fails without them.
fn apply_optional_auth(mut req: reqwest::RequestBuilder, cfg: &Config) -> reqwest::RequestBuilder {
if let Some(token) = &cfg.access_token {
req = req.header("Authorization", format!("Bearer {token}"));
} else if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) {
req = req
.header("DD-API-KEY", api_key.as_str())
.header("DD-APPLICATION-KEY", app_key.as_str());
}
req
}

async fn get(cfg: &Config, path: &str, query: &[(&str, &str)]) -> Result<serde_json::Value> {
let url = format!("{}{}", cfg.api_base_url(), path);
let client = reqwest::Client::new();
let mut req = client.get(&url);
if !query.is_empty() {
req = req.query(query);
}
req = apply_optional_auth(req, cfg);
let resp = req
.header("Accept", "application/vnd.api+json")
.header("User-Agent", useragent::get())
.send()
.await?;
read_json(resp, "GET", &url).await
}

async fn post(cfg: &Config, path: &str, body: serde_json::Value) -> Result<serde_json::Value> {
let url = format!("{}{}", cfg.api_base_url(), path);
let client = reqwest::Client::new();
let mut req = client.post(&url);
req = apply_optional_auth(req, cfg);
let resp = req
.header("Content-Type", "application/vnd.api+json")
.header("Accept", "application/vnd.api+json")
.header("User-Agent", useragent::get())
.json(&body)
.send()
.await?;
read_json(resp, "POST", &url).await
}

async fn read_json(resp: reqwest::Response, method: &str, url: &str) -> Result<serde_json::Value> {
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(HttpError {
status: status.as_u16(),
method: method.to_string(),
url: url.to_string(),
body,
}
.into());
}
Ok(resp.json().await?)
}

#[cfg(test)]
mod tests {
use crate::test_support::*;

#[tokio::test]
async fn skills_list_ok() {
let _lock = lock_env().await;
let mut server = mockito::Server::new_async().await;
let cfg = test_config(&server.url());
let _mock = server
.mock("GET", "/api/v2/onboarding/skills")
.with_status(200)
.with_header("content-type", "application/vnd.api+json")
.with_body(r#"{"data":[]}"#)
.create_async()
.await;
let result = super::skills_list(&cfg, None).await;
assert!(result.is_ok(), "skills list failed: {:?}", result.err());
cleanup_env();
}

#[tokio::test]
async fn skills_list_forwards_org_id() {
let _lock = lock_env().await;
let mut server = mockito::Server::new_async().await;
let cfg = test_config(&server.url());
let mock = server
.mock("GET", "/api/v2/onboarding/skills")
.match_query(mockito::Matcher::UrlEncoded("org_id".into(), "42".into()))
.with_status(200)
.with_header("content-type", "application/vnd.api+json")
.with_body(r#"{"data":[]}"#)
.create_async()
.await;
let result = super::skills_list(&cfg, Some(42)).await;
assert!(result.is_ok(), "skills list failed: {:?}", result.err());
mock.assert_async().await;
cleanup_env();
}

#[tokio::test]
async fn skills_get_ok_with_intent() {
let _lock = lock_env().await;
let mut server = mockito::Server::new_async().await;
let cfg = test_config(&server.url());
let mock = server
.mock("GET", "/api/v2/onboarding/skills/aws-integration-setup")
.match_query(mockito::Matcher::UrlEncoded(
"intent".into(),
"install".into(),
))
.with_status(200)
.with_header("content-type", "application/vnd.api+json")
.with_body(r#"{"data":{"id":"aws-integration-setup"}}"#)
.create_async()
.await;
let result =
super::skills_get(&cfg, "aws-integration-setup", Some("install"), None, None).await;
assert!(result.is_ok(), "skills get failed: {:?}", result.err());
mock.assert_async().await;
cleanup_env();
}

#[tokio::test]
async fn skills_get_rejects_invalid_intent() {
let _lock = lock_env().await;
let server = mockito::Server::new_async().await;
let cfg = test_config(&server.url());
let result = super::skills_get(&cfg, "some-skill", Some("bogus"), None, None).await;
let err = result.unwrap_err().to_string();
assert!(err.contains("invalid intent"), "got: {err}");
cleanup_env();
}

#[tokio::test]
async fn skills_get_surfaces_not_found() {
let _lock = lock_env().await;
let mut server = mockito::Server::new_async().await;
let cfg = test_config(&server.url());
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(404)
.with_header("content-type", "application/vnd.api+json")
.with_body(r#"{"errors":[{"detail":"Skill not found: nope"}]}"#)
.create_async()
.await;
let result = super::skills_get(&cfg, "nope", None, None, None).await;
let err = result.unwrap_err().to_string();
assert!(err.contains("404"), "expected 404 in error, got: {err}");
cleanup_env();
}

#[tokio::test]
async fn sessions_create_ok() {
let _lock = lock_env().await;
let mut server = mockito::Server::new_async().await;
let cfg = test_config(&server.url());
let mock = server
.mock("POST", "/api/v2/onboarding/sessions")
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
"data": {
"type": "onboarding_session",
"id": "run-123",
"attributes": {
"status": "completed",
"skill_ids": ["aws-integration-setup"],
},
},
})))
.with_status(201)
.with_header("content-type", "application/vnd.api+json")
.with_body(r#"{"data":{"id":"run-123"}}"#)
.create_async()
.await;
let result = super::sessions_create(
&cfg,
"run-123",
&["aws-integration-setup".to_string()],
"Set up the AWS integration",
"completed",
None,
)
.await;
assert!(result.is_ok(), "sessions create failed: {:?}", result.err());
mock.assert_async().await;
cleanup_env();
}

#[tokio::test]
async fn sessions_create_rejects_invalid_status() {
let _lock = lock_env().await;
let server = mockito::Server::new_async().await;
let cfg = test_config(&server.url());
let result = super::sessions_create(
&cfg,
"run-123",
&["aws-integration-setup".to_string()],
"summary",
"done",
None,
)
.await;
let err = result.unwrap_err().to_string();
assert!(err.contains("invalid status"), "got: {err}");
cleanup_env();
}

#[tokio::test]
async fn sessions_create_terminal_requires_skill_id() {
let _lock = lock_env().await;
let server = mockito::Server::new_async().await;
let cfg = test_config(&server.url());
let result =
super::sessions_create(&cfg, "run-123", &[], "summary", "completed", None).await;
let err = result.unwrap_err().to_string();
assert!(err.contains("at least one --skill-id"), "got: {err}");
cleanup_env();
}

#[tokio::test]
async fn sessions_create_in_progress_allows_empty_skill_ids() {
let _lock = lock_env().await;
let mut server = mockito::Server::new_async().await;
let cfg = test_config(&server.url());
let mock = server
.mock("POST", "/api/v2/onboarding/sessions")
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
"data": {
"type": "onboarding_session",
"id": "run-123",
"attributes": { "status": "in_progress", "skill_ids": [] },
},
})))
.with_status(201)
.with_header("content-type", "application/vnd.api+json")
.with_body(r#"{"data":{"id":"run-123"}}"#)
.create_async()
.await;
let result =
super::sessions_create(&cfg, "run-123", &[], "session started", "in_progress", None)
.await;
assert!(
result.is_ok(),
"in_progress create failed: {:?}",
result.err()
);
mock.assert_async().await;
cleanup_env();
}
}
Loading
Loading