releasy_client/
client.rs

1use std::fs::File;
2use std::path::Path;
3use std::time::Duration;
4
5use serde::de::DeserializeOwned;
6use ureq::{Agent, RequestBuilder};
7
8use crate::error::{Error, Result};
9use crate::models::*;
10
11/// Authentication strategy for API requests.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub enum Auth {
14    None,
15    AdminKey(String),
16    ApiKey(String),
17    OperatorJwt(String),
18}
19
20/// Blocking HTTP client for the Releasy API.
21#[derive(Clone, Debug)]
22pub struct Client {
23    base_url: String,
24    auth: Auth,
25    user_agent: Option<String>,
26    agent: Agent,
27}
28
29/// Builder for configuring a `Client`.
30#[derive(Clone, Debug)]
31pub struct ClientBuilder {
32    base_url: String,
33    auth: Auth,
34    user_agent: Option<String>,
35    timeout_global: Option<Duration>,
36    agent: Option<Agent>,
37}
38
39/// Resolved download redirect location.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct DownloadResolution {
42    pub location: String,
43}
44
45impl Client {
46    /// Start building a client with the given base URL and auth.
47    pub fn builder(base_url: impl Into<String>, auth: Auth) -> Result<ClientBuilder> {
48        ClientBuilder::new(base_url, auth)
49    }
50
51    /// Build a client with default configuration.
52    pub fn new(base_url: impl Into<String>, auth: Auth) -> Result<Self> {
53        ClientBuilder::new(base_url, auth)?.build()
54    }
55
56    /// Return a cloned client with updated authentication.
57    pub fn with_auth(&self, auth: Auth) -> Self {
58        let mut updated = self.clone();
59        updated.auth = auth;
60        updated
61    }
62
63    /// Fetch the OpenAPI document from the server.
64    pub fn openapi_json(&self) -> Result<serde_json::Value> {
65        let url = self.url("/openapi.json");
66        let request = self.apply_headers(self.agent.get(&url));
67        let response = request.call()?;
68        self.parse_json_response(response)
69    }
70
71    /// Check service health (API + database).
72    pub fn health_check(&self) -> Result<HealthResponse> {
73        let url = self.url("/health");
74        let request = self.apply_headers(self.agent.get(&url));
75        let response = request.call()?;
76        self.parse_json_response(response)
77    }
78
79    /// Check service liveness.
80    pub fn live_check(&self) -> Result<HealthResponse> {
81        let url = self.url("/live");
82        let request = self.apply_headers(self.agent.get(&url));
83        let response = request.call()?;
84        self.parse_json_response(response)
85    }
86
87    /// Check service readiness.
88    pub fn ready_check(&self) -> Result<HealthResponse> {
89        let url = self.url("/ready");
90        let request = self.apply_headers(self.agent.get(&url));
91        let response = request.call()?;
92        self.parse_json_response(response)
93    }
94
95    /// List audit events with optional filters.
96    pub fn list_audit_events(&self, query: &AuditEventListQuery) -> Result<AuditEventListResponse> {
97        let url = self.url("/v1/admin/audit-events");
98        let mut request = self.apply_headers(self.agent.get(&url));
99        if let Some(value) = &query.customer_id {
100            request = request.query("customer_id", value);
101        }
102        if let Some(value) = &query.actor {
103            request = request.query("actor", value);
104        }
105        if let Some(value) = &query.event {
106            request = request.query("event", value);
107        }
108        if let Some(value) = query.created_from {
109            let value = value.to_string();
110            request = request.query("created_from", &value);
111        }
112        if let Some(value) = query.created_to {
113            let value = value.to_string();
114            request = request.query("created_to", &value);
115        }
116        if let Some(value) = query.limit {
117            let value = value.to_string();
118            request = request.query("limit", &value);
119        }
120        if let Some(value) = query.offset {
121            let value = value.to_string();
122            request = request.query("offset", &value);
123        }
124        let response = request.call()?;
125        self.parse_json_response(response)
126    }
127
128    /// List customers with optional filters.
129    pub fn list_customers(
130        &self,
131        query: &AdminCustomerListQuery,
132    ) -> Result<AdminCustomerListResponse> {
133        let url = self.url("/v1/admin/customers");
134        let mut request = self.apply_headers(self.agent.get(&url));
135        if let Some(value) = &query.customer_id {
136            request = request.query("customer_id", value);
137        }
138        if let Some(value) = &query.name {
139            request = request.query("name", value);
140        }
141        if let Some(value) = &query.plan {
142            request = request.query("plan", value);
143        }
144        if let Some(value) = query.limit {
145            let value = value.to_string();
146            request = request.query("limit", &value);
147        }
148        if let Some(value) = query.offset {
149            let value = value.to_string();
150            request = request.query("offset", &value);
151        }
152        let response = request.call()?;
153        self.parse_json_response(response)
154    }
155
156    /// Create a customer (admin only).
157    pub fn admin_create_customer(
158        &self,
159        body: &AdminCreateCustomerRequest,
160    ) -> Result<AdminCreateCustomerResponse> {
161        self.admin_create_customer_with_idempotency(body, None)
162    }
163
164    /// Create a customer with an optional idempotency key.
165    pub fn admin_create_customer_with_idempotency(
166        &self,
167        body: &AdminCreateCustomerRequest,
168        idempotency_key: Option<&str>,
169    ) -> Result<AdminCreateCustomerResponse> {
170        let url = self.url("/v1/admin/customers");
171        let mut request = self.apply_headers(self.agent.post(&url));
172        if let Some(key) = idempotency_key {
173            request = request.header("Idempotency-Key", key);
174        }
175        let response = request.send_json(body)?;
176        self.parse_json_response(response)
177    }
178
179    /// Fetch a customer by id.
180    pub fn get_customer(&self, customer_id: &str) -> Result<AdminCustomerResponse> {
181        let url = self.url(&format!("/v1/admin/customers/{}", customer_id));
182        let request = self.apply_headers(self.agent.get(&url));
183        let response = request.call()?;
184        self.parse_json_response(response)
185    }
186
187    /// Update customer fields.
188    pub fn update_customer(
189        &self,
190        customer_id: &str,
191        body: &AdminUpdateCustomerRequest,
192    ) -> Result<AdminCustomerResponse> {
193        let url = self.url(&format!("/v1/admin/customers/{}", customer_id));
194        let request = self.apply_headers(self.agent.patch(&url));
195        let response = request.send_json(body)?;
196        self.parse_json_response(response)
197    }
198
199    /// List users with optional filters.
200    pub fn list_users(&self, query: &UserListQuery) -> Result<UserListResponse> {
201        let url = self.url("/v1/admin/users");
202        let mut request = self.apply_headers(self.agent.get(&url));
203        if let Some(value) = &query.customer_id {
204            request = request.query("customer_id", value);
205        }
206        if let Some(value) = &query.email {
207            request = request.query("email", value);
208        }
209        if let Some(value) = &query.status {
210            request = request.query("status", value);
211        }
212        if let Some(value) = &query.keycloak_user_id {
213            request = request.query("keycloak_user_id", value);
214        }
215        if let Some(value) = query.created_from {
216            let value = value.to_string();
217            request = request.query("created_from", &value);
218        }
219        if let Some(value) = query.created_to {
220            let value = value.to_string();
221            request = request.query("created_to", &value);
222        }
223        if let Some(value) = query.limit {
224            let value = value.to_string();
225            request = request.query("limit", &value);
226        }
227        if let Some(value) = &query.cursor {
228            request = request.query("cursor", value);
229        }
230        let response = request.call()?;
231        self.parse_json_response(response)
232    }
233
234    /// Create a user (admin only).
235    pub fn create_user(&self, body: &UserCreateRequest) -> Result<UserResponse> {
236        self.create_user_with_idempotency(body, None)
237    }
238
239    /// Create a user with an optional idempotency key.
240    pub fn create_user_with_idempotency(
241        &self,
242        body: &UserCreateRequest,
243        idempotency_key: Option<&str>,
244    ) -> Result<UserResponse> {
245        let url = self.url("/v1/admin/users");
246        let mut request = self.apply_headers(self.agent.post(&url));
247        if let Some(key) = idempotency_key {
248            request = request.header("Idempotency-Key", key);
249        }
250        let response = request.send_json(body)?;
251        self.parse_json_response(response)
252    }
253
254    /// Fetch a user by id.
255    pub fn get_user(&self, user_id: &str) -> Result<UserResponse> {
256        let url = self.url(&format!("/v1/admin/users/{}", user_id));
257        let request = self.apply_headers(self.agent.get(&url));
258        let response = request.call()?;
259        self.parse_json_response(response)
260    }
261
262    /// Patch a user by id.
263    pub fn patch_user(&self, user_id: &str, body: &UserPatchRequest) -> Result<UserResponse> {
264        let url = self.url(&format!("/v1/admin/users/{}", user_id));
265        let request = self.apply_headers(self.agent.patch(&url));
266        let response = request.send_json(body)?;
267        self.parse_json_response(response)
268    }
269
270    /// Replace the user's groups.
271    pub fn replace_groups(
272        &self,
273        user_id: &str,
274        body: &UserGroupsReplaceRequest,
275    ) -> Result<UserResponse> {
276        let url = self.url(&format!("/v1/admin/users/{}/groups", user_id));
277        let request = self.apply_headers(self.agent.put(&url));
278        let response = request.send_json(body)?;
279        self.parse_json_response(response)
280    }
281
282    /// Trigger a credential reset email for the user.
283    pub fn reset_credentials(&self, user_id: &str, body: &ResetCredentialsRequest) -> Result<()> {
284        let url = self.url(&format!("/v1/admin/users/{}/reset-credentials", user_id));
285        let request = self.apply_headers(self.agent.post(&url));
286        let response = request.send_json(body)?;
287        self.parse_empty_response(response, 202)
288    }
289
290    pub fn list_entitlements(
291        &self,
292        customer_id: &str,
293        query: &EntitlementListQuery,
294    ) -> Result<EntitlementListResponse> {
295        let url = self.url(&format!("/v1/admin/customers/{}/entitlements", customer_id));
296        let mut request = self.apply_headers(self.agent.get(&url));
297        if let Some(value) = &query.product {
298            request = request.query("product", value);
299        }
300        if let Some(value) = query.limit {
301            let value = value.to_string();
302            request = request.query("limit", &value);
303        }
304        if let Some(value) = query.offset {
305            let value = value.to_string();
306            request = request.query("offset", &value);
307        }
308        let response = request.call()?;
309        self.parse_json_response(response)
310    }
311
312    pub fn create_entitlement(
313        &self,
314        customer_id: &str,
315        body: &EntitlementCreateRequest,
316    ) -> Result<EntitlementResponse> {
317        let url = self.url(&format!("/v1/admin/customers/{}/entitlements", customer_id));
318        let request = self.apply_headers(self.agent.post(&url));
319        let response = request.send_json(body)?;
320        self.parse_json_response(response)
321    }
322
323    pub fn update_entitlement(
324        &self,
325        customer_id: &str,
326        entitlement_id: &str,
327        body: &EntitlementUpdateRequest,
328    ) -> Result<EntitlementResponse> {
329        let url = self.url(&format!(
330            "/v1/admin/customers/{}/entitlements/{}",
331            customer_id, entitlement_id
332        ));
333        let request = self.apply_headers(self.agent.patch(&url));
334        let response = request.send_json(body)?;
335        self.parse_json_response(response)
336    }
337
338    pub fn delete_entitlement(&self, customer_id: &str, entitlement_id: &str) -> Result<()> {
339        let url = self.url(&format!(
340            "/v1/admin/customers/{}/entitlements/{}",
341            customer_id, entitlement_id
342        ));
343        let request = self.apply_headers(self.agent.delete(&url));
344        let response = request.call()?;
345        self.parse_empty_response(response, 204)
346    }
347
348    pub fn admin_create_key(&self, body: &AdminCreateKeyRequest) -> Result<AdminCreateKeyResponse> {
349        let url = self.url("/v1/admin/keys");
350        let request = self.apply_headers(self.agent.post(&url));
351        let response = request.send_json(body)?;
352        self.parse_json_response(response)
353    }
354
355    pub fn admin_revoke_key(&self, body: &AdminRevokeKeyRequest) -> Result<AdminRevokeKeyResponse> {
356        let url = self.url("/v1/admin/keys/revoke");
357        let request = self.apply_headers(self.agent.post(&url));
358        let response = request.send_json(body)?;
359        self.parse_json_response(response)
360    }
361
362    pub fn auth_introspect(&self) -> Result<ApiKeyIntrospection> {
363        let url = self.url("/v1/auth/introspect");
364        let request = self.apply_headers(self.agent.post(&url));
365        let response = request.send("")?;
366        self.parse_json_response(response)
367    }
368
369    pub fn create_download_token(
370        &self,
371        body: &DownloadTokenRequest,
372    ) -> Result<DownloadTokenResponse> {
373        let url = self.url("/v1/downloads/token");
374        let request = self.apply_headers(self.agent.post(&url));
375        let response = request.send_json(body)?;
376        self.parse_json_response(response)
377    }
378
379    pub fn resolve_download_token(&self, token: &str) -> Result<DownloadResolution> {
380        let url = self.url(&format!("/v1/downloads/{}", token));
381        let request = self.apply_headers(self.agent.get(&url));
382        let response = request.call()?;
383        let status = response.status().as_u16();
384        if status == 302 {
385            let location = response
386                .headers()
387                .get(ureq::http::header::LOCATION)
388                .and_then(|value| value.to_str().ok())
389                .map(|value| value.to_string())
390                .ok_or(Error::MissingLocationHeader)?;
391            return Ok(DownloadResolution { location });
392        }
393        Err(self.error_from_response(response, status))
394    }
395
396    /// List releases with optional filters.
397    pub fn list_releases(&self, query: &ReleaseListQuery) -> Result<ReleaseListResponse> {
398        let url = self.url("/v1/releases");
399        let mut request = self.apply_headers(self.agent.get(&url));
400        if let Some(value) = &query.product {
401            request = request.query("product", value);
402        }
403        if let Some(value) = &query.version {
404            request = request.query("version", value);
405        }
406        if let Some(value) = &query.status {
407            request = request.query("status", value);
408        }
409        if let Some(value) = query.include_artifacts {
410            request = request.query("include_artifacts", if value { "true" } else { "false" });
411        }
412        if let Some(value) = query.limit {
413            let value = value.to_string();
414            request = request.query("limit", &value);
415        }
416        if let Some(value) = query.offset {
417            let value = value.to_string();
418            request = request.query("offset", &value);
419        }
420        let response = request.call()?;
421        self.parse_json_response(response)
422    }
423
424    /// Create a new release.
425    pub fn create_release(&self, body: &ReleaseCreateRequest) -> Result<ReleaseResponse> {
426        let url = self.url("/v1/releases");
427        let request = self.apply_headers(self.agent.post(&url));
428        let response = request.send_json(body)?;
429        self.parse_json_response(response)
430    }
431
432    pub fn delete_release(&self, release_id: &str) -> Result<()> {
433        let url = self.url(&format!("/v1/releases/{}", release_id));
434        let request = self.apply_headers(self.agent.delete(&url));
435        let response = request.call()?;
436        self.parse_empty_response(response, 204)
437    }
438
439    /// Register a release artifact.
440    pub fn register_release_artifact(
441        &self,
442        release_id: &str,
443        body: &ArtifactRegisterRequest,
444    ) -> Result<ArtifactRegisterResponse> {
445        let url = self.url(&format!("/v1/releases/{}/artifacts", release_id));
446        let request = self.apply_headers(self.agent.post(&url));
447        let response = request.send_json(body)?;
448        self.parse_json_response(response)
449    }
450
451    /// Request a presigned upload URL for an artifact.
452    pub fn presign_release_artifact_upload(
453        &self,
454        release_id: &str,
455        body: &ArtifactPresignRequest,
456    ) -> Result<ArtifactPresignResponse> {
457        let url = self.url(&format!("/v1/releases/{}/artifacts/presign", release_id));
458        let request = self.apply_headers(self.agent.post(&url));
459        let response = request.send_json(body)?;
460        self.parse_json_response(response)
461    }
462
463    /// Upload artifact bytes to a presigned URL.
464    pub fn upload_presigned_artifact(
465        &self,
466        upload_url: &str,
467        file_path: impl AsRef<Path>,
468    ) -> Result<()> {
469        let file = File::open(file_path.as_ref())
470            .map_err(|err| Error::Transport(ureq::Error::from(err)))?;
471        let response = self.agent.put(upload_url).send(file)?;
472        let status = response.status().as_u16();
473        if (200..300).contains(&status) {
474            return Ok(());
475        }
476        Err(self.error_from_response(response, status))
477    }
478
479    /// Publish a release.
480    pub fn publish_release(&self, release_id: &str) -> Result<ReleaseResponse> {
481        let url = self.url(&format!("/v1/releases/{}/publish", release_id));
482        let request = self.apply_headers(self.agent.post(&url));
483        let response = request.send("")?;
484        self.parse_json_response(response)
485    }
486
487    /// Unpublish a release.
488    pub fn unpublish_release(&self, release_id: &str) -> Result<ReleaseResponse> {
489        let url = self.url(&format!("/v1/releases/{}/unpublish", release_id));
490        let request = self.apply_headers(self.agent.post(&url));
491        let response = request.send("")?;
492        self.parse_json_response(response)
493    }
494
495    fn url(&self, path: &str) -> String {
496        let trimmed = path.trim_start_matches('/');
497        format!("{}/{}", self.base_url, trimmed)
498    }
499
500    fn apply_headers<B>(&self, request: RequestBuilder<B>) -> RequestBuilder<B> {
501        let mut request = request.header("Accept", "application/json");
502        if let Some(user_agent) = &self.user_agent {
503            request = request.header("User-Agent", user_agent);
504        }
505        self.apply_auth(request)
506    }
507
508    fn apply_auth<B>(&self, request: RequestBuilder<B>) -> RequestBuilder<B> {
509        match &self.auth {
510            Auth::None => request,
511            Auth::AdminKey(key) => request.header("x-releasy-admin-key", key),
512            Auth::ApiKey(key) => request.header("x-releasy-api-key", key),
513            Auth::OperatorJwt(token) => {
514                let value = format!("Bearer {}", token);
515                request.header("Authorization", &value)
516            }
517        }
518    }
519
520    fn parse_json_response<T: DeserializeOwned>(
521        &self,
522        response: ureq::http::Response<ureq::Body>,
523    ) -> Result<T> {
524        let status = response.status().as_u16();
525        if (200..300).contains(&status) {
526            let mut response = response;
527            let parsed = response.body_mut().read_json::<T>()?;
528            return Ok(parsed);
529        }
530        Err(self.error_from_response(response, status))
531    }
532
533    fn parse_empty_response(
534        &self,
535        response: ureq::http::Response<ureq::Body>,
536        expected_status: u16,
537    ) -> Result<()> {
538        let status = response.status().as_u16();
539        if status == expected_status {
540            return Ok(());
541        }
542        Err(self.error_from_response(response, status))
543    }
544
545    fn error_from_response(
546        &self,
547        mut response: ureq::http::Response<ureq::Body>,
548        status: u16,
549    ) -> Error {
550        let body = match response.body_mut().read_to_string() {
551            Ok(body) => body,
552            Err(err) => return Error::Transport(err),
553        };
554        let parsed = serde_json::from_str::<ErrorBody>(&body).ok();
555        Error::Api {
556            status,
557            error: parsed,
558            body: if body.is_empty() { None } else { Some(body) },
559        }
560    }
561}
562
563impl ClientBuilder {
564    pub fn new(base_url: impl Into<String>, auth: Auth) -> Result<Self> {
565        let base_url = normalize_base_url(base_url.into())?;
566        Ok(Self {
567            base_url,
568            auth,
569            user_agent: None,
570            timeout_global: None,
571            agent: None,
572        })
573    }
574
575    pub fn user_agent(mut self, value: impl Into<String>) -> Self {
576        self.user_agent = Some(value.into());
577        self
578    }
579
580    pub fn timeout_global(mut self, timeout: Duration) -> Self {
581        self.timeout_global = Some(timeout);
582        self
583    }
584
585    pub fn agent(mut self, agent: Agent) -> Self {
586        self.agent = Some(agent);
587        self
588    }
589
590    pub fn build(self) -> Result<Client> {
591        let agent = match self.agent {
592            Some(agent) => agent,
593            None => {
594                let mut builder = Agent::config_builder().http_status_as_error(false);
595                if let Some(timeout) = self.timeout_global {
596                    builder = builder.timeout_global(Some(timeout));
597                }
598                let config = builder.build();
599                config.into()
600            }
601        };
602        Ok(Client {
603            base_url: self.base_url,
604            auth: self.auth,
605            user_agent: self.user_agent,
606            agent,
607        })
608    }
609}
610
611fn normalize_base_url(base_url: String) -> Result<String> {
612    let trimmed = base_url.trim().trim_end_matches('/').to_string();
613    if trimmed.is_empty() {
614        return Err(Error::InvalidBaseUrl(base_url));
615    }
616    if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
617        return Err(Error::InvalidBaseUrl(base_url));
618    }
619    Ok(trimmed)
620}