diff --git a/crates/pcl/core/src/api.rs b/crates/pcl/core/src/api.rs index 65573aa..daceaf0 100644 --- a/crates/pcl/core/src/api.rs +++ b/crates/pcl/core/src/api.rs @@ -114,14 +114,15 @@ use templates::{ template_envelope, transfer_body_template, }; -use transport::{ - read_api_response, - write_request_log, -}; pub(crate) use transport::{ + generated_error_details, request_id_from_headers, response_body_value, }; +use transport::{ + read_api_response, + write_request_log, +}; use workflow_options::ApiWorkflowOptions; use workflows::{ access_request, diff --git a/crates/pcl/core/src/api/runner.rs b/crates/pcl/core/src/api/runner.rs index 5b8523c..563263e 100644 --- a/crates/pcl/core/src/api/runner.rs +++ b/crates/pcl/core/src/api/runner.rs @@ -16,6 +16,7 @@ use super::{ SearchArgs, TransfersArgs, WorkflowCallResult, + WorkflowOperation, WorkflowPaginationOptions, WorkflowRequest, access_body_template, @@ -60,6 +61,8 @@ use super::{ releases_next_actions, releases_request, request_body, + request_id_from_headers, + response_body_value, search_next_actions, search_request, split_path_and_inline_query, @@ -81,6 +84,10 @@ use crate::{ config::CliConfig, error::AuthError, }; +use dapp_api_client::generated::client::{ + Client as GeneratedClient, + Error as GeneratedError, +}; use pcl_common::args::CliArgs; use reqwest::header::{ HeaderMap, @@ -93,6 +100,68 @@ use serde_json::{ }; use std::path::Path; +async fn generated_error_to_api_error( + method: &'static str, + path: &str, + error: GeneratedError, +) -> ApiCommandError +where + E: serde::Serialize + std::fmt::Debug, +{ + match error { + GeneratedError::ErrorResponse(response) => { + let status = response.status().as_u16(); + let request_id = request_id_from_headers(response.headers()); + let body = serde_json::to_value(response.as_ref()).unwrap_or_else(|error| { + json!({ + "error": error.to_string(), + "body": format!("{response:?}"), + }) + }); + ApiCommandError::HttpStatus { + method, + path: path.to_string(), + status, + request_id, + body: Box::new(body), + } + } + GeneratedError::UnexpectedResponse(response) => { + let status = response.status().as_u16(); + let request_id = request_id_from_headers(response.headers()); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let bytes = match response.bytes().await { + Ok(bytes) => bytes, + Err(error) => return ApiCommandError::Request(error), + }; + ApiCommandError::HttpStatus { + method, + path: path.to_string(), + status, + request_id, + body: Box::new(response_body_value(&content_type, &bytes)), + } + } + GeneratedError::CommunicationError(error) => ApiCommandError::Request(error), + GeneratedError::InvalidUpgrade(error) => ApiCommandError::Request(error), + GeneratedError::ResponseBodyError(error) => ApiCommandError::Request(error), + GeneratedError::InvalidResponsePayload(bytes, error) => { + let body = response_body_value("application/json", &bytes); + ApiCommandError::InvalidWorkflow { + message: format!("Invalid generated API response payload: {error}; body={body}"), + } + } + GeneratedError::InvalidRequest(message) | GeneratedError::Custom(message) => { + ApiCommandError::InvalidWorkflow { message } + } + } +} + fn print_api_value(output: Value, json_output: bool) -> Result<(), ApiCommandError> { print_output(&output, json_output) } @@ -841,19 +910,11 @@ impl ApiArgs { &self, config: &CliConfig, ) -> Result { - let url = self.api_url("/openapi")?; - let request = self.http_client(config, false, false)?.get(url); - let response = read_api_response(request.send().await?).await?; - if !response.status.is_success() { - return Err(ApiCommandError::HttpStatus { - method: "GET", - path: "/openapi".to_string(), - status: response.status.as_u16(), - request_id: response.request_id, - body: Box::new(response.body), - }); + let client = self.generated_client(config, false, false)?; + match client.get_openapi().await { + Ok(response) => Ok(response.into_inner()), + Err(error) => Err(generated_error_to_api_error("GET", "/openapi", error).await), } - Ok(response.body) } pub(in crate::api) async fn try_refresh_after_401( @@ -1049,7 +1110,7 @@ impl ApiArgs { &path, response.status.as_u16(), response.request_id.as_deref(), - request.operation_id, + Some(request.operation_id), ); let mut retried_after_refresh = false; if response.status.as_u16() == 401 @@ -1069,7 +1130,7 @@ impl ApiArgs { &path, response.status.as_u16(), response.request_id.as_deref(), - request.operation_id, + Some(request.operation_id), ); } if !response.status.is_success() { @@ -1238,7 +1299,11 @@ impl ApiArgs { require_auth: bool, request_log_path: &Path, ) -> Result { - let path = format!("/projects/resolve/{project_ref}"); + let operation = WorkflowOperation::new(HttpMethod::Get, "get_projects_resolve_project_ref") + .path_param("project_ref", project_ref); + let path = operation.path()?; + // Project resolution accepts slugs and must preserve 404 request IDs. The + // generated method currently loses that metadata for the shared error schema. let url = self.api_url(&path)?; let client = self.http_client(config, attach_auth, require_auth)?; let response = read_api_response(client.get(url).send().await?).await?; @@ -1249,7 +1314,7 @@ impl ApiArgs { &path, response.status.as_u16(), response.request_id.as_deref(), - Some("get_projects_resolve_project_ref"), + Some(operation.operation_id), ); if !response.status.is_success() { return Err(ApiCommandError::HttpStatus { @@ -1385,6 +1450,18 @@ impl ApiArgs { .map_err(ApiCommandError::Request) } + pub(in crate::api) fn generated_client( + &self, + config: &CliConfig, + attach_auth: bool, + require_auth: bool, + ) -> Result { + let mut base = self.api_url.clone(); + base.set_path("/api/v1"); + let http_client = self.http_client(config, attach_auth, require_auth)?; + Ok(GeneratedClient::new_with_client(base.as_str(), http_client)) + } + pub(in crate::api) async fn resolve_operation_id( &self, config: &CliConfig, diff --git a/crates/pcl/core/src/api/runtime_types.rs b/crates/pcl/core/src/api/runtime_types.rs index 885597d..d3993a3 100644 --- a/crates/pcl/core/src/api/runtime_types.rs +++ b/crates/pcl/core/src/api/runtime_types.rs @@ -55,7 +55,7 @@ pub(in crate::api) struct WorkflowCallResult { #[derive(Clone, Debug)] pub(in crate::api) struct WorkflowRequest { pub(in crate::api) method: HttpMethod, - pub(in crate::api) operation_id: Option<&'static str>, + pub(in crate::api) operation_id: &'static str, pub(in crate::api) path: String, pub(in crate::api) query: Vec<(String, String)>, pub(in crate::api) body: Option, @@ -65,32 +65,6 @@ pub(in crate::api) struct WorkflowRequest { } impl WorkflowRequest { - pub(in crate::api) fn get( - path: impl Into, - require_auth: bool, - next_actions: impl IntoIterator>, - ) -> Self { - Self::get_with_query(path, Vec::new(), require_auth, next_actions) - } - - pub(in crate::api) fn get_with_query( - path: impl Into, - query: Vec<(String, String)>, - require_auth: bool, - next_actions: impl IntoIterator>, - ) -> Self { - Self { - method: HttpMethod::Get, - operation_id: None, - path: path.into(), - query, - body: None, - require_auth, - attach_auth: require_auth, - next_actions: next_actions.into_iter().map(Into::into).collect(), - } - } - pub(in crate::api) fn with_optional_auth(mut self) -> Self { self.attach_auth = true; self @@ -105,7 +79,7 @@ impl WorkflowRequest { ) -> Result { Ok(Self { method: operation.method, - operation_id: Some(operation.operation_id), + operation_id: operation.operation_id, path: operation.path()?, query, body, diff --git a/crates/pcl/core/src/api/tests.rs b/crates/pcl/core/src/api/tests.rs index 41b12f3..ea94430 100644 --- a/crates/pcl/core/src/api/tests.rs +++ b/crates/pcl/core/src/api/tests.rs @@ -36,6 +36,28 @@ fn test_api(api_url: impl AsRef, allow_unauthenticated: bool) -> ApiArgs { } } +fn test_workflow_request( + method: HttpMethod, + operation_id: &'static str, + require_auth: bool, + next_actions: impl IntoIterator>, +) -> WorkflowRequest { + test_workflow_request_for_operation( + WorkflowOperation::new(method, operation_id), + require_auth, + next_actions, + ) +} + +fn test_workflow_request_for_operation( + operation: WorkflowOperation, + require_auth: bool, + next_actions: impl IntoIterator>, +) -> WorkflowRequest { + WorkflowRequest::from_operation(operation, Vec::new(), None, require_auth, next_actions) + .unwrap() +} + fn valid_auth_config(access_token: &str, refresh_token: &str) -> CliConfig { auth_config(access_token, refresh_token, 2030, Some("agent@example.com")) } @@ -876,7 +898,12 @@ async fn paginates_incident_list_workflows() { .create_async() .await; let api = test_api(server.url(), true); - let request = WorkflowRequest::get("/views/public/incidents", false, Vec::::new()); + let request = test_workflow_request( + HttpMethod::Get, + "get_views_public_incidents", + false, + Vec::::new(), + ); let mut config = CliConfig::default(); let cli_args = CliArgs::default(); @@ -906,7 +933,12 @@ async fn paginates_incident_list_workflows() { #[tokio::test] async fn incident_workflow_pagination_rejects_zero_limit() { let api = test_api("https://app.phylax.systems", true); - let request = WorkflowRequest::get("/views/public/incidents", false, Vec::::new()); + let request = test_workflow_request( + HttpMethod::Get, + "get_views_public_incidents", + false, + Vec::::new(), + ); let mut config = CliConfig::default(); let cli_args = CliArgs::default(); @@ -959,7 +991,12 @@ async fn authenticated_project_slug_resolution_attaches_auth() { .await; let api = test_api(server.url(), false); let mut config = valid_auth_config("access-token", "refresh-token"); - let request = WorkflowRequest::get("/projects/private-slug", true, Vec::::new()); + let request = test_workflow_request_for_operation( + WorkflowOperation::new(HttpMethod::Get, "get_projects_project_id") + .path_param("project_id", "private-slug"), + true, + Vec::::new(), + ); let result = api .call_workflow_result( @@ -992,7 +1029,12 @@ async fn project_slug_resolution_errors_preserve_http_metadata() { .await; let api = test_api(server.url(), false); let mut config = valid_auth_config("access-token", "refresh-token"); - let request = WorkflowRequest::get("/projects/missing-slug", true, Vec::::new()); + let request = test_workflow_request_for_operation( + WorkflowOperation::new(HttpMethod::Get, "get_projects_project_id") + .path_param("project_id", "missing-slug"), + true, + Vec::::new(), + ); let error = api .call_workflow_result( @@ -1084,7 +1126,12 @@ async fn public_workflows_do_not_attach_expired_stored_tokens() { &mut config, &CliArgs::default(), "search", - WorkflowRequest::get("/health", false, vec!["pcl search --health".to_string()]), + test_workflow_request( + HttpMethod::Get, + "get_health", + false, + vec!["pcl search --health".to_string()], + ), test_request_log_path(), ) .await @@ -1176,7 +1223,12 @@ async fn authenticated_workflow_retries_once_after_refresh_on_401() { }; let mut config = valid_auth_config("old_access", "old_refresh"); config.write_to_file(&cli_args).unwrap(); - let request = WorkflowRequest::get("/web/auth/me", true, Vec::::new()); + let request = test_workflow_request( + HttpMethod::Get, + "get_web_auth_me", + true, + Vec::::new(), + ); let result = api .call_workflow_result(&mut config, &cli_args, &request, test_request_log_path()) @@ -1332,6 +1384,7 @@ fn builds_project_create_body_from_typed_flags() { assert_eq!(request.path, "/projects"); assert_eq!(request.method.openapi_key(), "post"); + assert_eq!(request.operation_id, "post_projects"); assert_eq!( serde_json::from_str::(request.body.as_deref().unwrap()).unwrap(), json!({ @@ -1454,6 +1507,7 @@ fn saved_projects_require_and_send_user_id() { }) .unwrap(); assert_eq!(request.path, "/projects/saved"); + assert_eq!(request.operation_id, "get_projects_saved"); assert_eq!( request.query, vec![("user_id".to_string(), "user-1".to_string())] @@ -1470,6 +1524,7 @@ fn projects_mine_uses_authenticated_home_view() { assert_eq!(request.path, "/views/projects/home"); assert_eq!(request.method.openapi_key(), "get"); + assert_eq!(request.operation_id, "get_views_projects_home"); assert!(request.require_auth); assert_eq!( request.next_actions, @@ -1557,6 +1612,10 @@ fn release_deploy_calldata_requires_and_sends_signer_address() { request.path, "/projects/project-1/releases/release-1/deploy-calldata" ); + assert_eq!( + request.operation_id, + "get_projects_project_id_releases_release_id_deploy_calldata" + ); assert_eq!( request.query, vec![("signerAddress".to_string(), "0xsigner".to_string())] @@ -1575,6 +1634,10 @@ fn release_check_progress_and_retry_are_first_class_workflows() { progress.path, "/projects/project-1/releases/release-1/backtest-progress" ); + assert_eq!( + progress.operation_id, + "get_projects_project_id_releases_release_id_backtest_progress" + ); assert!(progress.body.is_none()); let error = releases_request(&ReleasesArgs { @@ -1596,6 +1659,10 @@ fn release_check_progress_and_retry_are_first_class_workflows() { retry.path, "/projects/project-1/releases/release-1/checks/check-1/retry" ); + assert_eq!( + retry.operation_id, + "post_projects_project_id_releases_release_id_checks_check_id_retry" + ); assert_eq!(retry.method, HttpMethod::Post); assert_eq!(retry.body, Some(json!({}).to_string())); } @@ -1890,7 +1957,7 @@ async fn workflow_http_errors_include_response_body() { .await; let api = test_api(server.url(), true); let mut config = CliConfig::default(); - let request = WorkflowRequest::get("/health", false, Vec::::new()); + let request = test_workflow_request(HttpMethod::Get, "get_health", false, Vec::::new()); let error = api .call_workflow_result( @@ -1937,7 +2004,12 @@ async fn workflow_success_envelopes_include_request_provenance() { .create_async() .await; let api = test_api(server.url(), true); - let request = WorkflowRequest::get("/health", false, vec!["next".to_string()]); + let request = test_workflow_request( + HttpMethod::Get, + "get_health", + false, + vec!["next".to_string()], + ); let mut config = CliConfig::default(); let envelope = api @@ -3196,32 +3268,6 @@ fn human_output_formats_surface_lists_for_people() { assert_output_omits(&output, &["--toon", "Details:"]); } -#[test] -fn human_output_honors_display_metadata_before_shape_detection() { - let output = human_string(&with_envelope_metadata(json!({ - "status": "ok", - "data": { - "_display": { - "kind": "collection", - "title": "Projects", - "collection": "projects", - "columns": [ - {"label": "Name", "path": "project_name"}, - {"label": "ID", "path": "project_id"} - ], - "empty": "No projects found." - }, - "projects": [ - {"project_name": "Demo", "project_id": "project-1", "ignored": "hidden"} - ] - }, - "next_actions": [] - }))); - - assert_output_contains(&output, &["Projects\n", "Name", "Demo", "project-1"]); - assert_output_omits(&output, &["ignored"]); -} - #[test] fn human_output_formats_schema_action_for_people() { let output = envelope_output_string( diff --git a/crates/pcl/core/src/api/transport.rs b/crates/pcl/core/src/api/transport.rs index 5a48ebd..0b842a5 100644 --- a/crates/pcl/core/src/api/transport.rs +++ b/crates/pcl/core/src/api/transport.rs @@ -1,5 +1,7 @@ use super::ApiCommandError; +use dapp_api_client::generated::client::Error as GeneratedError; use reqwest::header::HeaderMap; +use serde::Serialize; use serde_json::{ Map, Value, @@ -14,6 +16,12 @@ pub(in crate::api) struct ApiResponsePayload { pub(in crate::api) body: Value, } +pub(crate) struct GeneratedErrorDetails { + pub(crate) status: Option, + pub(crate) request_id: Option, + pub(crate) body: Value, +} + pub(crate) fn request_id_from_headers(headers: &HeaderMap) -> Option { [ "x-request-id", @@ -114,3 +122,74 @@ pub(crate) fn response_body_value(content_type: &str, bytes: &[u8]) -> Value { serde_json::from_slice(bytes) .unwrap_or_else(|_| json!(String::from_utf8_lossy(bytes).to_string())) } + +pub(crate) async fn generated_error_details(error: GeneratedError) -> GeneratedErrorDetails +where + E: Serialize + std::fmt::Debug, +{ + match error { + GeneratedError::ErrorResponse(response) => { + GeneratedErrorDetails { + status: Some(response.status().as_u16()), + request_id: request_id_from_headers(response.headers()), + body: serde_json::to_value(response.as_ref()).unwrap_or_else(|error| { + json!({ + "error": error.to_string(), + "body": format!("{response:?}"), + }) + }), + } + } + GeneratedError::UnexpectedResponse(response) => { + let status = response.status().as_u16(); + let request_id = request_id_from_headers(response.headers()); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = match response.bytes().await { + Ok(bytes) => response_body_value(&content_type, &bytes), + Err(error) => { + json!({ + "error": "failed to read generated error response body", + "message": error.to_string(), + }) + } + }; + GeneratedErrorDetails { + status: Some(status), + request_id, + body, + } + } + GeneratedError::InvalidResponsePayload(bytes, error) => { + GeneratedErrorDetails { + status: None, + request_id: None, + body: json!({ + "error": "invalid generated API response payload", + "message": error.to_string(), + "body": response_body_value("application/json", &bytes), + }), + } + } + GeneratedError::CommunicationError(error) + | GeneratedError::InvalidUpgrade(error) + | GeneratedError::ResponseBodyError(error) => { + GeneratedErrorDetails { + status: error.status().map(|status| status.as_u16()), + request_id: None, + body: json!(error.to_string()), + } + } + GeneratedError::InvalidRequest(message) | GeneratedError::Custom(message) => { + GeneratedErrorDetails { + status: None, + request_id: None, + body: json!(message), + } + } + } +} diff --git a/crates/pcl/core/src/api/workflows.rs b/crates/pcl/core/src/api/workflows.rs index db3a6b0..a183d71 100644 --- a/crates/pcl/core/src/api/workflows.rs +++ b/crates/pcl/core/src/api/workflows.rs @@ -114,7 +114,6 @@ pub(super) use transfers::{ use super::{ ApiCommandError, - HttpMethod, ProjectsArgs, WorkflowOperation, WorkflowRequest, @@ -127,32 +126,30 @@ use serde_json::{ }; use std::path::PathBuf; -fn workflow_with_body( - method: HttpMethod, - path: impl Into, +fn workflow_operation_with_body( + operation: WorkflowOperation, require_auth: bool, body: Option, next_actions: impl IntoIterator>, -) -> WorkflowRequest { - WorkflowRequest { - method, - operation_id: None, - path: path.into(), - query: Vec::new(), - body, - require_auth, - attach_auth: require_auth, - next_actions: next_actions.into_iter().map(Into::into).collect(), - } +) -> Result { + WorkflowRequest::from_operation(operation, Vec::new(), body, require_auth, next_actions) } -fn workflow_operation_with_body( +fn workflow_operation_get( operation: WorkflowOperation, require_auth: bool, - body: Option, next_actions: impl IntoIterator>, ) -> Result { - WorkflowRequest::from_operation(operation, Vec::new(), body, require_auth, next_actions) + workflow_operation_get_with_query(operation, Vec::new(), require_auth, next_actions) +} + +fn workflow_operation_get_with_query( + operation: WorkflowOperation, + query: Vec<(String, String)>, + require_auth: bool, + next_actions: impl IntoIterator>, +) -> Result { + WorkflowRequest::from_operation(operation, query, None, require_auth, next_actions) } fn body_or_empty(body: Option) -> String { diff --git a/crates/pcl/core/src/api/workflows/access.rs b/crates/pcl/core/src/api/workflows/access.rs index 3c0a3b3..0a294a4 100644 --- a/crates/pcl/core/src/api/workflows/access.rs +++ b/crates/pcl/core/src/api/workflows/access.rs @@ -3,13 +3,15 @@ use super::{ AccessArgs, ApiCommandError, HttpMethod, + WorkflowOperation, WorkflowRequest, }, body_or_empty, request_body, required_arg, required_project_arg, - workflow_with_body, + workflow_operation_get, + workflow_operation_with_body, }; pub(in crate::api) fn access_request( @@ -17,106 +19,127 @@ pub(in crate::api) fn access_request( ) -> Result { let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; if args.pending { - return Ok(WorkflowRequest::get( - "/invitations/pending", + return workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_invitations_pending"), true, ["pcl access accept "], - )); + ); } if args.accept || args.preview { let token = required_arg(args.token.as_deref(), "--token")?; if args.accept { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/invitations/{token}/accept"), + return workflow_operation_with_body( + WorkflowOperation::new(HttpMethod::Post, "post_invitations_token_accept") + .path_param("token", &token), true, Some(body_or_empty(body)), ["pcl projects mine"], - )); + ); } - return Ok(WorkflowRequest::get( - format!("/invitations/{token}/preview"), + return workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_invitations_token_preview") + .path_param("token", &token), false, vec![format!("pcl access accept {token}")], - )); + ); } if let Some(token) = &args.token { - return Ok(WorkflowRequest::get( - format!("/invitations/{token}/preview"), + return workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_invitations_token_preview") + .path_param("token", token), false, vec![format!("pcl access accept {token}")], - )); + ); } let project = required_project_arg(args.project.as_deref(), "access", "--project")?; if args.my_role { - return Ok(WorkflowRequest::get( - format!("/projects/{project}/my-role"), + return workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_projects_project_id_my_role") + .path_param("project_id", &project), true, vec![format!("pcl access members {project}")], - )); + ); } if args.invite { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/invitations"), + return workflow_operation_with_body( + WorkflowOperation::new(HttpMethod::Post, "post_projects_project_id_invitations") + .path_param("project_id", &project), true, body, vec![format!("pcl access invitations {project}")], - )); + ); } if args.resend || args.revoke { let invitation_id = required_arg(args.invitation_id.as_deref(), "--invitation-id")?; if args.resend { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/invitations/{invitation_id}/resend"), + return workflow_operation_with_body( + WorkflowOperation::new( + HttpMethod::Post, + "post_projects_project_id_invitations_invitation_id_resend", + ) + .path_param("project_id", &project) + .path_param("invitation_id", &invitation_id), true, Some(body_or_empty(body)), vec![format!("pcl access invitations {project}")], - )); + ); } - return Ok(workflow_with_body( - HttpMethod::Delete, - format!("/projects/{project}/invitations/{invitation_id}"), + return workflow_operation_with_body( + WorkflowOperation::new( + HttpMethod::Delete, + "delete_projects_project_id_invitations_invitation_id", + ) + .path_param("project_id", &project) + .path_param("invitation_id", &invitation_id), true, body, vec![format!("pcl access invitations {project}")], - )); + ); } if args.update_role || args.remove { let member_user_id = required_arg(args.member_user_id.as_deref(), "--member-user-id")?; if args.update_role { - return Ok(workflow_with_body( - HttpMethod::Patch, - format!("/projects/{project}/members/{member_user_id}"), + return workflow_operation_with_body( + WorkflowOperation::new( + HttpMethod::Patch, + "patch_projects_project_id_members_member_user_id", + ) + .path_param("project_id", &project) + .path_param("member_user_id", &member_user_id), true, body, vec![format!("pcl access members {project}")], - )); + ); } - return Ok(workflow_with_body( - HttpMethod::Delete, - format!("/projects/{project}/members/{member_user_id}"), + return workflow_operation_with_body( + WorkflowOperation::new( + HttpMethod::Delete, + "delete_projects_project_id_members_member_user_id", + ) + .path_param("project_id", &project) + .path_param("member_user_id", &member_user_id), true, body, vec![format!("pcl access members {project}")], - )); + ); } if args.invitations { - return Ok(WorkflowRequest::get( - format!("/projects/{project}/invitations"), + return workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_projects_project_id_invitations") + .path_param("project_id", &project), true, vec![format!("pcl access invite {project} --body-template")], - )); + ); } - Ok(WorkflowRequest::get( - format!("/projects/{project}/members"), + workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_projects_project_id_members") + .path_param("project_id", &project), true, vec![ format!("pcl access my-role {project}"), format!("pcl access invitations {project}"), ], - )) + ) } workflow_definition!( diff --git a/crates/pcl/core/src/api/workflows/account.rs b/crates/pcl/core/src/api/workflows/account.rs index 3b27333..a3e8f38 100644 --- a/crates/pcl/core/src/api/workflows/account.rs +++ b/crates/pcl/core/src/api/workflows/account.rs @@ -3,11 +3,13 @@ use super::{ AccountArgs, ApiCommandError, HttpMethod, + WorkflowOperation, WorkflowRequest, }, body_or_empty, request_body, - workflow_with_body, + workflow_operation_get, + workflow_operation_with_body, }; pub(in crate::api) fn account_request( @@ -15,28 +17,26 @@ pub(in crate::api) fn account_request( ) -> Result { let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; if args.accept_terms { - return Ok(workflow_with_body( - HttpMethod::Post, - "/web/auth/accept-terms", + return workflow_operation_with_body( + WorkflowOperation::new(HttpMethod::Post, "post_web_auth_accept_terms"), true, Some(body_or_empty(body)), ["pcl account", "pcl projects mine"], - )); + ); } if args.logout { - return Ok(workflow_with_body( - HttpMethod::Post, - "/web/auth/logout", + return workflow_operation_with_body( + WorkflowOperation::new(HttpMethod::Post, "post_web_auth_logout"), true, Some(body_or_empty(body)), ["pcl auth logout"], - )); + ); } - Ok(WorkflowRequest::get( - "/web/auth/me", + workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_web_auth_me"), true, ["pcl account --accept-terms", "pcl projects mine"], - )) + ) } workflow_definition!( diff --git a/crates/pcl/core/src/api/workflows/assertions.rs b/crates/pcl/core/src/api/workflows/assertions.rs index e734939..f170dd2 100644 --- a/crates/pcl/core/src/api/workflows/assertions.rs +++ b/crates/pcl/core/src/api/workflows/assertions.rs @@ -2,11 +2,15 @@ use super::{ super::{ ApiCommandError, AssertionsArgs, + HttpMethod, + WorkflowOperation, WorkflowRequest, }, first_string_field, push_query, required_project_arg, + workflow_operation_get, + workflow_operation_get_with_query, }; use serde_json::Value; @@ -48,24 +52,21 @@ pub(in crate::api) fn assertions_request( } if let Some(adopter_address) = &args.adopter_address { - let mut request = WorkflowRequest::get( - "/assertions", - false, - ["pcl contracts --project "], - ); - push_query(&mut request.query, "adopter_address", Some(adopter_address)); - push_query(&mut request.query, "network", args.network.as_deref()); + let mut query = Vec::new(); + push_query(&mut query, "adopter_address", Some(adopter_address)); + push_query(&mut query, "network", args.network.as_deref()); + push_query(&mut query, "environment", args.environment.as_deref()); push_query( - &mut request.query, - "environment", - args.environment.as_deref(), - ); - push_query( - &mut request.query, + &mut query, "include_onchain_only", args.include_onchain_only, ); - return Ok(request); + return workflow_operation_get_with_query( + WorkflowOperation::new(HttpMethod::Get, "get_assertions"), + query, + false, + ["pcl contracts --project "], + ); } let project_id = @@ -77,49 +78,67 @@ pub(in crate::api) fn assertions_request( push_query(&mut query, "environment", args.environment.as_deref()); if args.registered { - return Ok(WorkflowRequest::get( - format!("/projects/{project_id}/registered-assertions"), + return workflow_operation_get( + WorkflowOperation::new( + HttpMethod::Get, + "get_projects_project_id_registered_assertions", + ) + .path_param("project_id", &project_id), true, vec![format!("pcl assertions --project-id {project_id}")], - )); + ); } if args.remove_info { - return Ok(WorkflowRequest::get( - format!("/projects/{project_id}/remove-assertions-info"), + return workflow_operation_get( + WorkflowOperation::new( + HttpMethod::Get, + "get_projects_project_id_remove_assertions_info", + ) + .path_param("project_id", &project_id), true, vec![format!( "pcl assertions --project-id {project_id} --remove-calldata" )], - )); + ); } if args.remove_calldata { - return Ok(WorkflowRequest::get( - format!("/projects/{project_id}/remove-assertions-calldata"), + return workflow_operation_get( + WorkflowOperation::new( + HttpMethod::Get, + "get_projects_project_id_remove_assertions_calldata", + ) + .path_param("project_id", &project_id), true, vec![format!("pcl releases list {project_id}")], - )); + ); } if let Some(assertion_id) = &args.assertion_id { - return Ok(WorkflowRequest::get_with_query( - format!("/views/projects/{project_id}/assertions/{assertion_id}"), + return workflow_operation_get_with_query( + WorkflowOperation::new( + HttpMethod::Get, + "get_views_projects_project_id_assertions_assertion_id", + ) + .path_param("projectId", &project_id) + .path_param("assertionId", assertion_id), query, true, vec![format!( "pcl incidents --project-id {project_id} --assertion-id {assertion_id}", )], - )); + ); } - Ok(WorkflowRequest::get_with_query( - format!("/views/projects/{project_id}/assertions"), + workflow_operation_get_with_query( + WorkflowOperation::new(HttpMethod::Get, "get_views_projects_project_id_assertions") + .path_param("projectId", &project_id), query, true, vec![ format!("pcl incidents --project-id {project_id} --limit 10"), format!("pcl assertions --project-id {project_id} --assertion-id "), ], - )) + ) } workflow_definition!( diff --git a/crates/pcl/core/src/api/workflows/contracts.rs b/crates/pcl/core/src/api/workflows/contracts.rs index 08775ad..d73b091 100644 --- a/crates/pcl/core/src/api/workflows/contracts.rs +++ b/crates/pcl/core/src/api/workflows/contracts.rs @@ -3,13 +3,16 @@ use super::{ ApiCommandError, ContractsArgs, HttpMethod, + WorkflowOperation, WorkflowRequest, }, first_string_field, push_query, request_body, required_arg, - workflow_with_body, + workflow_operation_get, + workflow_operation_get_with_query, + workflow_operation_with_body, }; use serde_json::Value; @@ -18,32 +21,31 @@ pub(in crate::api) fn contracts_request( ) -> Result { let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; if args.create { - return Ok(workflow_with_body( - HttpMethod::Post, - "/assertion_adopters", + return workflow_operation_with_body( + WorkflowOperation::new(HttpMethod::Post, "post_assertion_adopters"), true, body, ["pcl contracts --unassigned --manager "], - )); + ); } if args.assign_project { - return Ok(workflow_with_body( - HttpMethod::Post, - "/assertion_adopters/assign-project", + return workflow_operation_with_body( + WorkflowOperation::new(HttpMethod::Post, "post_assertion_adopters_assign_project"), true, body, ["pcl contracts --project "], - )); + ); } if args.unassigned { let manager = required_arg(args.manager.as_deref(), "--manager")?; - let mut request = WorkflowRequest::get( - "/assertion_adopters/no-project", + let mut query = Vec::new(); + push_query(&mut query, "manager", Some(manager)); + return workflow_operation_get_with_query( + WorkflowOperation::new(HttpMethod::Get, "get_assertion_adopters_no_project"), + query, true, ["pcl contracts --assign-project --body-template"], ); - push_query(&mut request.query, "manager", Some(manager)); - return Ok(request); } if args.remove_calldata { let address = required_arg(args.aa_address.as_deref(), "--aa-address")?; @@ -52,55 +54,63 @@ pub(in crate::api) fn contracts_request( message: "--assertion-id is required for --remove-calldata".to_string(), }); } - let mut request = WorkflowRequest::get( - format!("/assertion_adopters/{address}/remove-assertions-calldata"), + let mut query = Vec::new(); + push_query(&mut query, "network", args.network.as_deref()); + push_query(&mut query, "environment", args.environment.as_deref()); + for assertion_id in &args.assertion_ids { + push_query(&mut query, "assertion_ids", Some(assertion_id)); + } + return workflow_operation_get_with_query( + WorkflowOperation::new( + HttpMethod::Get, + "get_assertion_adopters_aa_address_remove_assertions_calldata", + ) + .path_param("aa_address", &address), + query, true, ["pcl releases list "], ); - push_query(&mut request.query, "network", args.network.as_deref()); - push_query( - &mut request.query, - "environment", - args.environment.as_deref(), - ); - for assertion_id in &args.assertion_ids { - push_query(&mut request.query, "assertion_ids", Some(assertion_id)); - } - return Ok(request); } if args.remove { let project = required_arg(args.project.as_deref(), "--project")?; let address = required_arg(args.aa_address.as_deref(), "--aa-address")?; - return Ok(workflow_with_body( - HttpMethod::Delete, - format!("/projects/{project}/{address}"), + return workflow_operation_with_body( + WorkflowOperation::new(HttpMethod::Delete, "delete_projects_project_id_aa_contract") + .path_param("project_id", &project) + .path_param("aa_contract", &address), true, body, vec![format!("pcl contracts --project {project}")], - )); + ); } if let Some(project) = &args.project { if let Some(adopter_id) = &args.adopter_id { - return Ok(WorkflowRequest::get( - format!("/views/projects/{project}/contracts/{adopter_id}"), + return workflow_operation_get( + WorkflowOperation::new( + HttpMethod::Get, + "get_views_projects_project_id_contracts_adopter_id", + ) + .path_param("projectId", project) + .path_param("adopterId", adopter_id), true, vec![format!("pcl contracts --project {project}")], - )); + ); } - return Ok(WorkflowRequest::get( - format!("/views/projects/{project}/contracts"), + return workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_views_projects_project_id_contracts") + .path_param("projectId", project), true, vec![format!( "pcl contracts --project {project} --adopter-id " )], - )); + ); } - Ok(WorkflowRequest::get( - "/assertion_adopters", + workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_assertion_adopters"), true, ["pcl contracts --unassigned --manager "], - )) + ) } pub(in crate::api) fn contracts_next_actions( diff --git a/crates/pcl/core/src/api/workflows/deployments.rs b/crates/pcl/core/src/api/workflows/deployments.rs index 054ea8d..92bb333 100644 --- a/crates/pcl/core/src/api/workflows/deployments.rs +++ b/crates/pcl/core/src/api/workflows/deployments.rs @@ -3,12 +3,14 @@ use super::{ ApiCommandError, DeploymentsArgs, HttpMethod, + WorkflowOperation, WorkflowRequest, }, redact_large_artifacts, request_body, required_project_arg, - workflow_with_body, + workflow_operation_get, + workflow_operation_with_body, }; use serde_json::Value; @@ -18,19 +20,23 @@ pub(in crate::api) fn deployments_request( let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; let project = required_project_arg(args.project.as_deref(), "deployments", "--project")?; if args.confirm { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/confirm-deployment"), + return workflow_operation_with_body( + WorkflowOperation::new( + HttpMethod::Post, + "post_projects_project_id_confirm_deployment", + ) + .path_param("project_id", &project), true, body, vec![format!("pcl deployments --project {project}")], - )); + ); } - Ok(WorkflowRequest::get( - format!("/views/projects/{project}/deployments"), + workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_views_projects_project_id_deployments") + .path_param("projectId", &project), true, vec![format!("pcl releases list {project}")], - )) + ) } pub(in crate::api) fn compact_deployment_data(data: &Value) -> Value { diff --git a/crates/pcl/core/src/api/workflows/events.rs b/crates/pcl/core/src/api/workflows/events.rs index 185ccf4..7e8ea4f 100644 --- a/crates/pcl/core/src/api/workflows/events.rs +++ b/crates/pcl/core/src/api/workflows/events.rs @@ -2,10 +2,13 @@ use super::{ super::{ ApiCommandError, EventsArgs, + HttpMethod, + WorkflowOperation, WorkflowRequest, }, push_query, required_project_arg, + workflow_operation_get, }; pub(in crate::api) fn events_request( @@ -13,17 +16,19 @@ pub(in crate::api) fn events_request( ) -> Result { let project = required_project_arg(args.project.as_deref(), "events", "--project")?; let mut request = if args.audit_log { - WorkflowRequest::get( - format!("/views/projects/{project}/audit-log"), + workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_views_projects_project_id_audit_log") + .path_param("projectId", &project), true, vec![format!("pcl events --project {project}")], - ) + )? } else { - WorkflowRequest::get( - format!("/views/projects/{project}/events"), + workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_views_projects_project_id_events") + .path_param("projectId", &project), true, vec![format!("pcl events --project {project} --audit-log")], - ) + )? }; push_query(&mut request.query, "page", args.page); push_query(&mut request.query, "limit", args.limit); diff --git a/crates/pcl/core/src/api/workflows/integrations.rs b/crates/pcl/core/src/api/workflows/integrations.rs index 8d9900f..4969b5d 100644 --- a/crates/pcl/core/src/api/workflows/integrations.rs +++ b/crates/pcl/core/src/api/workflows/integrations.rs @@ -2,13 +2,16 @@ use super::{ super::{ ApiCommandError, HttpMethod, + IntegrationProvider, IntegrationsArgs, + WorkflowOperation, WorkflowRequest, }, body_or_empty, request_body, required_project_arg, - workflow_with_body, + workflow_operation_get, + workflow_operation_with_body, }; pub(in crate::api) fn integrations_request( @@ -26,51 +29,114 @@ pub(in crate::api) fn integrations_request( ], }); }; - let provider = provider.path(); - let base = format!("/projects/{project}/integrations/{provider}"); + let provider_path = provider.path(); if args.configure { - return Ok(workflow_with_body( - HttpMethod::Post, - base, + return workflow_operation_with_body( + integration_operation(provider, IntegrationAction::Configure) + .path_param("project_id", &project), true, body, vec![format!( - "pcl integrations --project {project} --provider {provider}" + "pcl integrations --project {project} --provider {provider_path}" )], - )); + ); } if args.test { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("{base}/test"), + return workflow_operation_with_body( + integration_operation(provider, IntegrationAction::Test) + .path_param("project_id", &project), true, Some(body_or_empty(body)), vec![format!( - "pcl integrations --project {project} --provider {provider}" + "pcl integrations --project {project} --provider {provider_path}" )], - )); + ); } if args.delete { - return Ok(workflow_with_body( - HttpMethod::Delete, - base, + return workflow_operation_with_body( + integration_operation(provider, IntegrationAction::Delete) + .path_param("project_id", &project), true, body, vec![format!( - "pcl integrations --project {project} --provider {provider}" + "pcl integrations --project {project} --provider {provider_path}" )], - )); + ); } - Ok(WorkflowRequest::get( - base, + workflow_operation_get( + integration_operation(provider, IntegrationAction::Get).path_param("project_id", &project), true, vec![ - format!("pcl integrations --project {project} --provider {provider} --test"), + format!("pcl integrations --project {project} --provider {provider_path} --test"), format!( - "pcl integrations --project {project} --provider {provider} --configure --body-template" + "pcl integrations --project {project} --provider {provider_path} --configure --body-template" ), ], - )) + ) +} + +#[derive(Clone, Copy)] +enum IntegrationAction { + Get, + Configure, + Test, + Delete, +} + +fn integration_operation( + provider: IntegrationProvider, + action: IntegrationAction, +) -> WorkflowOperation { + match (provider, action) { + (IntegrationProvider::Slack, IntegrationAction::Get) => { + WorkflowOperation::new( + HttpMethod::Get, + "get_projects_project_id_integrations_slack", + ) + } + (IntegrationProvider::Slack, IntegrationAction::Configure) => { + WorkflowOperation::new( + HttpMethod::Post, + "post_projects_project_id_integrations_slack", + ) + } + (IntegrationProvider::Slack, IntegrationAction::Test) => { + WorkflowOperation::new( + HttpMethod::Post, + "post_projects_project_id_integrations_slack_test", + ) + } + (IntegrationProvider::Slack, IntegrationAction::Delete) => { + WorkflowOperation::new( + HttpMethod::Delete, + "delete_projects_project_id_integrations_slack", + ) + } + (IntegrationProvider::Pagerduty, IntegrationAction::Get) => { + WorkflowOperation::new( + HttpMethod::Get, + "get_projects_project_id_integrations_pagerduty", + ) + } + (IntegrationProvider::Pagerduty, IntegrationAction::Configure) => { + WorkflowOperation::new( + HttpMethod::Post, + "post_projects_project_id_integrations_pagerduty", + ) + } + (IntegrationProvider::Pagerduty, IntegrationAction::Test) => { + WorkflowOperation::new( + HttpMethod::Post, + "post_projects_project_id_integrations_pagerduty_test", + ) + } + (IntegrationProvider::Pagerduty, IntegrationAction::Delete) => { + WorkflowOperation::new( + HttpMethod::Delete, + "delete_projects_project_id_integrations_pagerduty", + ) + } + } } workflow_definition!( diff --git a/crates/pcl/core/src/api/workflows/projects.rs b/crates/pcl/core/src/api/workflows/projects.rs index 03c4bdc..ab00680 100644 --- a/crates/pcl/core/src/api/workflows/projects.rs +++ b/crates/pcl/core/src/api/workflows/projects.rs @@ -3,6 +3,7 @@ use super::{ ApiCommandError, HttpMethod, ProjectsArgs, + WorkflowOperation, WorkflowRequest, }, first_string_field, @@ -10,7 +11,7 @@ use super::{ push_query, required_arg, required_project_arg, - workflow_with_body, + workflow_operation_with_body, }; use serde_json::{ Value, @@ -43,35 +44,36 @@ pub(in crate::api) fn projects_request( let body = project_request_body(args)?; if args.create { - return Ok(workflow_with_body( - HttpMethod::Post, - "/projects", + return workflow_operation_with_body( + WorkflowOperation::new(HttpMethod::Post, "post_projects"), true, body, vec!["pcl projects mine".to_string()], - )); + ); } if args.mine { - return Ok(WorkflowRequest::get_with_query( - "/views/projects/home", + return WorkflowRequest::from_operation( + WorkflowOperation::new(HttpMethod::Get, "get_views_projects_home"), query, + None, true, vec![ "pcl account".to_string(), "pcl projects saved --user-id ".to_string(), ], - )); + ); } if args.saved { let user_id = required_arg(args.user_id.as_deref(), "--user-id")?; push_query(&mut query, "user_id", Some(user_id)); - return Ok(WorkflowRequest::get_with_query( - "/projects/saved", + return WorkflowRequest::from_operation( + WorkflowOperation::new(HttpMethod::Get, "get_projects_saved"), query, + None, true, vec!["pcl projects mine".to_string()], - )); + ); } if args.project_id.is_none() && (args.update || args.delete || args.save || args.unsave || args.resolve || args.widget) @@ -80,72 +82,79 @@ pub(in crate::api) fn projects_request( } if let Some(project_id) = &args.project_id { if args.resolve { - return Ok(WorkflowRequest::get_with_query( - format!("/projects/resolve/{project_id}"), + return Ok(WorkflowRequest::from_operation( + WorkflowOperation::new(HttpMethod::Get, "get_projects_resolve_project_ref") + .path_param("project_ref", project_id), query, + None, false, vec![format!("pcl projects show {project_id}")], - ) + )? .with_optional_auth()); } if args.widget { - return Ok(WorkflowRequest::get( - format!("/projects/{project_id}/widget"), + return WorkflowRequest::from_operation( + WorkflowOperation::new(HttpMethod::Get, "get_projects_project_id_widget") + .path_param("project_id", project_id), + Vec::new(), + None, true, vec![format!("pcl projects show {project_id}")], - )); + ); } if args.save || args.unsave { - return Ok(workflow_with_body( + return workflow_operation_with_body( if args.save { - HttpMethod::Post + WorkflowOperation::new(HttpMethod::Post, "post_projects_saved") } else { - HttpMethod::Delete + WorkflowOperation::new(HttpMethod::Delete, "delete_projects_saved") }, - "/projects/saved", true, Some(json!({ "project_id": project_id }).to_string()), vec![ format!("pcl projects show {project_id}"), "pcl projects mine".to_string(), ], - )); + ); } if args.update { - return Ok(workflow_with_body( - HttpMethod::Put, - format!("/projects/{project_id}"), + return workflow_operation_with_body( + WorkflowOperation::new(HttpMethod::Put, "put_projects_project_id") + .path_param("project_id", project_id), true, body, vec![format!("pcl projects show {project_id}")], - )); + ); } if args.delete { - return Ok(workflow_with_body( - HttpMethod::Delete, - format!("/projects/{project_id}"), + return workflow_operation_with_body( + WorkflowOperation::new(HttpMethod::Delete, "delete_projects_project_id") + .path_param("project_id", project_id), true, body, ["pcl projects mine"], - )); + ); } - return Ok(WorkflowRequest::get_with_query( - format!("/projects/{project_id}"), + return WorkflowRequest::from_operation( + WorkflowOperation::new(HttpMethod::Get, "get_projects_project_id") + .path_param("project_id", project_id), query, + None, true, vec![ format!("pcl assertions --project-id {project_id}"), format!("pcl incidents --project-id {project_id} --limit 10"), ], - )); + ); } - Ok(WorkflowRequest::get_with_query( - "/views/projects", + WorkflowRequest::from_operation( + WorkflowOperation::new(HttpMethod::Get, "get_views_projects"), query, + None, false, ["pcl projects show ", "pcl incidents --limit 5"], - )) + ) } workflow_definition!( diff --git a/crates/pcl/core/src/api/workflows/protocol_manager.rs b/crates/pcl/core/src/api/workflows/protocol_manager.rs index 726bee7..b309ca5 100644 --- a/crates/pcl/core/src/api/workflows/protocol_manager.rs +++ b/crates/pcl/core/src/api/workflows/protocol_manager.rs @@ -3,6 +3,7 @@ use super::{ ApiCommandError, HttpMethod, ProtocolManagerArgs, + WorkflowOperation, WorkflowRequest, }, first_string_field, @@ -10,7 +11,8 @@ use super::{ request_body, required_arg, required_project_arg, - workflow_with_body, + workflow_operation_get, + workflow_operation_with_body, }; use serde_json::Value; @@ -19,76 +21,100 @@ pub(in crate::api) fn protocol_manager_request( ) -> Result { let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; let project = required_project_arg(args.project.as_deref(), "protocol-manager", "--project")?; - let base = format!("/projects/{project}/protocol-manager"); if args.nonce { let address = required_arg(args.address.as_deref(), "--address")?; - let mut request = WorkflowRequest::get( - format!("{base}/nonce"), + let mut request = workflow_operation_get( + WorkflowOperation::new( + HttpMethod::Get, + "get_projects_project_id_protocol_manager_nonce", + ) + .path_param("project_id", &project), true, vec![format!( "pcl protocol-manager --project {project} --set --body-template" )], - ); + )?; push_query(&mut request.query, "address", Some(address)); push_query(&mut request.query, "chain_id", args.chain_id); return Ok(request); } if args.set { - return Ok(workflow_with_body( - HttpMethod::Post, - base, + return workflow_operation_with_body( + WorkflowOperation::new( + HttpMethod::Post, + "post_projects_project_id_protocol_manager", + ) + .path_param("project_id", &project), true, body, vec![format!( "pcl protocol-manager --project {project} --pending-transfer" )], - )); + ); } if args.clear { - return Ok(workflow_with_body( - HttpMethod::Delete, - base, + return workflow_operation_with_body( + WorkflowOperation::new( + HttpMethod::Delete, + "delete_projects_project_id_protocol_manager", + ) + .path_param("project_id", &project), true, body, vec![format!( "pcl protocol-manager --project {project} --nonce --address " )], - )); + ); } if args.transfer_calldata { let new_manager = required_arg(args.new_manager.as_deref(), "--new-manager")?; - let mut request = WorkflowRequest::get( - format!("{base}/transfer-calldata"), + let mut request = workflow_operation_get( + WorkflowOperation::new( + HttpMethod::Get, + "get_projects_project_id_protocol_manager_transfer_calldata", + ) + .path_param("project_id", &project), true, vec![format!( "pcl protocol-manager --project {project} --set --body-template" )], - ); + )?; push_query(&mut request.query, "new_manager", Some(new_manager)); return Ok(request); } if args.accept_calldata { - return Ok(WorkflowRequest::get( - format!("{base}/accept-calldata"), + return workflow_operation_get( + WorkflowOperation::new( + HttpMethod::Get, + "get_projects_project_id_protocol_manager_accept_calldata", + ) + .path_param("project_id", &project), true, vec![format!( "pcl protocol-manager --project {project} --confirm-transfer --body-template" )], - )); + ); } if args.confirm_transfer { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("{base}/confirm-transfer"), + return workflow_operation_with_body( + WorkflowOperation::new( + HttpMethod::Post, + "post_projects_project_id_protocol_manager_confirm_transfer", + ) + .path_param("project_id", &project), true, body, vec![format!( "pcl protocol-manager --project {project} --pending-transfer" )], - )); + ); } - Ok(WorkflowRequest::get( - format!("{base}/pending-transfer"), + workflow_operation_get( + WorkflowOperation::new( + HttpMethod::Get, + "get_projects_project_id_protocol_manager_pending_transfer", + ) + .path_param("project_id", &project), true, vec![ format!("pcl protocol-manager --project {project} --nonce --address "), @@ -96,7 +122,7 @@ pub(in crate::api) fn protocol_manager_request( "pcl protocol-manager --project {project} --transfer-calldata --new-manager " ), ], - )) + ) } pub(in crate::api) fn protocol_manager_next_actions( diff --git a/crates/pcl/core/src/api/workflows/releases.rs b/crates/pcl/core/src/api/workflows/releases.rs index d059978..b748b98 100644 --- a/crates/pcl/core/src/api/workflows/releases.rs +++ b/crates/pcl/core/src/api/workflows/releases.rs @@ -3,6 +3,7 @@ use super::{ ApiCommandError, HttpMethod, ReleasesArgs, + WorkflowOperation, WorkflowRequest, }, body_or_empty, @@ -11,7 +12,7 @@ use super::{ request_body, required_arg, required_project_arg, - workflow_with_body, + workflow_operation_with_body, }; use serde_json::Value; @@ -21,24 +22,27 @@ pub(in crate::api) fn releases_request( let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; let project = required_project_arg(args.project.as_deref(), "releases", "--project")?; if args.preview { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/releases/preview"), + return workflow_operation_with_body( + WorkflowOperation::new( + HttpMethod::Post, + "post_projects_project_id_releases_preview", + ) + .path_param("project_id", &project), true, body, vec![format!( "pcl releases create {project} --body-file release.json" )], - )); + ); } if args.create { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/releases"), + return workflow_operation_with_body( + WorkflowOperation::new(HttpMethod::Post, "post_projects_project_id_releases") + .path_param("project_id", &project), true, body, vec![format!("pcl releases list {project}")], - )); + ); } if args.deploy || args.remove @@ -49,67 +53,111 @@ pub(in crate::api) fn releases_request( { let release_id = required_arg(args.release_id.as_deref(), "--release-id")?; if args.backtest_progress { - return Ok(WorkflowRequest::get( - format!("/projects/{project}/releases/{release_id}/backtest-progress"), + return WorkflowRequest::from_operation( + WorkflowOperation::new( + HttpMethod::Get, + "get_projects_project_id_releases_release_id_backtest_progress", + ) + .path_param("project_id", &project) + .path_param("release_id", &release_id), + Vec::new(), + None, true, vec![format!("pcl releases show {project} {release_id}")], - )); + ); } if args.retry_check { let check_id = required_arg(args.check_id.as_deref(), "--check-id")?; - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/releases/{release_id}/checks/{check_id}/retry"), + return workflow_operation_with_body( + WorkflowOperation::new( + HttpMethod::Post, + "post_projects_project_id_releases_release_id_checks_check_id_retry", + ) + .path_param("project_id", &project) + .path_param("release_id", &release_id) + .path_param("check_id", &check_id), true, Some(body_or_empty(body)), vec![format!( "pcl releases backtest-progress {project} {release_id}" )], - )); + ); } if args.deploy { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/releases/{release_id}/deploy"), + return workflow_operation_with_body( + WorkflowOperation::new( + HttpMethod::Post, + "post_projects_project_id_releases_release_id_deploy", + ) + .path_param("project_id", &project) + .path_param("release_id", &release_id), true, body, vec![format!("pcl releases show {project} {release_id}")], - )); + ); } if args.remove { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/releases/{release_id}/remove"), + return workflow_operation_with_body( + WorkflowOperation::new( + HttpMethod::Post, + "post_projects_project_id_releases_release_id_remove", + ) + .path_param("project_id", &project) + .path_param("release_id", &release_id), true, body, vec![format!("pcl releases list {project}")], - )); + ); } if args.deploy_calldata { let signer_address = required_arg(args.signer_address.as_deref(), "--signer-address")?; - let mut request = WorkflowRequest::get( - format!("/projects/{project}/releases/{release_id}/deploy-calldata"), + let mut request = WorkflowRequest::from_operation( + WorkflowOperation::new( + HttpMethod::Get, + "get_projects_project_id_releases_release_id_deploy_calldata", + ) + .path_param("project_id", &project) + .path_param("release_id", &release_id), + Vec::new(), + None, true, vec![format!("pcl releases deploy {project} {release_id}")], - ); + )?; push_query(&mut request.query, "signerAddress", Some(signer_address)); return Ok(request); } - return Ok(WorkflowRequest::get( - format!("/projects/{project}/releases/{release_id}/remove-calldata"), + return WorkflowRequest::from_operation( + WorkflowOperation::new( + HttpMethod::Get, + "get_projects_project_id_releases_release_id_remove_calldata", + ) + .path_param("project_id", &project) + .path_param("release_id", &release_id), + Vec::new(), + None, true, vec![format!("pcl releases remove {project} {release_id}")], - )); + ); } let Some(release_id) = &args.release_id else { - return Ok(WorkflowRequest::get( - format!("/projects/{project}/releases"), + return WorkflowRequest::from_operation( + WorkflowOperation::new(HttpMethod::Get, "get_projects_project_id_releases") + .path_param("project_id", &project), + Vec::new(), + None, true, vec![format!("pcl releases show {project} ")], - )); + ); }; - Ok(WorkflowRequest::get( - format!("/projects/{project}/releases/{release_id}"), + WorkflowRequest::from_operation( + WorkflowOperation::new( + HttpMethod::Get, + "get_projects_project_id_releases_release_id", + ) + .path_param("project_id", &project) + .path_param("release_id", release_id), + Vec::new(), + None, true, vec![ format!( @@ -117,7 +165,7 @@ pub(in crate::api) fn releases_request( ), format!("pcl releases calldata remove {project} {release_id}"), ], - )) + ) } pub(in crate::api) fn releases_next_actions( diff --git a/crates/pcl/core/src/api/workflows/search.rs b/crates/pcl/core/src/api/workflows/search.rs index c229fd5..2ba4443 100644 --- a/crates/pcl/core/src/api/workflows/search.rs +++ b/crates/pcl/core/src/api/workflows/search.rs @@ -1,12 +1,16 @@ use super::{ super::{ ApiCommandError, + HttpMethod, SearchArgs, + WorkflowOperation, WorkflowRequest, }, first_string_field, push_query, required_arg, + workflow_operation_get, + workflow_operation_get_with_query, }; use serde_json::Value; @@ -14,32 +18,32 @@ pub(in crate::api) fn search_request( args: &SearchArgs, ) -> Result { if args.health { - return Ok(WorkflowRequest::get( - "/health", + return workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_health"), false, ["pcl search --system-status"], - )); + ); } if args.system_status { - return Ok(WorkflowRequest::get( - "/system-status", + return workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_system_status"), false, ["pcl search --stats"], - )); + ); } if args.stats { - return Ok(WorkflowRequest::get( - "/stats", + return workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_stats"), false, ["pcl projects list --limit 10"], - )); + ); } if args.whitelist { - return Ok(WorkflowRequest::get( - "/whitelist", + return workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_whitelist"), true, ["pcl projects mine"], - )); + ); } if args.verified_contract { let address = required_arg(args.address.as_deref(), "--address")?; @@ -53,14 +57,15 @@ pub(in crate::api) fn search_request( ], } })?; - let mut request = WorkflowRequest::get( - "/web/verified-contract", + let mut query = Vec::new(); + push_query(&mut query, "address", Some(address)); + push_query(&mut query, "chainId", Some(chain_id)); + return workflow_operation_get_with_query( + WorkflowOperation::new(HttpMethod::Get, "get_web_verified_contract"), + query, false, ["pcl contracts --project "], ); - push_query(&mut request.query, "address", Some(address)); - push_query(&mut request.query, "chainId", Some(chain_id)); - return Ok(request); } let query = args @@ -81,16 +86,17 @@ pub(in crate::api) fn search_request( } })?; - let mut request = WorkflowRequest::get( - "/search", + let mut query_params = Vec::new(); + push_query(&mut query_params, "query", Some(query)); + workflow_operation_get_with_query( + WorkflowOperation::new(HttpMethod::Get, "get_search"), + query_params, false, [ "pcl projects show ", "pcl contracts --project ", ], - ); - push_query(&mut request.query, "query", Some(query)); - Ok(request) + ) } pub(in crate::api) fn search_next_actions(data: &Value, fallback: Vec) -> Vec { diff --git a/crates/pcl/core/src/api/workflows/transfers.rs b/crates/pcl/core/src/api/workflows/transfers.rs index c2f268d..c3f2a69 100644 --- a/crates/pcl/core/src/api/workflows/transfers.rs +++ b/crates/pcl/core/src/api/workflows/transfers.rs @@ -3,11 +3,13 @@ use super::{ ApiCommandError, HttpMethod, TransfersArgs, + WorkflowOperation, WorkflowRequest, }, first_string_field, request_body, - workflow_with_body, + workflow_operation_get, + workflow_operation_with_body, }; use serde_json::Value; @@ -16,26 +18,26 @@ pub(in crate::api) fn transfers_request( ) -> Result { let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; if args.reject { - return Ok(workflow_with_body( - HttpMethod::Post, - "/transfers/reject", + return workflow_operation_with_body( + WorkflowOperation::new(HttpMethod::Post, "post_transfers_reject"), true, body, ["pcl transfers --pending"], - )); + ); } if let Some(transfer_id) = &args.transfer_id { - return Ok(WorkflowRequest::get( - format!("/views/transfers/{transfer_id}"), + return workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_views_transfers_transfer_id") + .path_param("transferId", transfer_id), true, ["pcl transfers --pending"], - )); + ); } - Ok(WorkflowRequest::get( - "/views/transfers/pending", + workflow_operation_get( + WorkflowOperation::new(HttpMethod::Get, "get_views_transfers_pending"), true, ["pcl transfers --transfer-id "], - )) + ) } pub(in crate::api) fn transfers_next_actions( diff --git a/crates/pcl/core/src/apply.rs b/crates/pcl/core/src/apply.rs index feb463e..ccef005 100644 --- a/crates/pcl/core/src/apply.rs +++ b/crates/pcl/core/src/apply.rs @@ -12,7 +12,6 @@ use crate::{ client::{ ClientBuildError, authenticated_client, - authorization_header, ensure_fresh_auth, }, config::CliConfig, @@ -33,11 +32,13 @@ use alloy_primitives::Bytes; use clap::ValueHint; use dapp_api_client::generated::client::{ Client as GeneratedClient, + Error as GeneratedError, types::{ GetProjectsResponseItem, PostProjectsProjectIdReleasesBody, PostProjectsProjectIdReleasesBodyContractsValue, PostProjectsProjectIdReleasesBodyContractsValueAssertionsItem, + PostProjectsProjectIdReleasesPreviewBody, PostProjectsProjectIdReleasesResponse, }, }; @@ -150,8 +151,8 @@ impl ApplyArgs { } Self::ensure_fresh_auth(config, cli_args, &self.api_url).await?; - let (http_client, base_url) = Self::build_http_client(config, &self.api_url)?; - let preview = Self::call_preview(&http_client, &base_url, &project_id, &payload).await?; + let client = self.build_client(config)?; + let preview = Self::call_preview(&client, &project_id, &payload).await?; if !preview.has_changes() { if output_mode == OutputMode::Human { @@ -188,19 +189,19 @@ impl ApplyArgs { } } - let client = self.build_client(config)?; - - let release = client + let release = match client .post_projects_project_id_releases(&project_id, None, &payload) .await - .map(dapp_api_client::generated::client::ResponseValue::into_inner) - .map_err(|e| { - ApplyError::Api { - endpoint: format!("/projects/{project_id}/releases"), - status: e.status().map(|s| s.as_u16()), - body: e.to_string(), - } - })?; + { + Ok(response) => response.into_inner(), + Err(error) => { + return Err(generated_apply_api_error( + format!("/projects/{project_id}/releases"), + error, + ) + .await); + } + }; if output_mode != OutputMode::Human { let envelope = ok_envelope( @@ -263,26 +264,6 @@ impl ApplyArgs { .map_err(client_error_to_apply) } - fn build_http_client( - config: &CliConfig, - api_url: &Url, - ) -> Result<(reqwest::Client, String), ApplyError> { - let mut base = api_url.clone(); - base.set_path("/api/v1"); - let base_url = base.to_string(); - - let mut headers = reqwest::header::HeaderMap::new(); - let header_value = authorization_header(config).map_err(client_error_to_apply)?; - headers.insert(reqwest::header::AUTHORIZATION, header_value); - - let http_client = reqwest::Client::builder() - .default_headers(headers) - .build() - .map_err(|e| ApplyError::InvalidConfig(format!("Failed to build HTTP client: {e}")))?; - - Ok((http_client, base_url)) - } - #[cfg(feature = "credible")] fn print_dry_run_output( &self, @@ -335,38 +316,26 @@ impl ApplyArgs { } async fn call_preview( - http_client: &reqwest::Client, - base_url: &str, + client: &GeneratedClient, project_id: &Uuid, payload: &PostProjectsProjectIdReleasesBody, ) -> Result { - let url = format!("{base_url}/projects/{project_id}/releases/preview"); - let response = http_client - .post(&url) - .json(payload) - .send() + let endpoint = format!("/projects/{project_id}/releases/preview"); + let body: PostProjectsProjectIdReleasesPreviewBody = + serde_json::from_value(serde_json::to_value(payload)?)?; + let response = match client + .post_projects_project_id_releases_preview(project_id, None, &body) .await - .map_err(|e| { - ApplyError::Api { - endpoint: format!("/projects/{project_id}/releases/preview"), - status: e.status().map(|s| s.as_u16()), - body: e.to_string(), - } - })?; - - if !response.status().is_success() { - let status = response.status().as_u16(); - let body = response.text().await.unwrap_or_default(); - return Err(ApplyError::Api { - endpoint: format!("/projects/{project_id}/releases/preview"), - status: Some(status), - body, - }); - } + { + Ok(response) => response.into_inner(), + Err(error) => { + return Err(generated_apply_api_error(endpoint.clone(), error).await); + } + }; - response.json::().await.map_err(|e| { + serde_json::from_value(serde_json::to_value(response)?).map_err(|e| { ApplyError::Api { - endpoint: format!("/projects/{project_id}/releases/preview"), + endpoint, status: None, body: format!("Failed to parse preview response: {e}"), } @@ -674,6 +643,23 @@ fn client_error_to_apply(error: ClientBuildError) -> ApplyError { } } +async fn generated_apply_api_error(endpoint: String, error: GeneratedError) -> ApplyError +where + E: serde::Serialize + std::fmt::Debug, +{ + let details = crate::api::generated_error_details(error).await; + ApplyError::Api { + endpoint, + status: details.status, + body: generated_error_body_string(&details.body), + } +} + +fn generated_error_body_string(body: &Value) -> String { + body.as_str() + .map_or_else(|| body.to_string(), ToString::to_string) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pcl/core/src/auth.rs b/crates/pcl/core/src/auth.rs index a864dfd..6606a94 100644 --- a/crates/pcl/core/src/auth.rs +++ b/crates/pcl/core/src/auth.rs @@ -21,6 +21,7 @@ use color_eyre::Result; use colored::Colorize; use dapp_api_client::generated::client::{ Client as GeneratedClient, + Error as GeneratedError, types::{ GetCliAuthCodeResponse, GetCliAuthStatusResponse, @@ -36,7 +37,9 @@ use pcl_common::args::{ current_output_mode, }; use reqwest::header::{ + AUTHORIZATION, CONTENT_TYPE, + HeaderMap, HeaderName, HeaderValue, RETRY_AFTER, @@ -76,6 +79,13 @@ pub struct RefreshOutcome { pub request_id: Option, } +struct RefreshErrorDetails { + status: Option, + request_id: Option, + code: Option, + message: Option, +} + #[derive(Deserialize)] struct RefreshResponse { token: String, @@ -84,11 +94,6 @@ struct RefreshResponse { refresh_expires_at: chrono::DateTime, } -struct RefreshErrorDetails { - code: Option, - message: Option, -} - struct RefreshLock { path: PathBuf, } @@ -455,6 +460,24 @@ impl AuthCommand { GeneratedClient::new(base.as_str()) } + fn authenticated_api_client(&self, access_token: &str) -> Result { + let mut base = self.effective_auth_url(); + base.set_path("/api/v1"); + + let mut headers = HeaderMap::new(); + let auth_value = format!("Bearer {access_token}"); + let auth_header = HeaderValue::from_str(&auth_value) + .map_err(|error| format!("Invalid auth token: {error}"))?; + headers.insert(AUTHORIZATION, auth_header); + + let http_client = reqwest::Client::builder() + .default_headers(headers) + .build() + .map_err(|error| format!("Failed to build HTTP client: {error}"))?; + + Ok(GeneratedClient::new_with_client(base.as_str(), http_client)) + } + /// Request an authentication code from the server async fn request_auth_code( client: &GeneratedClient, @@ -775,6 +798,9 @@ impl AuthCommand { auth_url: &url::Url, refresh_token: &str, ) -> Result { + // Preserve status, request id, and standard error bodies for token rotation. + // The generated method currently drops that metadata when error payloads do not + // deserialize as the success response type. let mut url = auth_url.clone(); url.set_path("/api/v1/auth/refresh"); url.set_query(None); @@ -809,51 +835,44 @@ impl AuthCommand { }); }; - let mut url = self.effective_auth_url(); - url.set_path("/api/v1/web/auth/logout"); - url.set_query(None); - let response = reqwest::Client::new() - .post(url) - .bearer_auth(&auth.access_token) - .json(&json!({})) - .send() - .await; + let client = match self.authenticated_api_client(&auth.access_token) { + Ok(client) => client, + Err(error) => { + return json!({ + "attempted": true, + "success": false, + "mode": "remote", + "endpoint": "/api/v1/web/auth/logout", + "error": error, + }); + } + }; + let body = serde_json::Map::new(); + let response = client.post_web_auth_logout(&body).await; let response = match response { Ok(response) => response, Err(error) => { + let details = generated_error_details(&error); return json!({ "attempted": true, "success": false, "mode": "remote", "endpoint": "/api/v1/web/auth/logout", - "error": error.to_string(), + "http_status": details.status, + "request_id": details.request_id, + "error_code": details.code, + "error": details.message, }); } }; - let status = response.status(); - let request_id = request_id_from_headers(response.headers()); - if status.is_success() { - return json!({ - "attempted": true, - "success": true, - "mode": "remote", - "endpoint": "/api/v1/web/auth/logout", - "http_status": status.as_u16(), - "request_id": request_id, - }); - } - - let details = refresh_error_details(response).await; json!({ "attempted": true, - "success": false, + "success": true, "mode": "remote", "endpoint": "/api/v1/web/auth/logout", - "http_status": status.as_u16(), - "request_id": request_id, - "error_code": details.code, - "error": details.message, + "http_status": response.status().as_u16(), + "request_id": request_id_from_headers(response.headers()), }) } @@ -1176,6 +1195,8 @@ async fn refresh_error_details(response: reqwest::Response) -> RefreshErrorDetai Ok(bytes) => bytes, Err(error) => { return RefreshErrorDetails { + status: None, + request_id: None, code: None, message: Some(error.to_string()), }; @@ -1185,6 +1206,8 @@ async fn refresh_error_details(response: reqwest::Response) -> RefreshErrorDetai && let Ok(body) = serde_json::from_slice::(&bytes) { return RefreshErrorDetails { + status: None, + request_id: None, code: body .get("code") .and_then(Value::as_str) @@ -1197,11 +1220,31 @@ async fn refresh_error_details(response: reqwest::Response) -> RefreshErrorDetai }; } RefreshErrorDetails { + status: None, + request_id: None, code: None, message: String::from_utf8(bytes.to_vec()).ok(), } } +fn generated_error_details(error: &GeneratedError) -> RefreshErrorDetails +where + E: std::fmt::Debug, +{ + let status = error.status().map(|status| status.as_u16()); + let request_id = match &error { + GeneratedError::ErrorResponse(response) => request_id_from_headers(response.headers()), + GeneratedError::UnexpectedResponse(response) => request_id_from_headers(response.headers()), + _ => None, + }; + RefreshErrorDetails { + status, + request_id, + code: None, + message: Some(error.to_string()), + } +} + fn finish_timeout_if_needed(spinner: &ProgressBar, json_output: bool, error: &AuthError) { if !matches!(error, AuthError::Timeout(_)) { return; diff --git a/crates/pcl/core/src/download.rs b/crates/pcl/core/src/download.rs index 5b4c487..99795ba 100644 --- a/crates/pcl/core/src/download.rs +++ b/crates/pcl/core/src/download.rs @@ -9,7 +9,7 @@ use crate::{ DEFAULT_PLATFORM_URL, client::{ ClientBuildError, - authorization_header, + authenticated_client, ensure_fresh_auth, }, config::CliConfig, @@ -20,6 +20,11 @@ use crate::{ print_envelope, }, }; +use dapp_api_client::generated::client::{ + Client as GeneratedClient, + Error as GeneratedError, + types::GetViewsProjectsProjectIdAssertionsAssertionIdAssertionId, +}; use pcl_common::args::{ CliArgs, OutputMode, @@ -132,7 +137,7 @@ impl DownloadArgs { ensure_fresh_auth(config, &self.api_url, cli_args) .await .map_err(client_error_to_download)?; - let client = Self::build_client(config)?; + let client = self.build_client(config)?; let (project_id, project_name) = self.resolve_project(&client).await?; @@ -203,7 +208,7 @@ impl DownloadArgs { async fn download_assertions( &self, - client: &reqwest::Client, + client: &GeneratedClient, project_id: &Uuid, assertions: &[AssertionSummary], output_dir: &Path, @@ -319,31 +324,20 @@ impl DownloadArgs { Ok(()) } - fn build_client(config: &CliConfig) -> Result { - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert( - reqwest::header::HeaderName::from_static("api-version"), - reqwest::header::HeaderValue::from_static("1"), - ); - headers.insert( - reqwest::header::AUTHORIZATION, - authorization_header(config).map_err(client_error_to_download)?, - ); - - reqwest::Client::builder() - .default_headers(headers) - .build() - .map_err(|error| { - DownloadError::InvalidConfig(format!("Failed to build HTTP client: {error}")) - }) + fn build_client(&self, config: &CliConfig) -> Result { + authenticated_client(config, &self.api_url).map_err(client_error_to_download) } async fn resolve_project( &self, - client: &reqwest::Client, + client: &GeneratedClient, ) -> Result<(Uuid, String), DownloadError> { let pid = self.project_id.ok_or(DownloadError::MissingIdentifier)?; - let project = self.get_json(client, &format!("/projects/{pid}")).await?; + let project = match client.get_projects_project_id(&pid, None).await { + Ok(response) => response.into_inner(), + Err(error) => return Err(generated_api_error(format!("/projects/{pid}"), error).await), + }; + let project = serde_json::to_value(project)?; let project_id = project .get("project_id") .or_else(|| project.get("projectId")) @@ -364,12 +358,23 @@ impl DownloadArgs { async fn fetch_assertions_list( &self, - client: &reqwest::Client, + client: &GeneratedClient, project_id: &Uuid, ) -> Result, DownloadError> { - let response = self - .get_json(client, &format!("/views/projects/{project_id}/assertions")) - .await?; + let response = match client + .get_views_projects_project_id_assertions(project_id, None) + .await + { + Ok(response) => response.into_inner(), + Err(error) => { + return Err(generated_api_error( + format!("/views/projects/{project_id}/assertions"), + error, + ) + .await); + } + }; + let response = serde_json::to_value(response)?; let assertions = response .pointer("/data/assertions") .or_else(|| response.get("assertions")) @@ -409,65 +414,49 @@ impl DownloadArgs { async fn fetch_assertion_detail( &self, - client: &reqwest::Client, + client: &GeneratedClient, project_id: &Uuid, assertion_id: &str, ) -> Result { - let response = self - .get_json( - client, - &format!("/views/projects/{project_id}/assertions/{assertion_id}"), + let generated_assertion_id = assertion_id + .parse::() + .map_err(|error| { + DownloadError::InvalidConfig(format!("Invalid assertion ID: {error}")) + })?; + let response = match client + .get_views_projects_project_id_assertions_assertion_id( + project_id, + &generated_assertion_id, ) - .await?; - Ok(response.get("data").cloned().unwrap_or(response)) - } - - async fn get_json( - &self, - client: &reqwest::Client, - endpoint: &str, - ) -> Result { - let url = self.endpoint_url(endpoint); - let response = client.get(url).send().await.map_err(|error| { - DownloadError::Api { - endpoint: endpoint.to_string(), - status: error.status().map(|status| status.as_u16()), - request_id: None, - body: json!(error.to_string()), - } - })?; - let status = response.status(); - let request_id = crate::api::request_id_from_headers(response.headers()); - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let bytes = response.bytes().await.map_err(|error| { - DownloadError::Api { - endpoint: endpoint.to_string(), - status: Some(status.as_u16()), - request_id: request_id.clone(), - body: json!(error.to_string()), + .await + { + Ok(response) => response.into_inner(), + Err(error) => { + return Err(generated_api_error( + format!("/views/projects/{project_id}/assertions/{assertion_id}"), + error, + ) + .await); } - })?; - let body = crate::api::response_body_value(&content_type, &bytes); - if !status.is_success() { - return Err(DownloadError::Api { - endpoint: endpoint.to_string(), - status: Some(status.as_u16()), - request_id, - body, - }); - } - Ok(body) + }; + let response = serde_json::to_value(response)?; + Ok(response.get("data").cloned().unwrap_or(response)) } +} - fn endpoint_url(&self, endpoint: &str) -> url::Url { - let mut url = self.api_url.clone(); - url.set_path(&format!("/api/v1/{}", endpoint.trim_start_matches('/'))); - url +async fn generated_api_error( + endpoint: impl Into, + error: GeneratedError, +) -> DownloadError +where + E: Serialize + std::fmt::Debug, +{ + let details = crate::api::generated_error_details(error).await; + DownloadError::Api { + endpoint: endpoint.into(), + status: details.status, + request_id: details.request_id, + body: details.body, } } diff --git a/crates/pcl/core/src/output/human.rs b/crates/pcl/core/src/output/human.rs index d47d8c9..f2f20fc 100644 --- a/crates/pcl/core/src/output/human.rs +++ b/crates/pcl/core/src/output/human.rs @@ -24,8 +24,7 @@ pub fn human_string(value: &Value) -> String { if let Some(error) = value.get("error") { render_human_error(&mut output, error); - } else if !render_human_display(&mut output, &value) - && !render_human_special(&mut output, &value) + } else if !render_human_special(&mut output, &value) && !render_human_collection(&mut output, &value) && let Some(data) = value.get("data") { @@ -110,7 +109,6 @@ fn is_dangerous_or_internal_action(action: &str) -> bool { || trimmed.starts_with("pcl releases remove") || trimmed.starts_with("pcl access revoke") || trimmed.starts_with("pcl access member remove") - || trimmed.starts_with("pcl transfers reject") || action.starts_with("Read error.http.body") || action.starts_with("Use data.") } @@ -120,7 +118,6 @@ fn is_item_placeholder_action(action: &str) -> bool { "", "", "", - "", "", "", "", @@ -172,137 +169,6 @@ struct HumanCollection<'a> { meta: Option<&'a Value>, } -fn render_human_display(output: &mut String, envelope: &Value) -> bool { - let Some(data) = envelope.get("data") else { - return false; - }; - let Some(display) = data.get("_display").and_then(Value::as_object) else { - return false; - }; - match display.get("kind").and_then(Value::as_str) { - Some("collection") => render_display_collection(output, data, display), - Some("detail") => render_display_detail(output, data, display), - Some("mutation") => render_display_mutation(output, data, display), - _ => false, - } -} - -fn render_display_collection( - output: &mut String, - data: &Value, - display: &serde_json::Map, -) -> bool { - let Some(collection_name) = display.get("collection").and_then(Value::as_str) else { - return false; - }; - let Some(items) = data.get(collection_name).and_then(Value::as_array) else { - return false; - }; - if let Some(title) = display.get("title").and_then(Value::as_str) { - output.push('\n'); - output.push_str(title); - output.push('\n'); - } - if items.is_empty() { - let empty = display - .get("empty") - .and_then(Value::as_str) - .unwrap_or("No results found."); - output.push_str(empty); - output.push('\n'); - return true; - } - let columns = display - .get("columns") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(display_column) - .collect::>(); - if columns.is_empty() { - render_generic_table(output, items); - return true; - } - write!(output, "{:<3}", "#").expect("write to string"); - for (label, _) in &columns { - write!(output, " {label:<22}").expect("write to string"); - } - output.push('\n'); - for (index, item) in items.iter().enumerate() { - write!(output, "{:<3}", index + 1).expect("write to string"); - for (_, path) in &columns { - let value = value_at_path(item, path).map_or_else(String::new, human_cell); - write!(output, " {:<22}", pad(&value, 22)).expect("write to string"); - } - output.push('\n'); - } - true -} - -fn render_display_detail( - output: &mut String, - data: &Value, - display: &serde_json::Map, -) -> bool { - let fields = display - .get("fields") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(display_column) - .collect::>(); - if fields.is_empty() { - return false; - } - if let Some(title) = display.get("title").and_then(Value::as_str) { - output.push('\n'); - output.push_str(title); - output.push('\n'); - } - for (label, path) in fields { - if let Some(value) = value_at_path(data, &path) { - writeln!(output, "{}: {}", label, human_cell(value)).expect("write to string"); - } - } - true -} - -fn render_display_mutation( - output: &mut String, - data: &Value, - display: &serde_json::Map, -) -> bool { - let Some(title) = display.get("title").and_then(Value::as_str) else { - return false; - }; - output.push('\n'); - output.push_str(title); - if let Some(summary) = display - .get("summary_path") - .and_then(Value::as_str) - .and_then(|path| value_at_path(data, path)) - .map(human_cell) - .filter(|summary| !summary.is_empty()) - { - output.push_str(": "); - output.push_str(&summary); - } - output.push('\n'); - true -} - -fn display_column(value: &Value) -> Option<(String, String)> { - Some(( - value.get("label")?.as_str()?.to_string(), - value.get("path")?.as_str()?.to_string(), - )) -} - -fn value_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> { - path.split('.') - .try_fold(value, |current, segment| current.get(segment)) -} - fn render_human_error(output: &mut String, error: &Value) { output.push('\n'); let code = error.get("code").and_then(Value::as_str); @@ -401,7 +267,6 @@ fn render_human_special(output: &mut String, envelope: &Value) -> bool { render_search_results, render_account_detail, render_deployment_state, - render_transfer_state, render_integration_status, render_protocol_manager_status, ] { @@ -860,7 +725,7 @@ fn render_search_results(output: &mut String, data: &Value) -> bool { } if !contracts.is_empty() { output.push_str("\nContracts\n"); - render_search_contracts_table(output, contracts); + render_generic_table(output, contracts); } if !assertions.is_empty() { output.push_str("\nAssertions\n"); @@ -869,38 +734,6 @@ fn render_search_results(output: &mut String, data: &Value) -> bool { true } -fn render_search_contracts_table(output: &mut String, items: &[Value]) { - writeln!( - output, - "{:<32} {:<10} {:<22} Project", - "Contract", "Network", "Address" - ) - .expect("write to string"); - for item in items { - let data = item.get("data").unwrap_or(item); - let name = data - .get("contract_name") - .and_then(Value::as_str) - .unwrap_or("-"); - let network = data.get("network").and_then(Value::as_str).unwrap_or("-"); - let address = data.get("address").and_then(Value::as_str).unwrap_or("-"); - let project = data - .get("related_project_slug") - .or_else(|| data.get("related_project_id")) - .and_then(Value::as_str) - .unwrap_or("-"); - writeln!( - output, - "{:<32} {:<10} {:<22} {}", - pad(name, 32), - pad(network, 10), - pad(address, 22), - project - ) - .expect("write to string"); - } -} - fn render_account_detail(output: &mut String, data: &Value) -> bool { if data.get("email").is_none() || data.get("authMethod").is_none() { return false; @@ -943,16 +776,6 @@ fn render_deployment_state(output: &mut String, data: &Value) -> bool { true } -fn render_transfer_state(output: &mut String, data: &Value) -> bool { - let (Some(incoming), Some(outgoing)) = (data.get("incoming"), data.get("outgoing")) else { - return false; - }; - output.push_str("\nProtocol manager transfers\n"); - write_transfer_counts(output, "Incoming", incoming); - write_transfer_counts(output, "Outgoing", outgoing); - true -} - fn render_integration_status(output: &mut String, data: &Value) -> bool { if data.get("configured").is_none() || data.get("enabled").is_none() { return false; @@ -1487,24 +1310,6 @@ fn write_network_list_for_value(output: &mut String, data: &Value) { writeln!(output, "Networks: {}", names.join(", ")).expect("write to string"); } -fn write_transfer_counts(output: &mut String, label: &str, value: &Value) { - let projects = value - .get("project_transfers") - .and_then(Value::as_array) - .map_or(0, Vec::len); - let contracts = value - .get("contract_transfers") - .and_then(Value::as_array) - .map_or(0, Vec::len); - writeln!( - output, - "{label}: {}, {}", - plural_count(projects, "project transfer"), - plural_count(contracts, "contract transfer") - ) - .expect("write to string"); -} - fn render_human_collection(output: &mut String, envelope: &Value) -> bool { let Some(collection) = find_human_collection(envelope) else { return false; @@ -1615,7 +1420,6 @@ fn find_collection_in_value<'a>( "members", "invitations", "integrations", - "transfers", "requests", "no_hit", "no_2xx", @@ -1653,7 +1457,6 @@ fn infer_collection_field(request_path: &str) -> String { "events", "members", "invitations", - "transfers", ] { if request_path.contains(field) { return field.to_string(); @@ -2161,7 +1964,7 @@ fn render_generic_table(output: &mut String, items: &[Value]) { } output.push('\n'); - for (index, item) in items.iter().enumerate() { + for (index, item) in items.iter().map(table_item).enumerate() { write!(output, "{:<3}", index + 1).expect("write to string"); for column in &columns { let value = item.get(column).map_or_else(String::new, human_cell); @@ -2175,8 +1978,12 @@ fn generic_columns(items: &[Value]) -> Vec { let mut columns = Vec::new(); for preferred in [ "name", + "project_name", + "contract_name", "title", "id", + "project_id", + "address", "status", "environment", "network", @@ -2184,7 +1991,11 @@ fn generic_columns(items: &[Value]) -> Vec { "createdAt", "updatedAt", ] { - if items.iter().any(|item| item.get(preferred).is_some()) { + if items + .iter() + .map(table_item) + .any(|item| item.get(preferred).is_some()) + { columns.push(preferred.to_string()); } if columns.len() == 4 { @@ -2193,13 +2004,17 @@ fn generic_columns(items: &[Value]) -> Vec { } if columns.is_empty() - && let Some(object) = items.first().and_then(Value::as_object) + && let Some(object) = items.first().map(table_item).and_then(Value::as_object) { columns.extend(object.keys().take(4).cloned()); } columns } +fn table_item(item: &Value) -> &Value { + item.get("data").unwrap_or(item) +} + fn human_cell(value: &Value) -> String { match value { Value::Object(object) if object.contains_key("name") => { diff --git a/crates/pcl/core/src/surface.rs b/crates/pcl/core/src/surface.rs index ba00f93..f06a0a2 100644 --- a/crates/pcl/core/src/surface.rs +++ b/crates/pcl/core/src/surface.rs @@ -24,6 +24,10 @@ use crate::{ request_log, }; use chrono::Utc; +use dapp_api_client::generated::client::{ + Client as GeneratedClient, + Error as GeneratedError, +}; use pcl_common::args::{ CliArgs, OutputMode, @@ -825,12 +829,16 @@ async fn export_incidents( "--limit must be greater than zero".to_string(), )); } + if args.page == 0 { + return Err(ProductSurfaceError::InvalidInput( + "--page must be greater than zero".to_string(), + )); + } if args.max_pages == 0 { return Err(ProductSurfaceError::InvalidInput( "--max-pages must be greater than zero".to_string(), )); } - let out = args .out .clone() @@ -885,6 +893,11 @@ async fn export_incidents( } else { args.page }; + if start_page == 0 { + return Err(ProductSurfaceError::InvalidInput( + "Checkpoint page must be greater than zero".to_string(), + )); + } let mut out_file = BufWriter::new( fs::OpenOptions::new() .create(true) @@ -1625,19 +1638,23 @@ fn auth_value(auth: Option<&UserAuth>) -> Value { }) } +fn generated_client(api_url: &url::Url) -> GeneratedClient { + let mut base = api_url.clone(); + base.set_path("/api/v1"); + GeneratedClient::new(base.as_str()) +} + +fn generated_error_request_id(error: &GeneratedError) -> Option { + match error { + GeneratedError::ErrorResponse(response) => request_id_from_headers(response.headers()), + GeneratedError::UnexpectedResponse(response) => request_id_from_headers(response.headers()), + _ => None, + } +} + async fn health_check(api_url: &url::Url) -> Value { - let url = match build_api_url(api_url, "/health") { - Ok(url) => url, - Err(error) => { - return json!({ - "name": "api_health", - "status": "error", - "error": error.to_string(), - }); - } - }; - let response = reqwest::Client::new().get(url).send().await; - match response { + let client = generated_client(api_url); + match client.get_health().await { Ok(response) => { let status = response.status(); json!({ @@ -1648,9 +1665,12 @@ async fn health_check(api_url: &url::Url) -> Value { }) } Err(error) => { + let status = error.status().map(|status| status.as_u16()); json!({ "name": "api_health", "status": "error", + "http_status": status, + "request_id": generated_error_request_id(&error), "error": error.to_string(), }) } @@ -1658,20 +1678,21 @@ async fn health_check(api_url: &url::Url) -> Value { } async fn auth_capability_check(api_url: &url::Url) -> Value { - let url = match build_api_url(api_url, "/openapi") { - Ok(url) => url, - Err(error) => { - return json!({ - "name": "auth_capabilities", - "status": "error", - "error": error.to_string(), - }); - } - }; - let response = reqwest::Client::new().get(url).send().await; - let response = match response { + let client = generated_client(api_url); + let response = match client.get_openapi().await { Ok(response) => response, Err(error) => { + let http_status = error.status(); + let request_id = generated_error_request_id(&error); + if let Some(http_status) = http_status { + return json!({ + "name": "auth_capabilities", + "status": "warning", + "http_status": http_status.as_u16(), + "request_id": request_id, + "error": "OpenAPI manifest could not be fetched, so CLI auth support could not be verified.", + }); + } return json!({ "name": "auth_capabilities", "status": "error", @@ -1679,28 +1700,8 @@ async fn auth_capability_check(api_url: &url::Url) -> Value { }); } }; - let http_status = response.status(); let request_id = request_id_from_headers(response.headers()); - if !http_status.is_success() { - return json!({ - "name": "auth_capabilities", - "status": "warning", - "http_status": http_status.as_u16(), - "request_id": request_id, - "error": "OpenAPI manifest could not be fetched, so CLI auth support could not be verified.", - }); - } - let spec = match response.json::().await { - Ok(spec) => spec, - Err(error) => { - return json!({ - "name": "auth_capabilities", - "status": "warning", - "request_id": request_id, - "error": error.to_string(), - }); - } - }; + let spec = response.into_inner(); let refresh_supported = openapi_has_operation(&spec, "/auth/refresh", "post"); let login_supported = openapi_has_operation(&spec, "/cli/auth/code", "get") && openapi_has_operation(&spec, "/cli/auth/status", "get") @@ -1876,6 +1877,9 @@ async fn fetch_export_page( query: &[(String, String)], max_retries: u64, ) -> Result { + // Export retries need raw status and body metadata from non-2xx pages. The + // generated incident list methods currently erase those when the API returns + // the shared error schema. let max_attempts = max_retries.saturating_add(1); for attempt in 1..=max_attempts { let result = client.get(url.clone()).query(query).send().await;