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#[derive(Clone, Debug, PartialEq, Eq)]
13pub enum Auth {
14 None,
15 AdminKey(String),
16 ApiKey(String),
17 OperatorJwt(String),
18}
19
20#[derive(Clone, Debug)]
22pub struct Client {
23 base_url: String,
24 auth: Auth,
25 user_agent: Option<String>,
26 agent: Agent,
27}
28
29#[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#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct DownloadResolution {
42 pub location: String,
43}
44
45impl Client {
46 pub fn builder(base_url: impl Into<String>, auth: Auth) -> Result<ClientBuilder> {
48 ClientBuilder::new(base_url, auth)
49 }
50
51 pub fn new(base_url: impl Into<String>, auth: Auth) -> Result<Self> {
53 ClientBuilder::new(base_url, auth)?.build()
54 }
55
56 pub fn with_auth(&self, auth: Auth) -> Self {
58 let mut updated = self.clone();
59 updated.auth = auth;
60 updated
61 }
62
63 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 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 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 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 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 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 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 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 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 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 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 pub fn create_user(&self, body: &UserCreateRequest) -> Result<UserResponse> {
236 self.create_user_with_idempotency(body, None)
237 }
238
239 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 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 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 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 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 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 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 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 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 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 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 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}