ironflow_sdk/client.rs
1//! Ergonomic HTTP client for the Ironflow API.
2//!
3//! Wraps [`reqwest::Client`] with Bearer auth and automatic
4//! deserialization of the `{ data, meta }` response envelope.
5
6use std::time::Duration;
7
8use chrono::{DateTime, Utc};
9pub use ironflow_types::{ApiMeta, ApiResponse};
10use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
11use reqwest::{Client, RequestBuilder, Response};
12use serde::Serialize;
13use serde_json::{from_str, json};
14use uuid::Uuid;
15
16pub use crate::builder::{ArtifactDownload, ClientBuilder};
17use crate::error::{ApiErrorEnvelope, Error};
18use crate::rate_limit::RateLimiter;
19use crate::retry::RetryConfig;
20use crate::types;
21
22/// Configuration for the Ironflow SDK client.
23///
24/// # Examples
25///
26/// ```
27/// use ironflow_sdk::client::ClientConfig;
28/// use std::time::Duration;
29///
30/// let config = ClientConfig {
31/// base_url: "https://ironflow.example.com".to_string(),
32/// api_key: "my-api-key".to_string(),
33/// timeout: Duration::from_secs(60),
34/// };
35/// ```
36#[derive(Debug, Clone)]
37pub struct ClientConfig {
38 /// Base URL of the Ironflow API (e.g. `https://ironflow.example.com`).
39 pub base_url: String,
40 /// API key for Bearer authentication.
41 pub api_key: String,
42 /// HTTP request timeout.
43 pub timeout: Duration,
44}
45
46/// Filtering options for [`IronflowClient::list_runs_filtered`].
47///
48/// # Examples
49///
50/// ```
51/// use ironflow_sdk::client::ListRunsFilter;
52///
53/// let filter = ListRunsFilter {
54/// workflow: Some("deploy"),
55/// status: Some("completed"),
56/// page: Some(1),
57/// ..Default::default()
58/// };
59/// ```
60#[derive(Debug, Clone, Default, serde::Serialize)]
61pub struct ListRunsFilter<'a> {
62 /// Filter by workflow name.
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub workflow: Option<&'a str>,
65 /// Filter by run status.
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub status: Option<&'a str>,
68 /// Page number (1-based).
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub page: Option<u32>,
71 /// Items per page.
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub per_page: Option<u32>,
74 /// Filter runs that have/don't have steps.
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub has_steps: Option<bool>,
77 /// Filter by label.
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub label: Option<&'a str>,
80 /// Filter by author: the user ID that triggered the run.
81 ///
82 /// Also matches runs triggered by one of that user's API keys.
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub created_by: Option<Uuid>,
85 /// Filter by concurrency group: only runs that belong to this group.
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub concurrency_group: Option<&'a str>,
88 /// Filter by priority: only runs with exactly this priority.
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub priority: Option<i16>,
91}
92
93/// Query options for [`IronflowClient::stats_history_with`].
94///
95/// The filters have the same semantics as [`ListRunsFilter`]: `workflow` is a
96/// case-insensitive substring match.
97///
98/// # Examples
99///
100/// ```
101/// use ironflow_sdk::client::StatsHistoryParams;
102///
103/// let params = StatsHistoryParams {
104/// period: Some("24h"),
105/// status: Some("failed"),
106/// label: Some("env:prod"),
107/// ..Default::default()
108/// };
109/// assert_eq!(params.period, Some("24h"));
110/// ```
111#[derive(Debug, Clone, Default, serde::Serialize)]
112pub struct StatsHistoryParams<'a> {
113 /// Filter by workflow name (case-insensitive substring match).
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub workflow: Option<&'a str>,
116 /// Time period to query: `24h`, `7d`, `30d` or `90d`.
117 #[serde(skip_serializing_if = "Option::is_none")]
118 pub period: Option<&'a str>,
119 /// Bucket granularity: `1h`, `1d` or `1w`. Derived from the period when omitted.
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub granularity: Option<&'a str>,
122 /// Filter by run status.
123 #[serde(skip_serializing_if = "Option::is_none")]
124 pub status: Option<&'a str>,
125 /// Filter by labels. Comma-separated `key:value` pairs.
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub label: Option<&'a str>,
128 /// Filter runs that have/don't have steps.
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub has_steps: Option<bool>,
131 /// Filter by author: the user ID that triggered the run.
132 ///
133 /// Also matches runs triggered by one of that user's API keys.
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub created_by: Option<Uuid>,
136 /// Filter by concurrency group: only runs that belong to this group.
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub concurrency_group: Option<&'a str>,
139}
140
141/// Query parameters for [`IronflowClient::list_steps`].
142///
143/// # Examples
144///
145/// ```
146/// use ironflow_sdk::client::ListStepsParams;
147///
148/// let params = ListStepsParams {
149/// agent_role: "Reviewer",
150/// page: Some(2),
151/// per_page: None,
152/// };
153/// assert_eq!(params.agent_role, "Reviewer");
154/// ```
155#[derive(Debug, Clone, Serialize)]
156pub struct ListStepsParams<'a> {
157 /// Name of the `AgentRole` whose agent steps to list. Matched exactly.
158 pub agent_role: &'a str,
159 /// Page number (1-based).
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub page: Option<u32>,
162 /// Items per page (max 100).
163 #[serde(skip_serializing_if = "Option::is_none")]
164 pub per_page: Option<u32>,
165}
166
167/// Filtering options for [`IronflowClient::get_run_logs`].
168///
169/// # Examples
170///
171/// ```
172/// use ironflow_sdk::client::ListRunLogsFilter;
173/// use uuid::Uuid;
174///
175/// let filter = ListRunLogsFilter {
176/// step_id: Some(Uuid::nil()),
177/// stream: Some("stderr"),
178/// ..Default::default()
179/// };
180/// ```
181#[derive(Debug, Clone, Default, serde::Serialize)]
182pub struct ListRunLogsFilter<'a> {
183 /// Filter by step ID.
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pub step_id: Option<Uuid>,
186 /// Filter by output stream (`stdout`, `stderr`, `system`).
187 #[serde(skip_serializing_if = "Option::is_none")]
188 pub stream: Option<&'a str>,
189 /// Cursor for pagination (last entry ID from previous page).
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub cursor: Option<Uuid>,
192 /// Number of entries to return (default: 100, max: 1000).
193 #[serde(skip_serializing_if = "Option::is_none")]
194 pub limit: Option<u32>,
195}
196
197/// Filtering options for [`IronflowClient::list_audit_logs_filtered`].
198///
199/// # Examples
200///
201/// ```
202/// use ironflow_sdk::client::ListAuditLogsFilter;
203///
204/// let filter = ListAuditLogsFilter {
205/// event_type: Some("run_created"),
206/// per_page: Some(25),
207/// ..Default::default()
208/// };
209/// ```
210#[derive(Debug, Clone, Default, serde::Serialize)]
211pub struct ListAuditLogsFilter<'a> {
212 /// Filter by event type (e.g. `run_status_changed`).
213 #[serde(skip_serializing_if = "Option::is_none")]
214 pub event_type: Option<&'a str>,
215 /// Filter by run ID.
216 #[serde(skip_serializing_if = "Option::is_none")]
217 pub run_id: Option<Uuid>,
218 /// Keep entries recorded at or after this instant.
219 #[serde(skip_serializing_if = "Option::is_none")]
220 pub from: Option<DateTime<Utc>>,
221 /// Keep entries recorded at or before this instant.
222 #[serde(skip_serializing_if = "Option::is_none")]
223 pub to: Option<DateTime<Utc>>,
224 /// Page number (1-based).
225 #[serde(skip_serializing_if = "Option::is_none")]
226 pub page: Option<u32>,
227 /// Items per page.
228 #[serde(skip_serializing_if = "Option::is_none")]
229 pub per_page: Option<u32>,
230}
231
232/// Filtering options for [`IronflowClient::list_approval_delegations_filtered`].
233///
234/// The user filters are honoured for an admin only; a non-admin always gets the
235/// delegations they granted or received.
236///
237/// # Examples
238///
239/// ```
240/// use ironflow_sdk::client::ListApprovalDelegationsFilter;
241///
242/// let filter = ListApprovalDelegationsFilter {
243/// page: Some(2),
244/// per_page: Some(50),
245/// ..Default::default()
246/// };
247/// ```
248#[derive(Debug, Clone, Default, serde::Serialize)]
249pub struct ListApprovalDelegationsFilter {
250 /// Only delegations granted by this user.
251 #[serde(skip_serializing_if = "Option::is_none")]
252 pub from_user_id: Option<Uuid>,
253 /// Only delegations received by this user.
254 #[serde(skip_serializing_if = "Option::is_none")]
255 pub to_user_id: Option<Uuid>,
256 /// Page number (1-based).
257 #[serde(skip_serializing_if = "Option::is_none")]
258 pub page: Option<u32>,
259 /// Items per page (max 100).
260 #[serde(skip_serializing_if = "Option::is_none")]
261 pub per_page: Option<u32>,
262}
263
264/// Filtering options for [`IronflowClient::list_signals`].
265///
266/// # Examples
267///
268/// ```
269/// use ironflow_sdk::client::ListSignalsFilter;
270///
271/// let filter = ListSignalsFilter {
272/// name: Some("ci.pipeline_finished".to_string()),
273/// key: Some("4f2a9c1".to_string()),
274/// ..Default::default()
275/// };
276/// assert!(filter.page.is_none());
277/// ```
278#[derive(Debug, Clone, Default, serde::Serialize)]
279pub struct ListSignalsFilter {
280 /// Only signals with this exact name.
281 #[serde(skip_serializing_if = "Option::is_none")]
282 pub name: Option<String>,
283 /// Only signals with this exact key.
284 #[serde(skip_serializing_if = "Option::is_none")]
285 pub key: Option<String>,
286 /// Page number (1-based).
287 #[serde(skip_serializing_if = "Option::is_none")]
288 pub page: Option<u32>,
289 /// Items per page (max 100).
290 #[serde(skip_serializing_if = "Option::is_none")]
291 pub per_page: Option<u32>,
292}
293
294/// Type-safe client for the Ironflow REST API.
295///
296/// Handles Bearer authentication, the `{ data, meta }` response envelope,
297/// and provides methods for all public API endpoints.
298///
299/// # Examples
300///
301/// ```no_run
302/// use ironflow_sdk::IronflowClient;
303///
304/// # async fn example() -> Result<(), ironflow_sdk::Error> {
305/// let client = IronflowClient::new("https://ironflow.example.com", "my-api-key");
306///
307/// // List runs
308/// let runs = client.list_runs().await?;
309/// for run in &runs.data {
310/// # let _ = run;
311/// }
312///
313/// // Get a specific workflow
314/// let wf = client.get_workflow("deploy").await?;
315/// # Ok(())
316/// # }
317/// ```
318#[derive(Debug, Clone)]
319pub struct IronflowClient {
320 pub(crate) http: Client,
321 base_url: String,
322 pub(crate) retry_config: RetryConfig,
323 pub(crate) rate_limiter: RateLimiter,
324}
325
326impl IronflowClient {
327 /// Create a new client with the given base URL and API key.
328 ///
329 /// Uses a default timeout of 30 seconds.
330 ///
331 /// # Panics
332 ///
333 /// Panics if `api_key` contains characters invalid for HTTP headers
334 /// (e.g. newlines, NUL bytes, or non-visible ASCII).
335 ///
336 /// # Examples
337 ///
338 /// ```
339 /// use ironflow_sdk::IronflowClient;
340 ///
341 /// let client = IronflowClient::new("https://ironflow.example.com", "my-api-key");
342 /// ```
343 pub fn new(base_url: &str, api_key: &str) -> Self {
344 let config = ClientConfig {
345 base_url: base_url.trim_end_matches('/').to_string(),
346 api_key: api_key.to_string(),
347 timeout: Duration::from_secs(30),
348 };
349 Self::from_config(config)
350 }
351
352 /// Create a new client from a [`ClientConfig`].
353 ///
354 /// # Panics
355 ///
356 /// Panics if `config.api_key` contains characters invalid for HTTP headers
357 /// (e.g. newlines, NUL bytes, or non-visible ASCII).
358 ///
359 /// # Examples
360 ///
361 /// ```
362 /// use ironflow_sdk::IronflowClient;
363 /// use ironflow_sdk::client::ClientConfig;
364 /// use std::time::Duration;
365 ///
366 /// let config = ClientConfig {
367 /// base_url: "https://ironflow.example.com".to_string(),
368 /// api_key: "my-api-key".to_string(),
369 /// timeout: Duration::from_secs(60),
370 /// };
371 /// let client = IronflowClient::from_config(config);
372 /// ```
373 pub fn from_config(config: ClientConfig) -> Self {
374 Self::from_config_with_retry(config, RetryConfig::default(), RateLimiter::new())
375 }
376
377 /// Create a new client with custom retry and rate-limit settings.
378 ///
379 /// Prefer [`ClientBuilder`] for a more ergonomic interface.
380 ///
381 /// # Panics
382 ///
383 /// Panics if `config.api_key` contains characters invalid for HTTP headers.
384 pub fn from_config_with_retry(
385 config: ClientConfig,
386 retry_config: RetryConfig,
387 rate_limiter: RateLimiter,
388 ) -> Self {
389 let mut headers = HeaderMap::new();
390 let auth_value = format!("Bearer {}", config.api_key);
391 headers.insert(
392 AUTHORIZATION,
393 HeaderValue::from_str(&auth_value).expect("invalid API key characters"),
394 );
395
396 let http = Client::builder()
397 .timeout(config.timeout)
398 .default_headers(headers)
399 .build()
400 .expect("failed to build HTTP client");
401
402 Self {
403 http,
404 base_url: config.base_url,
405 retry_config,
406 rate_limiter,
407 }
408 }
409
410 /// Return the base URL.
411 pub fn base_url(&self) -> &str {
412 &self.base_url
413 }
414
415 pub(crate) fn url(&self, path: &str) -> String {
416 format!("{}{path}", self.base_url)
417 }
418
419 pub(crate) fn get(&self, path: &str) -> RequestBuilder {
420 self.http.get(self.url(path))
421 }
422
423 pub(crate) fn post(&self, path: &str) -> RequestBuilder {
424 self.http.post(self.url(path))
425 }
426
427 pub(crate) fn put(&self, path: &str) -> RequestBuilder {
428 self.http.put(self.url(path))
429 }
430
431 pub(crate) fn delete(&self, path: &str) -> RequestBuilder {
432 self.http.delete(self.url(path))
433 }
434
435 pub(crate) fn patch(&self, path: &str) -> RequestBuilder {
436 self.http.patch(self.url(path))
437 }
438
439 /// Parse an error response into an [`Error`].
440 pub(crate) async fn into_api_error(response: Response) -> Error {
441 let status = response.status();
442 let status_code = status.as_u16();
443 let text = response.text().await.unwrap_or_default();
444 from_str::<ApiErrorEnvelope>(&text)
445 .map(|env| Error::api(status_code, env.error.code, env.error.message))
446 .unwrap_or_else(|_| {
447 Error::api(
448 status_code,
449 status.canonical_reason().unwrap_or("UNKNOWN"),
450 text,
451 )
452 })
453 }
454
455 // ── Health ──────────────────────────────────────────────────────
456
457 /// `GET /api/v1/health-check`
458 ///
459 /// # Errors
460 ///
461 /// Returns [`Error::Http`] on network failure.
462 pub async fn health_check(&self) -> Result<(), Error> {
463 self.send_no_content(self.get("/api/v1/health-check")).await
464 }
465
466 // ── Runs ───────────────────────────────────────────────────────
467
468 /// `GET /api/v1/runs` -- List runs with optional filtering and pagination.
469 ///
470 /// # Errors
471 ///
472 /// Returns [`Error::Api`] on 401 Unauthorized.
473 pub async fn list_runs(&self) -> Result<ApiResponse<Vec<types::RunResponse>>, Error> {
474 self.list_runs_filtered(&ListRunsFilter::default()).await
475 }
476
477 /// `GET /api/v1/runs` with query parameters.
478 ///
479 /// # Errors
480 ///
481 /// Returns [`Error::Api`] on 401 Unauthorized.
482 ///
483 /// # Examples
484 ///
485 /// ```no_run
486 /// use ironflow_sdk::IronflowClient;
487 /// use ironflow_sdk::client::ListRunsFilter;
488 ///
489 /// # async fn example() -> Result<(), ironflow_sdk::Error> {
490 /// let client = IronflowClient::new("https://ironflow.example.com", "key");
491 /// let filter = ListRunsFilter {
492 /// workflow: Some("deploy"),
493 /// status: Some("completed"),
494 /// ..Default::default()
495 /// };
496 /// let runs = client.list_runs_filtered(&filter).await?;
497 /// # Ok(())
498 /// # }
499 /// ```
500 pub async fn list_runs_filtered(
501 &self,
502 filter: &ListRunsFilter<'_>,
503 ) -> Result<ApiResponse<Vec<types::RunResponse>>, Error> {
504 self.send_envelope(self.get("/api/v1/runs").query(filter))
505 .await
506 }
507
508 /// `POST /api/v1/runs` -- Trigger a workflow.
509 ///
510 /// # Errors
511 ///
512 /// Returns [`Error::Api`] on 400 (unknown workflow) or 401/403.
513 pub async fn create_run(
514 &self,
515 request: &types::CreateRunRequest,
516 ) -> Result<ApiResponse<types::RunResponse>, Error> {
517 self.send_envelope_once(self.post("/api/v1/runs").json(request))
518 .await
519 }
520
521 /// `POST /api/v1/runs` with an `Idempotency-Key` header -- trigger a workflow
522 /// exactly once.
523 ///
524 /// Replaying the same key returns the run it already produced instead of
525 /// creating a second one. The key stays bound for 24 hours. Use a value the
526 /// caller can reproduce across retries, such as a webhook delivery id.
527 ///
528 /// The returned envelope is identical whether the run was created or replayed;
529 /// compare `data.idempotency_key` or the run id if the distinction matters.
530 ///
531 /// # Errors
532 ///
533 /// Returns [`Error::Api`] on 400 (unknown workflow or malformed key), 401/403,
534 /// or 409 (`IDEMPOTENCY_KEY_CONFLICT`) when the key is already bound to a
535 /// different workflow or payload.
536 ///
537 /// # Examples
538 ///
539 /// ```no_run
540 /// use ironflow_sdk::{IronflowClient, types};
541 ///
542 /// # async fn example(request: &types::CreateRunRequest) -> Result<(), ironflow_sdk::Error> {
543 /// let client = IronflowClient::new("https://ironflow.example.com", "key");
544 ///
545 /// // Derive the key from something the caller can reproduce on retry,
546 /// // such as the provider delivery id of the webhook that triggered this.
547 /// let run = client
548 /// .create_run_idempotent(request, "github:8f4e2a10")
549 /// .await?;
550 /// # Ok(())
551 /// # }
552 /// ```
553 pub async fn create_run_idempotent(
554 &self,
555 request: &types::CreateRunRequest,
556 idempotency_key: &str,
557 ) -> Result<ApiResponse<types::RunResponse>, Error> {
558 self.send_envelope(
559 self.post("/api/v1/runs")
560 .header("Idempotency-Key", idempotency_key)
561 .json(request),
562 )
563 .await
564 }
565
566 /// `GET /api/v1/runs/:id` -- Get run details.
567 ///
568 /// # Errors
569 ///
570 /// Returns [`Error::Api`] on 404 (run not found) or 401.
571 pub async fn get_run(&self, id: Uuid) -> Result<ApiResponse<types::RunDetailResponse>, Error> {
572 self.send_envelope(self.get(&format!("/api/v1/runs/{id}")))
573 .await
574 }
575
576 /// Execute a lifecycle action on a run.
577 async fn run_action(
578 &self,
579 id: Uuid,
580 action: &str,
581 ) -> Result<ApiResponse<types::RunResponse>, Error> {
582 self.send_envelope(self.post(&format!("/api/v1/runs/{id}/{action}")))
583 .await
584 }
585
586 /// `POST /api/v1/runs/:id/cancel` -- Cancel a run and the sub-workflow
587 /// runs below it.
588 ///
589 /// The response carries the cancelled run's fields plus
590 /// `cancelled_descendants`, the ids of the sub-runs cancelled with it.
591 ///
592 /// # Errors
593 ///
594 /// Returns [`Error::Api`] on 404 or 400 (invalid state transition).
595 pub async fn cancel_run(
596 &self,
597 id: Uuid,
598 ) -> Result<ApiResponse<types::CancelRunResponse>, Error> {
599 self.send_envelope(self.post(&format!("/api/v1/runs/{id}/cancel")))
600 .await
601 }
602
603 /// `POST /api/v1/runs/:id/pause` -- Pause a run and the sub-workflow runs
604 /// below it.
605 ///
606 /// A running run has its step in flight interrupted, executed again on
607 /// resume. The response carries
608 /// the paused run's fields, with `resume_status` (the state it returns
609 /// to), plus `paused_descendants`, the ids of the sub-runs paused with it.
610 ///
611 /// # Errors
612 ///
613 /// Returns [`Error::Api`] on 404, 403 (not an admin) or 400 (the run
614 /// already finished, is already paused, or is a sub-workflow run).
615 pub async fn pause_run(&self, id: Uuid) -> Result<ApiResponse<types::PauseRunResponse>, Error> {
616 self.send_envelope(self.post(&format!("/api/v1/runs/{id}/pause")))
617 .await
618 }
619
620 /// `POST /api/v1/runs/:id/resume` -- Resume a paused run and the
621 /// sub-workflow runs paused below it.
622 ///
623 /// The response carries the resumed run's fields plus
624 /// `resumed_descendants`, the ids of the sub-runs resumed with it.
625 ///
626 /// # Errors
627 ///
628 /// Returns [`Error::Api`] on 404, 403 (not an admin) or 400 (the run is
629 /// not paused, or is a sub-workflow run).
630 pub async fn resume_run(
631 &self,
632 id: Uuid,
633 ) -> Result<ApiResponse<types::ResumeRunResponse>, Error> {
634 self.send_envelope(self.post(&format!("/api/v1/runs/{id}/resume")))
635 .await
636 }
637
638 /// `POST /api/v1/runs/:id/approve` -- Approve a run waiting for approval.
639 ///
640 /// # Errors
641 ///
642 /// Returns [`Error::Api`] on 404 or 400.
643 pub async fn approve_run(&self, id: Uuid) -> Result<ApiResponse<types::RunResponse>, Error> {
644 self.run_action(id, "approve").await
645 }
646
647 /// `POST /api/v1/runs/:id/reject` -- Reject a run waiting for approval.
648 ///
649 /// # Errors
650 ///
651 /// Returns [`Error::Api`] on 404 or 400.
652 pub async fn reject_run(&self, id: Uuid) -> Result<ApiResponse<types::RunResponse>, Error> {
653 self.run_action(id, "reject").await
654 }
655
656 /// `POST /api/v1/runs/:id/steps/:step_id/input` -- Answer a human input step.
657 ///
658 /// `value` is serialized as the JSON body and validated by the server
659 /// against the schema stored on the step. On success the run resumes.
660 ///
661 /// # Errors
662 ///
663 /// Returns [`Error::Api`] on 400 (the step does not wait for input), 403
664 /// (the caller may not answer), 404 (unknown run or step), 409 (already
665 /// answered or rejected) or 422 (the value does not match the schema).
666 pub async fn submit_input<T: Serialize + ?Sized>(
667 &self,
668 run_id: Uuid,
669 step_id: Uuid,
670 value: &T,
671 ) -> Result<ApiResponse<types::RunResponse>, Error> {
672 self.send_envelope(
673 self.post(&format!("/api/v1/runs/{run_id}/steps/{step_id}/input"))
674 .json(value),
675 )
676 .await
677 }
678
679 /// `POST /api/v1/runs/:id/steps/:step_id/reject` -- Reject a human input step.
680 ///
681 /// The run resumes and the handler receives the rejection with `reason`
682 /// (or a default reason naming the caller).
683 ///
684 /// # Errors
685 ///
686 /// Returns [`Error::Api`] on 400 (the step does not wait for input), 403
687 /// (the caller may not reject it), 404 (unknown run or step) or 409
688 /// (already answered or rejected).
689 pub async fn reject_input(
690 &self,
691 run_id: Uuid,
692 step_id: Uuid,
693 reason: Option<&str>,
694 ) -> Result<ApiResponse<types::RunResponse>, Error> {
695 self.send_envelope(
696 self.post(&format!("/api/v1/runs/{run_id}/steps/{step_id}/reject"))
697 .json(&json!({ "reason": reason })),
698 )
699 .await
700 }
701
702 /// `POST /api/v1/runs/:id/retry` -- Retry a failed run (creates a new run).
703 ///
704 /// When `force` is `true`, the query parameter `?force=true` is appended
705 /// to override a handler version mismatch.
706 ///
707 /// # Errors
708 ///
709 /// Returns [`Error::Api`] on 404, 400, or 409 (version mismatch).
710 pub async fn retry_run(
711 &self,
712 id: Uuid,
713 force: bool,
714 ) -> Result<ApiResponse<types::RunResponse>, Error> {
715 let action = if force { "retry?force=true" } else { "retry" };
716 self.run_action(id, action).await
717 }
718
719 /// `POST /api/v1/runs/:id/replay` -- Replay a finished run on the current
720 /// handler version (creates a new run).
721 ///
722 /// Unlike [`retry_run`](Self::retry_run), the new run always targets the
723 /// handler's current version; there is no compatibility check to force.
724 ///
725 /// # Errors
726 ///
727 /// Returns [`Error::Api`] on 404 (run not found or workflow no longer
728 /// registered) or 400 (run still in flight).
729 pub async fn replay_run(&self, id: Uuid) -> Result<ApiResponse<types::RunResponse>, Error> {
730 self.run_action(id, "replay").await
731 }
732
733 // ── Workflows ──────────────────────────────────────────────────
734
735 /// `GET /api/v1/workflows` -- List registered workflows.
736 ///
737 /// # Errors
738 ///
739 /// Returns [`Error::Api`] on 401.
740 pub async fn list_workflows(&self) -> Result<ApiResponse<Vec<types::WorkflowSummary>>, Error> {
741 self.send_envelope(self.get("/api/v1/workflows")).await
742 }
743
744 /// `GET /api/v1/workflows/:name` -- Get workflow details.
745 ///
746 /// # Errors
747 ///
748 /// Returns [`Error::Api`] on 404 or 401.
749 pub async fn get_workflow(
750 &self,
751 name: &str,
752 ) -> Result<ApiResponse<types::WorkflowDetailResponse>, Error> {
753 self.send_envelope(self.get(&format!("/api/v1/workflows/{name}")))
754 .await
755 }
756
757 /// `POST /api/v1/workflows/:name/plan` -- Build the execution plan for a workflow.
758 ///
759 /// Planning has no side effect: no step runs, and no run is created.
760 ///
761 /// # Errors
762 ///
763 /// Returns [`Error::Api`] on 400 (invalid payload or `max_depth`), 401, or
764 /// 404 (workflow not registered).
765 pub async fn plan_workflow(
766 &self,
767 name: &str,
768 request: &types::PlanWorkflowRequest,
769 ) -> Result<ApiResponse<types::ExecutionPlanResponse>, Error> {
770 self.send_envelope(
771 self.post(&format!("/api/v1/workflows/{name}/plan"))
772 .json(request),
773 )
774 .await
775 }
776
777 /// `POST /api/v1/workflows/:name/pause` -- Pause a workflow: workers stop
778 /// picking its queued runs until it is resumed.
779 ///
780 /// # Errors
781 ///
782 /// Returns [`Error::Api`] on 404 (workflow not registered) or 403 (not an
783 /// admin).
784 pub async fn pause_workflow(
785 &self,
786 name: &str,
787 ) -> Result<ApiResponse<types::WorkflowPauseResponse>, Error> {
788 self.send_envelope(self.post(&format!("/api/v1/workflows/{name}/pause")))
789 .await
790 }
791
792 /// `POST /api/v1/workflows/:name/resume` -- Resume a paused workflow.
793 ///
794 /// # Errors
795 ///
796 /// Returns [`Error::Api`] on 404 (workflow not registered) or 403 (not an
797 /// admin).
798 pub async fn resume_workflow(
799 &self,
800 name: &str,
801 ) -> Result<ApiResponse<types::WorkflowPauseResponse>, Error> {
802 self.send_envelope(self.post(&format!("/api/v1/workflows/{name}/resume")))
803 .await
804 }
805
806 // ── Stats ──────────────────────────────────────────────────────
807
808 /// `GET /api/v1/stats` -- Aggregate statistics.
809 ///
810 /// # Errors
811 ///
812 /// Returns [`Error::Api`] on 401.
813 pub async fn get_stats(&self) -> Result<ApiResponse<types::StatsResponse>, Error> {
814 self.send_envelope(self.get("/api/v1/stats")).await
815 }
816
817 /// `GET /api/v1/stats/history` -- Time-bucketed historical statistics.
818 ///
819 /// Shorthand for [`stats_history_with`](Self::stats_history_with) without
820 /// run filters other than `workflow`.
821 ///
822 /// # Examples
823 ///
824 /// ```no_run
825 /// # async fn example(client: &ironflow_sdk::IronflowClient) -> Result<(), ironflow_sdk::error::Error> {
826 /// let history = client.stats_history(Some("deploy"), Some("7d"), None).await?;
827 /// for bucket in &history.data.buckets {
828 /// println!(
829 /// "{}: {} completed, {} warning, {} running",
830 /// bucket.time, bucket.completed, bucket.warning, bucket.running
831 /// );
832 /// }
833 /// # Ok(())
834 /// # }
835 /// ```
836 ///
837 /// # Errors
838 ///
839 /// Returns [`Error::Api`] on 401 or 400.
840 pub async fn stats_history(
841 &self,
842 workflow: Option<&str>,
843 period: Option<&str>,
844 granularity: Option<&str>,
845 ) -> Result<ApiResponse<types::StatsHistoryResponse>, Error> {
846 self.stats_history_with(&StatsHistoryParams {
847 workflow,
848 period,
849 granularity,
850 ..StatsHistoryParams::default()
851 })
852 .await
853 }
854
855 /// `GET /api/v1/stats/history` with every query parameter.
856 ///
857 /// Returns one bucket per granularity step over the whole period
858 /// (zero-filled), each with a counter per run status and the success rate.
859 ///
860 /// # Examples
861 ///
862 /// ```no_run
863 /// use ironflow_sdk::client::StatsHistoryParams;
864 ///
865 /// # async fn example(client: &ironflow_sdk::IronflowClient) -> Result<(), ironflow_sdk::error::Error> {
866 /// let params = StatsHistoryParams {
867 /// period: Some("24h"),
868 /// label: Some("env:prod"),
869 /// ..Default::default()
870 /// };
871 /// let history = client.stats_history_with(¶ms).await?;
872 /// for bucket in &history.data.buckets {
873 /// println!("{}: {:?}", bucket.time, bucket.success_rate_percent);
874 /// }
875 /// # Ok(())
876 /// # }
877 /// ```
878 ///
879 /// # Errors
880 ///
881 /// Returns [`Error::Api`] on 401 or 400.
882 pub async fn stats_history_with(
883 &self,
884 params: &StatsHistoryParams<'_>,
885 ) -> Result<ApiResponse<types::StatsHistoryResponse>, Error> {
886 self.send_envelope(self.get("/api/v1/stats/history").query(params))
887 .await
888 }
889
890 /// `GET /api/v1/stats/agent-roles` -- Cost, executions and failures per
891 /// agent role over a period, all workflows combined.
892 ///
893 /// `period` is `24h`, `7d`, `30d` or `90d`; the server defaults to `7d`.
894 ///
895 /// # Examples
896 ///
897 /// ```no_run
898 /// # async fn example(client: &ironflow_sdk::IronflowClient) -> Result<(), ironflow_sdk::error::Error> {
899 /// let stats = client.agent_role_stats(Some("30d")).await?;
900 /// for role in &stats.data.roles {
901 /// println!("{}: {} executions", role.agent_role, role.executions);
902 /// }
903 /// # Ok(())
904 /// # }
905 /// ```
906 ///
907 /// # Errors
908 ///
909 /// Returns [`Error::Api`] on 401, 403 or 400 (invalid period).
910 pub async fn agent_role_stats(
911 &self,
912 period: Option<&str>,
913 ) -> Result<ApiResponse<types::AgentRoleStatsResponse>, Error> {
914 let mut request = self.get("/api/v1/stats/agent-roles");
915 if let Some(period) = period {
916 request = request.query(&[("period", period)]);
917 }
918 self.send_envelope(request).await
919 }
920
921 // ── Steps ──────────────────────────────────────────────────────
922
923 /// `GET /api/v1/steps` -- List the agent steps of one agent role across
924 /// every run and workflow, newest first.
925 ///
926 /// # Examples
927 ///
928 /// ```no_run
929 /// use ironflow_sdk::client::ListStepsParams;
930 ///
931 /// # async fn example(client: &ironflow_sdk::IronflowClient) -> Result<(), ironflow_sdk::error::Error> {
932 /// let params = ListStepsParams {
933 /// agent_role: "Reviewer",
934 /// page: None,
935 /// per_page: Some(50),
936 /// };
937 /// let steps = client.list_steps(¶ms).await?;
938 /// for step in &steps.data {
939 /// println!("{} in run {}", step.name, step.run_id);
940 /// }
941 /// # Ok(())
942 /// # }
943 /// ```
944 ///
945 /// # Errors
946 ///
947 /// Returns [`Error::Api`] on 401, 403 or 400 (blank or too long role).
948 pub async fn list_steps(
949 &self,
950 params: &ListStepsParams<'_>,
951 ) -> Result<ApiResponse<Vec<types::StepResponse>>, Error> {
952 self.send_envelope(self.get("/api/v1/steps").query(params))
953 .await
954 }
955
956 // ── Auth ───────────────────────────────────────────────────────
957
958 /// `POST /api/v1/auth/sign-in` -- Sign in with credentials.
959 ///
960 /// Returns cookies/headers for session management.
961 ///
962 /// # Errors
963 ///
964 /// Returns [`Error::Api`] on 401 (invalid credentials).
965 pub async fn sign_in(&self, request: &types::SignInRequest) -> Result<(), Error> {
966 self.send_no_content(self.post("/api/v1/auth/sign-in").json(request))
967 .await
968 }
969
970 /// `POST /api/v1/auth/sign-out` -- Sign out.
971 ///
972 /// # Errors
973 ///
974 /// Returns [`Error::Api`] on 401.
975 pub async fn sign_out(&self) -> Result<(), Error> {
976 self.send_no_content(self.post("/api/v1/auth/sign-out"))
977 .await
978 }
979
980 /// `GET /api/v1/auth/me` -- Get current user info.
981 ///
982 /// # Errors
983 ///
984 /// Returns [`Error::Api`] on 401.
985 pub async fn me(&self) -> Result<ApiResponse<types::MeResponse>, Error> {
986 self.send_envelope(self.get("/api/v1/auth/me")).await
987 }
988
989 // ── API Keys ───────────────────────────────────────────────────
990
991 /// `GET /api/v1/api-keys` -- List API keys.
992 ///
993 /// # Errors
994 ///
995 /// Returns [`Error::Api`] on 401.
996 pub async fn list_api_keys(&self) -> Result<ApiResponse<Vec<types::ApiKeyResponse>>, Error> {
997 self.send_envelope(self.get("/api/v1/api-keys")).await
998 }
999
1000 /// `POST /api/v1/api-keys` -- Create a new API key.
1001 ///
1002 /// # Errors
1003 ///
1004 /// Returns [`Error::Api`] on 400 or 401.
1005 pub async fn create_api_key(
1006 &self,
1007 request: &types::CreateApiKeyRequest,
1008 ) -> Result<ApiResponse<types::CreateApiKeyResponse>, Error> {
1009 self.send_envelope_once(self.post("/api/v1/api-keys").json(request))
1010 .await
1011 }
1012
1013 /// `GET /api/v1/api-keys/scopes` -- List available API key scopes.
1014 ///
1015 /// # Errors
1016 ///
1017 /// Returns [`Error::Api`] on 401.
1018 pub async fn available_scopes(&self) -> Result<ApiResponse<Vec<types::ScopeEntry>>, Error> {
1019 self.send_envelope(self.get("/api/v1/api-keys/scopes"))
1020 .await
1021 }
1022
1023 /// `DELETE /api/v1/api-keys/:id` -- Delete an API key.
1024 ///
1025 /// # Errors
1026 ///
1027 /// Returns [`Error::Api`] on 404 or 401.
1028 pub async fn delete_api_key(&self, id: Uuid) -> Result<(), Error> {
1029 self.send_no_content(self.delete(&format!("/api/v1/api-keys/{id}")))
1030 .await
1031 }
1032
1033 // ── Users (admin) ──────────────────────────────────────────────
1034
1035 /// `GET /api/v1/users` -- List users (admin only).
1036 ///
1037 /// # Errors
1038 ///
1039 /// Returns [`Error::Api`] on 401 or 403.
1040 pub async fn list_users(&self) -> Result<ApiResponse<Vec<types::UserResponse>>, Error> {
1041 self.send_envelope(self.get("/api/v1/users")).await
1042 }
1043
1044 /// `POST /api/v1/users` -- Create a user (admin only).
1045 ///
1046 /// # Errors
1047 ///
1048 /// Returns [`Error::Api`] on 400, 401, 403, or 409.
1049 pub async fn create_user(
1050 &self,
1051 request: &types::CreateUserRequest,
1052 ) -> Result<ApiResponse<types::UserResponse>, Error> {
1053 self.send_envelope(self.post("/api/v1/users").json(request))
1054 .await
1055 }
1056
1057 /// `DELETE /api/v1/users/:id` -- Delete a user (admin only).
1058 ///
1059 /// # Errors
1060 ///
1061 /// Returns [`Error::Api`] on 404, 401, or 403.
1062 pub async fn delete_user(&self, id: Uuid) -> Result<(), Error> {
1063 self.send_no_content(self.delete(&format!("/api/v1/users/{id}")))
1064 .await
1065 }
1066
1067 /// `PATCH /api/v1/users/:id/role` -- Update a user's role (admin only).
1068 ///
1069 /// # Errors
1070 ///
1071 /// Returns [`Error::Api`] on 404, 401, or 403.
1072 pub async fn update_role(
1073 &self,
1074 id: Uuid,
1075 request: &types::UpdateRoleRequest,
1076 ) -> Result<ApiResponse<types::UserResponse>, Error> {
1077 self.send_envelope(
1078 self.patch(&format!("/api/v1/users/{id}/role"))
1079 .json(request),
1080 )
1081 .await
1082 }
1083
1084 /// `GET /api/v1/users/:id/groups` -- List a user's groups (admin only).
1085 ///
1086 /// # Errors
1087 ///
1088 /// Returns [`Error::Api`] on 401, 403, or 404.
1089 pub async fn get_user_groups(
1090 &self,
1091 id: Uuid,
1092 ) -> Result<ApiResponse<types::UserGroupsResponse>, Error> {
1093 self.send_envelope(self.get(&format!("/api/v1/users/{id}/groups")))
1094 .await
1095 }
1096
1097 /// `PUT /api/v1/users/:id/groups` -- Replace a user's groups (admin only).
1098 ///
1099 /// Group membership restricts who may vote on an approval gate whose rule
1100 /// lists approver groups. An empty slice removes the user from every
1101 /// group.
1102 ///
1103 /// # Errors
1104 ///
1105 /// Returns [`Error::Api`] on 400 (invalid group name), 401, 403, or 404.
1106 pub async fn update_user_groups(
1107 &self,
1108 id: Uuid,
1109 groups: &[String],
1110 ) -> Result<ApiResponse<types::UserGroupsResponse>, Error> {
1111 let request = types::UpdateUserGroupsRequest {
1112 groups: groups.to_vec(),
1113 };
1114 self.send_envelope(
1115 self.put(&format!("/api/v1/users/{id}/groups"))
1116 .json(&request),
1117 )
1118 .await
1119 }
1120
1121 // ── Secrets (admin) ────────────────────────────────────────────
1122
1123 /// `GET /api/v1/secrets` -- List secrets (admin only).
1124 ///
1125 /// # Errors
1126 ///
1127 /// Returns [`Error::Api`] on 401 or 403.
1128 pub async fn list_secrets(&self) -> Result<ApiResponse<Vec<types::SecretResponse>>, Error> {
1129 self.send_envelope(self.get("/api/v1/secrets")).await
1130 }
1131
1132 /// `POST /api/v1/secrets` -- Create a secret (admin only).
1133 ///
1134 /// # Errors
1135 ///
1136 /// Returns [`Error::Api`] on 400, 401, or 403.
1137 pub async fn create_secret(
1138 &self,
1139 request: &types::SetSecretRequest,
1140 ) -> Result<ApiResponse<types::SecretResponse>, Error> {
1141 self.send_envelope(self.post("/api/v1/secrets").json(request))
1142 .await
1143 }
1144
1145 /// `PUT /api/v1/secrets/:key` -- Update a secret (admin only).
1146 ///
1147 /// # Errors
1148 ///
1149 /// Returns [`Error::Api`] on 404, 401, or 403.
1150 pub async fn update_secret(
1151 &self,
1152 key: &str,
1153 request: &types::UpdateSecretRequest,
1154 ) -> Result<ApiResponse<types::SecretResponse>, Error> {
1155 self.send_envelope(self.put(&format!("/api/v1/secrets/{key}")).json(request))
1156 .await
1157 }
1158
1159 /// `DELETE /api/v1/secrets/:key` -- Delete a secret (admin only).
1160 ///
1161 /// # Errors
1162 ///
1163 /// Returns [`Error::Api`] on 404, 401, or 403.
1164 pub async fn delete_secret(&self, key: &str) -> Result<(), Error> {
1165 self.send_no_content(self.delete(&format!("/api/v1/secrets/{key}")))
1166 .await
1167 }
1168
1169 /// `POST /api/v1/secrets/rotate` -- Re-encrypt one batch of secrets
1170 /// towards a key version (admin only).
1171 ///
1172 /// One call handles one batch. Loop, passing the returned `last_id` back
1173 /// as `after_id`, until `remaining` reaches zero or `last_id` is `None`.
1174 /// The operation is idempotent, so an interrupted rotation can simply be
1175 /// restarted.
1176 ///
1177 /// # Errors
1178 ///
1179 /// Returns [`Error::Api`] on 400 (unknown key version), 401, or 403.
1180 pub async fn rotate_secrets(
1181 &self,
1182 request: &types::RotateSecretsRequest,
1183 ) -> Result<ApiResponse<types::RotateSecretsResponse>, Error> {
1184 self.send_envelope(self.post("/api/v1/secrets/rotate").json(request))
1185 .await
1186 }
1187
1188 /// `GET /api/v1/secrets/key-versions` -- Encryption key ring status
1189 /// (admin only).
1190 ///
1191 /// # Errors
1192 ///
1193 /// Returns [`Error::Api`] on 401 or 403.
1194 pub async fn secret_key_versions(
1195 &self,
1196 ) -> Result<ApiResponse<types::KeyVersionsResponse>, Error> {
1197 self.send_envelope(self.get("/api/v1/secrets/key-versions"))
1198 .await
1199 }
1200
1201 // ── Run Logs ───────────────────────────────────────────────────
1202
1203 /// `GET /api/v1/runs/:id/logs` -- Retrieve persisted log lines for a run.
1204 ///
1205 /// Returns log entries with cursor-based pagination. Pass the
1206 /// `next_cursor` from a previous response to fetch the next page.
1207 ///
1208 /// # Errors
1209 ///
1210 /// Returns [`Error::Api`] on 404 (run not found) or 401.
1211 ///
1212 /// # Examples
1213 ///
1214 /// ```no_run
1215 /// use ironflow_sdk::IronflowClient;
1216 /// use ironflow_sdk::client::ListRunLogsFilter;
1217 /// use uuid::Uuid;
1218 ///
1219 /// # async fn example() -> Result<(), ironflow_sdk::Error> {
1220 /// let client = IronflowClient::new("https://ironflow.example.com", "key");
1221 /// let run_id = Uuid::nil();
1222 /// let logs = client.get_run_logs(run_id, &ListRunLogsFilter::default()).await?;
1223 /// # Ok(())
1224 /// # }
1225 /// ```
1226 pub async fn get_run_logs(
1227 &self,
1228 run_id: Uuid,
1229 filter: &ListRunLogsFilter<'_>,
1230 ) -> Result<ApiResponse<Vec<types::LogEntry>>, Error> {
1231 self.send_envelope(
1232 self.get(&format!("/api/v1/runs/{run_id}/logs"))
1233 .query(filter),
1234 )
1235 .await
1236 }
1237
1238 // ── Audit Logs (admin) ─────────────────────────────────────────
1239
1240 /// `GET /api/v1/audit-logs` -- List audit log entries (admin only).
1241 ///
1242 /// # Errors
1243 ///
1244 /// Returns [`Error::Api`] on 401 or 403.
1245 pub async fn list_audit_logs(&self) -> Result<ApiResponse<Vec<types::AuditLogEntry>>, Error> {
1246 self.list_audit_logs_filtered(&ListAuditLogsFilter::default())
1247 .await
1248 }
1249
1250 /// `GET /api/v1/audit-logs` with query parameters (admin only).
1251 ///
1252 /// # Errors
1253 ///
1254 /// Returns [`Error::Api`] on 401 or 403.
1255 ///
1256 /// # Examples
1257 ///
1258 /// ```no_run
1259 /// use ironflow_sdk::IronflowClient;
1260 /// use ironflow_sdk::client::ListAuditLogsFilter;
1261 ///
1262 /// # async fn example() -> Result<(), ironflow_sdk::Error> {
1263 /// let client = IronflowClient::new("https://ironflow.example.com", "key");
1264 /// let filter = ListAuditLogsFilter {
1265 /// event_type: Some("run_created"),
1266 /// ..Default::default()
1267 /// };
1268 /// let entries = client.list_audit_logs_filtered(&filter).await?;
1269 /// # Ok(())
1270 /// # }
1271 /// ```
1272 pub async fn list_audit_logs_filtered(
1273 &self,
1274 filter: &ListAuditLogsFilter<'_>,
1275 ) -> Result<ApiResponse<Vec<types::AuditLogEntry>>, Error> {
1276 self.send_envelope(self.get("/api/v1/audit-logs").query(filter))
1277 .await
1278 }
1279}