mz_cloud_api/
client.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! # Materialize cloud API client
11//!
12//! This module provides an API client with typed methods for
13//! interacting with the Materialize cloud API. This client includes,
14//! token management, and basic requests against the API.
15//!
16//! The [`Client`] requires an [`mz_frontegg_client::client::Client`] as a parameter. The
17//! Frontegg client is used to request and manage the access token.
18use std::sync::Arc;
19
20use reqwest::{Method, RequestBuilder, StatusCode, Url, header::HeaderMap};
21use serde::Deserialize;
22use serde::de::DeserializeOwned;
23
24use crate::config::API_VERSION_HEADER;
25use crate::error::{ApiError, Error};
26
27use self::cloud_provider::CloudProvider;
28
29/// Represents the structure for the client.
30pub struct Client {
31    pub(crate) inner: reqwest::Client,
32    pub(crate) auth_client: Arc<mz_frontegg_client::client::Client>,
33    pub(crate) endpoint: Url,
34}
35
36pub mod cloud_provider;
37pub mod region;
38
39/// Cloud endpoints architecture:
40///
41/// (CloudProvider)                                (Region)
42///   ---------                     --------------------------------------
43///  |          |                  |              Region API              |
44///  |  Cloud   |        url       |    ----------        -------------   |
45///  |  Sync    | ---------------> |   | Provider | ---- |    Region   |  |
46///  |          |                  |   | (aws..)  |      |  (east-1..) |  |
47///  |          |                  |    ----------        -------------   |
48///   ----------                    --------------------------------------
49///
50impl Client {
51    /// Builds a request towards the `Client`'s endpoint
52    async fn build_global_request<P>(
53        &self,
54        method: Method,
55        path: P,
56        query: Option<&[(&str, &str)]>,
57    ) -> Result<RequestBuilder, Error>
58    where
59        P: IntoIterator,
60        P::Item: AsRef<str>,
61    {
62        self.build_request(method, path, query, self.endpoint.clone(), None)
63            .await
64    }
65
66    /// Builds a request towards the `Client`'s endpoint
67    /// The function requires a [CloudProvider] as parameter
68    /// since it contains the api url (Region API url)
69    /// to interact with the region.
70    /// Specify an api_version corresponding to the request/response
71    /// schema your code will handle. Refer to the Region API docs
72    /// for schema information.
73    async fn build_region_request<P>(
74        &self,
75        method: Method,
76        path: P,
77        query: Option<&[(&str, &str)]>,
78        cloud_provider: &CloudProvider,
79        api_version: Option<u16>,
80    ) -> Result<RequestBuilder, Error>
81    where
82        P: IntoIterator,
83        P::Item: AsRef<str>,
84    {
85        self.build_request(
86            method,
87            path,
88            query,
89            cloud_provider.url.clone(),
90            api_version.and_then(|api_ver| {
91                let mut headers = HeaderMap::with_capacity(1);
92                headers.insert(API_VERSION_HEADER, api_ver.into());
93                Some(headers)
94            }),
95        )
96        .await
97    }
98
99    /// Builds a request towards the `Client`'s endpoint
100    async fn build_request<P>(
101        &self,
102        method: Method,
103        path: P,
104        query: Option<&[(&str, &str)]>,
105        mut domain: Url,
106        headers: Option<HeaderMap>,
107    ) -> Result<RequestBuilder, Error>
108    where
109        P: IntoIterator,
110        P::Item: AsRef<str>,
111    {
112        domain
113            .path_segments_mut()
114            .or(Err(Error::UrlBaseError))?
115            .clear()
116            .extend(path);
117
118        let mut req = self.inner.request(method, domain);
119        if let Some(header_map) = headers {
120            req = req.headers(header_map);
121        }
122        if let Some(query_params) = query {
123            req = req.query(&query_params);
124        }
125        let token = self.auth_client.auth().await?;
126
127        Ok(req.bearer_auth(token))
128    }
129
130    async fn send_request<T>(&self, req: RequestBuilder) -> Result<T, Error>
131    where
132        T: DeserializeOwned,
133    {
134        #[derive(Deserialize)]
135        #[serde(rename_all = "camelCase")]
136        struct ErrorResponse {
137            #[serde(default)]
138            message: Option<String>,
139            #[serde(default)]
140            errors: Vec<String>,
141        }
142
143        let res = req.send().await?;
144        let status_code = res.status();
145        if status_code.is_success() {
146            if status_code == StatusCode::NO_CONTENT {
147                Err(Error::SuccesfullButNoContent)
148            } else {
149                Ok(res.json().await?)
150            }
151        } else {
152            match res.json::<ErrorResponse>().await {
153                Ok(e) => {
154                    let mut messages = e.errors;
155                    messages.extend(e.message);
156                    Err(Error::Api(ApiError {
157                        status_code,
158                        messages,
159                    }))
160                }
161                Err(_) => Err(Error::Api(ApiError {
162                    status_code,
163                    messages: vec!["unable to decode error details".into()],
164                })),
165            }
166        }
167    }
168}