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
70 changes: 56 additions & 14 deletions src/api/client.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,52 @@
use std::fmt::Debug;
use std::time::Duration;

use anyhow::{Context, Result};
use reqwest::header::{HeaderMap, AUTHORIZATION};
use serde::Deserialize;

use progenitor::progenitor_client::ResponseValue;
use serde::{Deserialize, Serialize};

use progenitor::progenitor_client::{Error as ProgenitorError, ResponseValue};

/// Convert a progenitor client error into a concise anyhow error.
///
/// progenitor's own `Display` for `ErrorResponse` dumps headers and the typed
/// body via `Debug`, which is the verbose output the CLI is trying to avoid.
/// This collapses HTTP error responses to `<status> <reason>: <message>` so
/// the chain stays actionable (e.g. "401 Unauthorized") without leaking
/// internal struct shape.
#[allow(
clippy::needless_pass_by_value,
reason = "shape matches map_err's FnOnce(E) -> F"
)]
fn api_error<E: Debug + Serialize>(e: ProgenitorError<E>) -> anyhow::Error {
if let ProgenitorError::ErrorResponse(rv) = &e {
let status = rv.status();
let head = format!(
"API error: {} {}",
status.as_u16(),
status.canonical_reason().unwrap_or("HTTP error"),
);
let msg = serde_json::to_value(rv.as_ref())
.ok()
.as_ref()
.and_then(|v| v.get("message"))
.and_then(serde_json::Value::as_str)
.filter(|m| !m.is_empty())
.map(str::to_owned);
return msg.map_or_else(
|| anyhow::anyhow!("{head}"),
|m| anyhow::anyhow!("{head}: {m}"),
);
}
if let Some(status) = e.status() {
return anyhow::anyhow!(
"API error: {} {}",
status.as_u16(),
status.canonical_reason().unwrap_or("HTTP error"),
);
}
anyhow::anyhow!("API error: {e}")
}

use super::generated::types::CreateRuleBody;
use super::types::{
Expand Down Expand Up @@ -52,7 +94,7 @@ impl ApiClient {
.get_public_user()
.await
.map(ResponseValue::into_inner)
.map_err(|e| anyhow::anyhow!("API error: {e}"))
.map_err(api_error)
}

pub async fn list_bugs(
Expand All @@ -75,15 +117,15 @@ impl ApiClient {
)
.await
.map(ResponseValue::into_inner)
.map_err(|e| anyhow::anyhow!("API error: {e}"))
.map_err(api_error)
}

pub async fn get_bug(&self, bug_id: &BugId) -> Result<Bug> {
self.inner
.get_public_bug(bug_id)
.await
.map(ResponseValue::into_inner)
.map_err(|e| anyhow::anyhow!("API error: {e}"))
.map_err(api_error)
}

pub async fn update_bug_close(
Expand All @@ -103,7 +145,7 @@ impl ApiClient {
.create_public_bug_review(bug_id, &body)
.await
.map(ResponseValue::into_inner)
.map_err(|e| anyhow::anyhow!("API error: {e}"))
.map_err(api_error)
}

pub async fn list_scans(
Expand All @@ -118,7 +160,7 @@ impl ApiClient {
.list_public_scans(NonZeroU64::new(limit.into()), Some(offset.into()), repo_id)
.await
.map(ResponseValue::into_inner)
.map_err(|e| anyhow::anyhow!("API error: {e}"))
.map_err(api_error)
}

pub async fn list_repos(&self, limit: u32, offset: u32) -> Result<ReposResponse> {
Expand All @@ -128,7 +170,7 @@ impl ApiClient {
.list_public_repos(NonZeroU64::new(limit.into()), Some(offset.into()))
.await
.map(ResponseValue::into_inner)
.map_err(|e| anyhow::anyhow!("API error: {e}"))
.map_err(api_error)
}

pub async fn create_rule(
Expand All @@ -144,23 +186,23 @@ impl ApiClient {
.create_rule(&body)
.await
.map(ResponseValue::into_inner)
.map_err(|e| anyhow::anyhow!("API error: {e}"))
.map_err(api_error)
}

pub async fn list_rules(&self, repo_id: &RepoId) -> Result<RulesResponse> {
self.inner
.list_rules(repo_id)
.await
.map(ResponseValue::into_inner)
.map_err(|e| anyhow::anyhow!("API error: {e}"))
.map_err(api_error)
}

pub async fn get_rule(&self, rule_id: &RuleId) -> Result<Rule> {
self.inner
.get_rule(rule_id)
.await
.map(ResponseValue::into_inner)
.map_err(|e| anyhow::anyhow!("API error: {e}"))
.map_err(api_error)
}

pub async fn get_rule_request(
Expand All @@ -171,15 +213,15 @@ impl ApiClient {
.get_rule_request(rcr_id)
.await
.map(ResponseValue::into_inner)
.map_err(|e| anyhow::anyhow!("API error: {e}"))
.map_err(api_error)
}

pub async fn list_rule_requests(&self, repo_id: &RepoId) -> Result<RuleRequestsResponse> {
self.inner
.list_rule_requests(repo_id)
.await
.map(ResponseValue::into_inner)
.map_err(|e| anyhow::anyhow!("API error: {e}"))
.map_err(api_error)
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ async fn main() -> ExitCode {
match detail_cli::Cli::parse().run().await {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
let _ = Term::stderr().write_line(&format!("Error: {err}"));
let _ = Term::stderr().write_line(&format!("Error: {err:#}"));
ExitCode::FAILURE
}
}
Expand Down
20 changes: 20 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,26 @@ fn bugs_list_rejects_invalid_repo_format() {
assert!(!out.success, "bugs list should reject a/b/c format");
}

#[test]
fn error_includes_status_code_for_auth_failures() {
// The token is well-formed (passes the local `dtl_` prefix check) so the
// CLI actually sends it to the API; we need a 401 from a real server to
// exercise the error-chain mapping. Gate on DETAIL_API_KEY like other
// live-API tests so this is skipped offline.
let _ = require_api_key!();

let env = Env::new("error_status_code");
env.write_config(r#"api_token = "dtl_live_invalid_token""#);

let out = env.run(&["repos", "list"]);
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 Skip live auth-failure test without integration opt-in

This new test is the only live API test that does not call require_api_key!() before executing a command that reaches api.detail.dev. In the documented/default case where DETAIL_API_KEY is absent, running cargo test --test integration in an offline or network-restricted environment now fails with a connection error instead of being silently skipped, so the assertion for 401/Unauthorized never holds. Please gate this test the same way as the other live API tests or use a local mock for the 401 response.

Useful? React with 👍 / 👎.

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.

Fair — the invalid token here is well-formed enough to be sent to the API (it passes the local dtl_ prefix check), so the test really does make a network call and would fail with a connection error in an offline environment. Gated on DETAIL_API_KEY like the other live-API tests in d5def86. Skipped a local mock for now since this file has no mocking infra; happy to add one if it's expected.

assert!(!out.success);
assert!(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
out.stderr.contains("401") && out.stderr.contains("Unauthorized"),
"Error should include both the 401 status code and \"Unauthorized\":\nstderr: {}",
out.stderr,
);
}

#[test]
fn config_api_url_is_used() {
let key = require_api_key!();
Expand Down
Loading