Skip to main content

tapis_apps/
client.rs

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