Skip to main content

tapis_apps/
client.rs

1use crate::apis::{
2    Error, applications_api, configuration, general_api, permissions_api, sharing_api,
3};
4use crate::models;
5use http::header::{HeaderMap, HeaderValue};
6use reqwest::{Client, Request, Response};
7use reqwest_middleware::{ClientBuilder, Middleware, Next, Result as MiddlewareResult};
8use std::sync::Arc;
9use tapis_core::TokenProvider;
10
11tokio::task_local! {
12    /// Allowlisted per-call headers to inject within a [`with_headers`] scope.
13    static EXTRA_HEADERS: HeaderMap;
14}
15
16/// Run an async call with allowlisted request-context headers injected into
17/// every request made within the future `f`. Headers are scoped to this
18/// task only, so concurrent calls with different headers are safe.
19pub async fn with_headers<F, T>(headers: HeaderMap, f: F) -> T
20where
21    F: std::future::Future<Output = T>,
22{
23    EXTRA_HEADERS.scope(headers, f).await
24}
25
26fn is_allowed_extra_header(name: &reqwest::header::HeaderName) -> bool {
27    let name = name.as_str();
28    name.eq_ignore_ascii_case("x-tapis-tracking-id")
29        || name.eq_ignore_ascii_case("x_tapis_tracking_id")
30        || name.eq_ignore_ascii_case("x-request-id")
31}
32
33#[derive(Debug)]
34struct LoggingMiddleware;
35
36#[derive(Debug)]
37struct HeaderInjectionMiddleware;
38
39#[async_trait::async_trait]
40impl Middleware for LoggingMiddleware {
41    async fn handle(
42        &self,
43        req: Request,
44        extensions: &mut http::Extensions,
45        next: Next<'_>,
46    ) -> MiddlewareResult<Response> {
47        let method = req.method().clone();
48        let url = req.url().clone();
49        println!("Tapis SDK request: {} {}", method, url);
50        next.run(req, extensions).await
51    }
52}
53
54#[async_trait::async_trait]
55impl Middleware for HeaderInjectionMiddleware {
56    async fn handle(
57        &self,
58        mut req: Request,
59        extensions: &mut http::Extensions,
60        next: Next<'_>,
61    ) -> MiddlewareResult<Response> {
62        let validation = EXTRA_HEADERS.try_with(|headers| {
63            for key in headers.keys() {
64                if !is_allowed_extra_header(key) {
65                    return Err(reqwest_middleware::Error::Middleware(anyhow::anyhow!(
66                        "with_headers only allows request-scoped context headers; disallowed header: {}",
67                        key.as_str()
68                    )));
69                }
70            }
71            for (k, v) in headers {
72                req.headers_mut().insert(k, v.clone());
73            }
74            Ok::<(), reqwest_middleware::Error>(())
75        });
76        if let Ok(result) = validation {
77            result?;
78        }
79        next.run(req, extensions).await
80    }
81}
82
83fn validate_tracking_id(tracking_id: &str) -> Result<(), String> {
84    if !tracking_id.is_ascii() {
85        return Err("X-Tapis-Tracking-ID must be an entirely ASCII string.".to_string());
86    }
87    if tracking_id.len() > 126 {
88        return Err("X-Tapis-Tracking-ID must be less than 126 characters.".to_string());
89    }
90    if tracking_id.matches('.').count() != 1 {
91        return Err("X-Tapis-Tracking-ID must contain exactly one '.' (format: <namespace>.<unique_identifier>).".to_string());
92    }
93    if tracking_id.starts_with('.') || tracking_id.ends_with('.') {
94        return Err("X-Tapis-Tracking-ID cannot start or end with '.'.".to_string());
95    }
96    let (namespace, unique_id) = tracking_id.split_once('.').unwrap();
97    if !namespace.chars().all(|c| c.is_alphanumeric() || c == '_') {
98        return Err("X-Tapis-Tracking-ID namespace must contain only alphanumeric characters and underscores.".to_string());
99    }
100    if !unique_id.chars().all(|c| c.is_alphanumeric() || c == '-') {
101        return Err("X-Tapis-Tracking-ID unique identifier must contain only alphanumeric characters and hyphens.".to_string());
102    }
103    Ok(())
104}
105
106#[derive(Debug)]
107struct TrackingIdMiddleware;
108
109#[async_trait::async_trait]
110impl Middleware for TrackingIdMiddleware {
111    async fn handle(
112        &self,
113        mut req: Request,
114        extensions: &mut http::Extensions,
115        next: Next<'_>,
116    ) -> MiddlewareResult<Response> {
117        let tracking_key = req
118            .headers()
119            .keys()
120            .find(|k| {
121                let s = k.as_str();
122                s.eq_ignore_ascii_case("x-tapis-tracking-id")
123                    || s.eq_ignore_ascii_case("x_tapis_tracking_id")
124            })
125            .cloned();
126        if let Some(key) = tracking_key {
127            let tracking_id = req
128                .headers()
129                .get(&key)
130                .and_then(|v| v.to_str().ok())
131                .map(|s| s.to_owned());
132            if let Some(id) = tracking_id {
133                req.headers_mut().remove(&key);
134                validate_tracking_id(&id)
135                    .map_err(|e| reqwest_middleware::Error::Middleware(anyhow::anyhow!(e)))?;
136                let name = reqwest::header::HeaderName::from_static("x-tapis-tracking-id");
137                let value = reqwest::header::HeaderValue::from_str(&id)
138                    .map_err(|e| reqwest_middleware::Error::Middleware(anyhow::anyhow!(e)))?;
139                req.headers_mut().insert(name, value);
140            }
141        }
142        next.run(req, extensions).await
143    }
144}
145
146/// Decode a base64url-encoded segment (no padding required) into raw bytes.
147fn decode_base64url(s: &str) -> Option<Vec<u8>> {
148    fn val(c: u8) -> Option<u8> {
149        match c {
150            b'A'..=b'Z' => Some(c - b'A'),
151            b'a'..=b'z' => Some(c - b'a' + 26),
152            b'0'..=b'9' => Some(c - b'0' + 52),
153            b'-' | b'+' => Some(62),
154            b'_' | b'/' => Some(63),
155            _ => None,
156        }
157    }
158    let chars: Vec<u8> = s.bytes().filter(|&b| b != b'=').collect();
159    let mut out = Vec::with_capacity(chars.len() * 3 / 4 + 1);
160    let mut i = 0;
161    while i < chars.len() {
162        let a = val(chars[i])?;
163        let b = val(*chars.get(i + 1)?)?;
164        out.push((a << 2) | (b >> 4));
165        if let Some(&c3) = chars.get(i + 2) {
166            let c = val(c3)?;
167            out.push(((b & 0x0f) << 4) | (c >> 2));
168            if let Some(&c4) = chars.get(i + 3) {
169                let d = val(c4)?;
170                out.push(((c & 0x03) << 6) | d);
171            }
172        }
173        i += 4;
174    }
175    Some(out)
176}
177
178/// Extract the `exp` (expiration) claim from a JWT without verifying the signature.
179fn extract_jwt_exp(token: &str) -> Option<i64> {
180    let payload_b64 = token.split('.').nth(1)?;
181    let bytes = decode_base64url(payload_b64)?;
182    let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
183    claims.get("exp")?.as_i64()
184}
185
186struct RefreshMiddleware {
187    token_provider: Arc<dyn TokenProvider>,
188}
189
190#[async_trait::async_trait]
191impl Middleware for RefreshMiddleware {
192    async fn handle(
193        &self,
194        mut req: Request,
195        extensions: &mut http::Extensions,
196        next: Next<'_>,
197    ) -> MiddlewareResult<Response> {
198        let is_token_endpoint = {
199            let url = req.url().as_str();
200            url.contains("/oauth2/tokens") || url.contains("/v3/tokens")
201        };
202        if !is_token_endpoint {
203            let needs_refresh = req
204                .headers()
205                .get("x-tapis-token")
206                .and_then(|v| v.to_str().ok())
207                .and_then(extract_jwt_exp)
208                .map(|exp| {
209                    let now = std::time::SystemTime::now()
210                        .duration_since(std::time::UNIX_EPOCH)
211                        .map(|d| d.as_secs() as i64)
212                        .unwrap_or(0);
213                    exp - now < 5
214                })
215                .unwrap_or(false);
216            if needs_refresh && let Some(new_token) = self.token_provider.get_token().await {
217                let value = HeaderValue::from_str(&new_token)
218                    .map_err(|e| reqwest_middleware::Error::Middleware(anyhow::anyhow!(e)))?;
219                req.headers_mut().insert("x-tapis-token", value);
220            }
221        }
222        next.run(req, extensions).await
223    }
224}
225
226#[derive(Clone)]
227pub struct TapisApps {
228    config: Arc<configuration::Configuration>,
229    pub applications: ApplicationsClient,
230    pub general: GeneralClient,
231    pub permissions: PermissionsClient,
232    pub sharing: SharingClient,
233}
234
235impl TapisApps {
236    pub fn new(
237        base_url: &str,
238        jwt_token: Option<&str>,
239    ) -> Result<Self, Box<dyn std::error::Error>> {
240        Self::build(base_url, jwt_token, None)
241    }
242
243    /// Create a client with a [`TokenProvider`] for automatic token refresh.
244    /// `RefreshMiddleware` is added to the middleware chain and will call
245    /// `provider.get_token()` transparently whenever the JWT is about to expire.
246    pub fn with_token_provider(
247        base_url: &str,
248        jwt_token: Option<&str>,
249        provider: Arc<dyn TokenProvider>,
250    ) -> Result<Self, Box<dyn std::error::Error>> {
251        Self::build(base_url, jwt_token, Some(provider))
252    }
253
254    fn build(
255        base_url: &str,
256        jwt_token: Option<&str>,
257        token_provider: Option<Arc<dyn TokenProvider>>,
258    ) -> Result<Self, Box<dyn std::error::Error>> {
259        let mut headers = HeaderMap::new();
260        if let Some(token) = jwt_token {
261            headers.insert("X-Tapis-Token", HeaderValue::from_str(token)?);
262        }
263
264        let reqwest_client = Client::builder().default_headers(headers).build()?;
265
266        let mut builder = ClientBuilder::new(reqwest_client)
267            .with(LoggingMiddleware)
268            .with(HeaderInjectionMiddleware)
269            .with(TrackingIdMiddleware);
270
271        if let Some(provider) = token_provider {
272            builder = builder.with(RefreshMiddleware {
273                token_provider: provider,
274            });
275        }
276
277        let client = builder.build();
278
279        let config = Arc::new(configuration::Configuration {
280            base_path: base_url.to_string(),
281            client,
282            ..Default::default()
283        });
284
285        Ok(Self {
286            config: config.clone(),
287            applications: ApplicationsClient {
288                config: config.clone(),
289            },
290            general: GeneralClient {
291                config: config.clone(),
292            },
293            permissions: PermissionsClient {
294                config: config.clone(),
295            },
296            sharing: SharingClient {
297                config: config.clone(),
298            },
299        })
300    }
301
302    pub fn config(&self) -> &configuration::Configuration {
303        &self.config
304    }
305}
306
307#[derive(Clone)]
308pub struct ApplicationsClient {
309    config: Arc<configuration::Configuration>,
310}
311
312impl ApplicationsClient {
313    pub async fn change_app_owner(
314        &self,
315        app_id: &str,
316        user_name: &str,
317    ) -> Result<models::RespChangeCount, Error<applications_api::ChangeAppOwnerError>> {
318        applications_api::change_app_owner(&self.config, app_id, user_name).await
319    }
320
321    pub async fn create_app_version(
322        &self,
323        req_post_app: models::ReqPostApp,
324    ) -> Result<models::RespResourceUrl, Error<applications_api::CreateAppVersionError>> {
325        applications_api::create_app_version(&self.config, req_post_app).await
326    }
327
328    pub async fn delete_app(
329        &self,
330        app_id: &str,
331    ) -> Result<models::RespChangeCount, Error<applications_api::DeleteAppError>> {
332        applications_api::delete_app(&self.config, app_id).await
333    }
334
335    pub async fn disable_app(
336        &self,
337        app_id: &str,
338    ) -> Result<models::RespChangeCount, Error<applications_api::DisableAppError>> {
339        applications_api::disable_app(&self.config, app_id).await
340    }
341
342    pub async fn disable_app_version(
343        &self,
344        app_id: &str,
345        app_version: &str,
346    ) -> Result<models::RespChangeCount, Error<applications_api::DisableAppVersionError>> {
347        applications_api::disable_app_version(&self.config, app_id, app_version).await
348    }
349
350    pub async fn enable_app(
351        &self,
352        app_id: &str,
353    ) -> Result<models::RespChangeCount, Error<applications_api::EnableAppError>> {
354        applications_api::enable_app(&self.config, app_id).await
355    }
356
357    pub async fn enable_app_version(
358        &self,
359        app_id: &str,
360        app_version: &str,
361    ) -> Result<models::RespChangeCount, Error<applications_api::EnableAppVersionError>> {
362        applications_api::enable_app_version(&self.config, app_id, app_version).await
363    }
364
365    pub async fn get_app(
366        &self,
367        app_id: &str,
368        app_version: &str,
369        require_exec_perm: Option<bool>,
370        impersonation_id: Option<&str>,
371        select: Option<&str>,
372        resource_tenant: Option<&str>,
373    ) -> Result<models::RespApp, Error<applications_api::GetAppError>> {
374        applications_api::get_app(
375            &self.config,
376            app_id,
377            app_version,
378            require_exec_perm,
379            impersonation_id,
380            select,
381            resource_tenant,
382        )
383        .await
384    }
385
386    pub async fn get_app_latest_version(
387        &self,
388        app_id: &str,
389        require_exec_perm: Option<bool>,
390        select: Option<&str>,
391        resource_tenant: Option<&str>,
392        impersonation_id: Option<&str>,
393    ) -> Result<models::RespApp, Error<applications_api::GetAppLatestVersionError>> {
394        applications_api::get_app_latest_version(
395            &self.config,
396            app_id,
397            require_exec_perm,
398            select,
399            resource_tenant,
400            impersonation_id,
401        )
402        .await
403    }
404
405    pub async fn get_apps(
406        &self,
407        search: Option<&str>,
408        list_type: Option<models::ListTypeEnum>,
409        limit: Option<i32>,
410        order_by: Option<&str>,
411        skip: Option<i32>,
412        start_after: Option<&str>,
413        compute_total: Option<bool>,
414        select: Option<&str>,
415        show_deleted: Option<bool>,
416        impersonation_id: Option<&str>,
417    ) -> Result<models::RespApps, Error<applications_api::GetAppsError>> {
418        applications_api::get_apps(
419            &self.config,
420            search,
421            list_type,
422            limit,
423            order_by,
424            skip,
425            start_after,
426            compute_total,
427            select,
428            show_deleted,
429            impersonation_id,
430        )
431        .await
432    }
433
434    pub async fn get_history(
435        &self,
436        app_id: &str,
437    ) -> Result<models::RespAppHistory, Error<applications_api::GetHistoryError>> {
438        applications_api::get_history(&self.config, app_id).await
439    }
440
441    pub async fn is_enabled(
442        &self,
443        app_id: &str,
444        version: Option<&str>,
445    ) -> Result<models::RespBoolean, Error<applications_api::IsEnabledError>> {
446        applications_api::is_enabled(&self.config, app_id, version).await
447    }
448
449    pub async fn lock_app(
450        &self,
451        app_id: &str,
452        app_version: &str,
453    ) -> Result<models::RespChangeCount, Error<applications_api::LockAppError>> {
454        applications_api::lock_app(&self.config, app_id, app_version).await
455    }
456
457    pub async fn patch_app(
458        &self,
459        app_id: &str,
460        app_version: &str,
461        req_patch_app: models::ReqPatchApp,
462    ) -> Result<models::RespResourceUrl, Error<applications_api::PatchAppError>> {
463        applications_api::patch_app(&self.config, app_id, app_version, req_patch_app).await
464    }
465
466    pub async fn put_app(
467        &self,
468        app_id: &str,
469        app_version: &str,
470        req_put_app: models::ReqPutApp,
471    ) -> Result<models::RespResourceUrl, Error<applications_api::PutAppError>> {
472        applications_api::put_app(&self.config, app_id, app_version, req_put_app).await
473    }
474
475    pub async fn search_apps_query_parameters(
476        &self,
477        list_type: Option<models::ListTypeEnum>,
478        limit: Option<i32>,
479        order_by: Option<&str>,
480        skip: Option<i32>,
481        start_after: Option<&str>,
482        compute_total: Option<bool>,
483        select: Option<&str>,
484    ) -> Result<models::RespApps, Error<applications_api::SearchAppsQueryParametersError>> {
485        applications_api::search_apps_query_parameters(
486            &self.config,
487            list_type,
488            limit,
489            order_by,
490            skip,
491            start_after,
492            compute_total,
493            select,
494        )
495        .await
496    }
497
498    pub async fn search_apps_request_body(
499        &self,
500        req_search_apps: models::ReqSearchApps,
501        list_type: Option<models::ListTypeEnum>,
502        limit: Option<i32>,
503        order_by: Option<&str>,
504        skip: Option<i32>,
505        start_after: Option<&str>,
506        compute_total: Option<bool>,
507        select: Option<&str>,
508    ) -> Result<models::RespApps, Error<applications_api::SearchAppsRequestBodyError>> {
509        applications_api::search_apps_request_body(
510            &self.config,
511            req_search_apps,
512            list_type,
513            limit,
514            order_by,
515            skip,
516            start_after,
517            compute_total,
518            select,
519        )
520        .await
521    }
522
523    pub async fn undelete_app(
524        &self,
525        app_id: &str,
526    ) -> Result<models::RespChangeCount, Error<applications_api::UndeleteAppError>> {
527        applications_api::undelete_app(&self.config, app_id).await
528    }
529
530    pub async fn unlock_app(
531        &self,
532        app_id: &str,
533        app_version: &str,
534    ) -> Result<models::RespChangeCount, Error<applications_api::UnlockAppError>> {
535        applications_api::unlock_app(&self.config, app_id, app_version).await
536    }
537}
538
539#[derive(Clone)]
540pub struct GeneralClient {
541    config: Arc<configuration::Configuration>,
542}
543
544impl GeneralClient {
545    pub async fn health_check(
546        &self,
547    ) -> Result<models::RespBasic, Error<general_api::HealthCheckError>> {
548        general_api::health_check(&self.config).await
549    }
550
551    pub async fn ready_check(
552        &self,
553    ) -> Result<models::RespBasic, Error<general_api::ReadyCheckError>> {
554        general_api::ready_check(&self.config).await
555    }
556}
557
558#[derive(Clone)]
559pub struct PermissionsClient {
560    config: Arc<configuration::Configuration>,
561}
562
563impl PermissionsClient {
564    pub async fn get_user_perms(
565        &self,
566        app_id: &str,
567        user_name: &str,
568    ) -> Result<models::RespNameArray, Error<permissions_api::GetUserPermsError>> {
569        permissions_api::get_user_perms(&self.config, app_id, user_name).await
570    }
571
572    pub async fn grant_user_perms(
573        &self,
574        app_id: &str,
575        user_name: &str,
576        req_perms: models::ReqPerms,
577    ) -> Result<models::RespBasic, Error<permissions_api::GrantUserPermsError>> {
578        permissions_api::grant_user_perms(&self.config, app_id, user_name, req_perms).await
579    }
580
581    pub async fn revoke_user_perm(
582        &self,
583        app_id: &str,
584        user_name: &str,
585        permission: &str,
586    ) -> Result<models::RespBasic, Error<permissions_api::RevokeUserPermError>> {
587        permissions_api::revoke_user_perm(&self.config, app_id, user_name, permission).await
588    }
589
590    pub async fn revoke_user_perms(
591        &self,
592        app_id: &str,
593        user_name: &str,
594        req_perms: models::ReqPerms,
595    ) -> Result<models::RespBasic, Error<permissions_api::RevokeUserPermsError>> {
596        permissions_api::revoke_user_perms(&self.config, app_id, user_name, req_perms).await
597    }
598}
599
600#[derive(Clone)]
601pub struct SharingClient {
602    config: Arc<configuration::Configuration>,
603}
604
605impl SharingClient {
606    pub async fn get_share_info(
607        &self,
608        app_id: &str,
609    ) -> Result<models::RespShareInfo, Error<sharing_api::GetShareInfoError>> {
610        sharing_api::get_share_info(&self.config, app_id).await
611    }
612
613    pub async fn share_app(
614        &self,
615        app_id: &str,
616        req_share_update: models::ReqShareUpdate,
617    ) -> Result<models::RespBasic, Error<sharing_api::ShareAppError>> {
618        sharing_api::share_app(&self.config, app_id, req_share_update).await
619    }
620
621    pub async fn share_app_public(
622        &self,
623        app_id: &str,
624    ) -> Result<models::RespBasic, Error<sharing_api::ShareAppPublicError>> {
625        sharing_api::share_app_public(&self.config, app_id).await
626    }
627
628    pub async fn un_share_app(
629        &self,
630        app_id: &str,
631        req_share_update: models::ReqShareUpdate,
632    ) -> Result<models::RespBasic, Error<sharing_api::UnShareAppError>> {
633        sharing_api::un_share_app(&self.config, app_id, req_share_update).await
634    }
635
636    pub async fn un_share_app_public(
637        &self,
638        app_id: &str,
639    ) -> Result<models::RespBasic, Error<sharing_api::UnShareAppPublicError>> {
640        sharing_api::un_share_app_public(&self.config, app_id).await
641    }
642}