mz_metabase/
lib.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//! An API client for [Metabase].
11//!
12//! Only the features presently required are implemented. Documentation is
13//! sparse to avoid duplicating information in the upstream API documentation.
14//! See:
15//!
16//!   * [Using the REST API](https://github.com/metabase/metabase/wiki/Using-the-REST-API)
17//!   * [Auto-generated API documentation](https://github.com/metabase/metabase/blob/master/docs/api-documentation.md)
18//!
19//! [Metabase]: https://metabase.com
20
21#![warn(missing_debug_implementations)]
22
23use std::fmt;
24use std::time::Duration;
25
26use reqwest::{IntoUrl, Url};
27use serde::de::DeserializeOwned;
28use serde::{Deserialize, Serialize};
29
30/// A Metabase API client.
31#[derive(Debug)]
32pub struct Client {
33    inner: reqwest::Client,
34    url: Url,
35    session_id: Option<String>,
36}
37
38impl Client {
39    /// Constructs a new `Client` that will target a Metabase instance at `url`.
40    ///
41    /// `url` must not contain a path nor be a [cannot-be-a-base] URL.
42    ///
43    /// [cannot-be-a-base]: https://url.spec.whatwg.org/#url-cannot-be-a-base-url-flag
44    pub fn new<U>(url: U) -> Result<Self, Error>
45    where
46        U: IntoUrl,
47    {
48        let mut url = url.into_url()?;
49        if url.path() != "/" {
50            return Err(Error::InvalidUrl("base URL cannot have path".into()));
51        }
52        assert!(!url.cannot_be_a_base());
53        url.path_segments_mut()
54            .expect("cannot-be-a-base checked to be false")
55            .push("api");
56        Ok(Client {
57            inner: reqwest::Client::new(),
58            url,
59            session_id: None,
60        })
61    }
62
63    /// Sets the session ID to include in future requests made by this client.
64    pub fn set_session_id(&mut self, session_id: String) {
65        self.session_id = Some(session_id);
66    }
67
68    /// Fetches public, global properties.
69    ///
70    /// The underlying API call is `GET /api/session/properties`.
71    pub async fn session_properties(&self) -> Result<SessionPropertiesResponse, reqwest::Error> {
72        let url = self.api_url(&["session", "properties"]);
73        self.send_request(self.inner.get(url)).await
74    }
75
76    /// Requests a session ID for the username and password named in `request`.
77    ///
78    /// Note that usernames are typically email addresses. To authenticate
79    /// future requests with the returned session ID, call `set_session_id`.
80    ///
81    /// The underlying API call is `POST /api/session`.
82    pub async fn login(&self, request: &LoginRequest) -> Result<LoginResponse, reqwest::Error> {
83        let url = self.api_url(&["session"]);
84        self.send_request(self.inner.post(url).json(request)).await
85    }
86
87    /// Creates a user and database connection if the Metabase instance has not
88    /// yet been set up.
89    ///
90    /// The request must include the `setup_token` from a
91    /// `SessionPropertiesResponse`. If the setup token returned by
92    /// [`Client::session_properties`] is `None`, the cluster is already set up,
93    /// and this request will fail.
94    ///
95    /// The underlying API call is `POST /api/setup`.
96    pub async fn setup(&self, request: &SetupRequest) -> Result<LoginResponse, reqwest::Error> {
97        let url = self.api_url(&["setup"]);
98        self.send_request(self.inner.post(url).json(request)).await
99    }
100
101    /// Fetches the list of databases known to Metabase.
102    ///
103    /// The underlying API call is `GET /database`.
104    pub async fn databases(&self) -> Result<Vec<Database>, reqwest::Error> {
105        let url = self.api_url(&["database"]);
106        let res: ListWrapper<_> = self.send_request(self.inner.get(url)).await?;
107        Ok(res.data)
108    }
109
110    /// Fetches metadata about a particular database.
111    ///
112    /// The underlying API call is `GET /database/:id/metadata`.
113    pub async fn database_metadata(&self, id: usize) -> Result<DatabaseMetadata, reqwest::Error> {
114        let url = self.api_url(&["database", &id.to_string(), "metadata"]);
115        self.send_request(self.inner.get(url)).await
116    }
117
118    fn api_url(&self, endpoint: &[&str]) -> Url {
119        let mut url = self.url.clone();
120        url.path_segments_mut()
121            .expect("url validated on construction")
122            .extend(endpoint);
123        url
124    }
125
126    async fn send_request<T>(&self, mut req: reqwest::RequestBuilder) -> Result<T, reqwest::Error>
127    where
128        T: DeserializeOwned,
129    {
130        req = req.timeout(Duration::from_secs(5));
131        if let Some(session_id) = &self.session_id {
132            req = req.header("X-Metabase-Session", session_id);
133        }
134        let res = req.send().await?.error_for_status()?;
135        res.json().await
136    }
137}
138
139/// A Metabase error.
140#[derive(Debug)]
141pub enum Error {
142    /// The provided URL was invalid.
143    InvalidUrl(String),
144    /// The underlying transport mechanism returned na error.
145    Transport(reqwest::Error),
146}
147
148impl From<reqwest::Error> for Error {
149    fn from(e: reqwest::Error) -> Error {
150        Error::Transport(e)
151    }
152}
153
154impl std::error::Error for Error {
155    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
156        match self {
157            Error::InvalidUrl(_) => None,
158            Error::Transport(e) => Some(e),
159        }
160    }
161}
162
163impl fmt::Display for Error {
164    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
165        match self {
166            Error::InvalidUrl(msg) => write!(f, "invalid url: {}", msg),
167            Error::Transport(e) => write!(f, "transport: {}", e),
168        }
169    }
170}
171
172#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
173struct ListWrapper<T> {
174    data: Vec<T>,
175}
176
177/// The response to [`Client::session_properties`].
178#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
179#[serde(rename_all = "kebab-case")]
180pub struct SessionPropertiesResponse {
181    pub setup_token: Option<String>,
182}
183
184/// The request for [`Client::setup`].
185#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
186pub struct SetupRequest {
187    pub allow_tracking: bool,
188    pub database: SetupDatabase,
189    pub token: String,
190    pub prefs: SetupPrefs,
191    pub user: SetupUser,
192}
193
194/// A database to create as part of a [`SetupRequest`].
195#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
196pub struct SetupDatabase {
197    pub engine: String,
198    pub name: String,
199    pub details: SetupDatabaseDetails,
200}
201
202/// Details for a [`SetupDatabase`].
203#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
204pub struct SetupDatabaseDetails {
205    pub host: String,
206    pub port: usize,
207    pub dbname: String,
208    pub user: String,
209}
210
211/// Preferences for a [`SetupRequest`].
212#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
213pub struct SetupPrefs {
214    pub site_name: String,
215}
216
217/// A user to create as part of a [`SetupRequest`].
218#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
219pub struct SetupUser {
220    pub email: String,
221    pub first_name: String,
222    pub last_name: String,
223    pub password: String,
224    pub site_name: String,
225}
226
227/// The request for [`Client::login`].
228#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
229pub struct LoginRequest {
230    pub username: String,
231    pub password: String,
232}
233
234/// The response to [`Client::login`].
235#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
236pub struct LoginResponse {
237    pub id: String,
238}
239
240/// A database returned by [`Client::databases`].
241#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
242pub struct Database {
243    pub name: String,
244    pub id: usize,
245}
246
247/// The response to [`Client::database_metadata`].
248#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
249pub struct DatabaseMetadata {
250    pub tables: Vec<Table>,
251}
252
253/// A table that is part of [`DatabaseMetadata`].
254#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
255pub struct Table {
256    pub name: String,
257    pub schema: String,
258    pub fields: Vec<TableField>,
259}
260
261/// A field of a [`Table`].
262#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
263pub struct TableField {
264    pub name: String,
265    pub database_type: String,
266    pub base_type: String,
267    pub special_type: Option<String>,
268}