fm_script_client/
data_api.rs

1use crate::{Connection, Error, FileMakerError, ScriptClient};
2use async_trait::async_trait;
3use reqwest::{Client, Response};
4use serde::de::DeserializeOwned;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9use tokio::sync::Mutex;
10use url::Url;
11
12/// Context for Data API script execution.
13///
14/// Scripts within the Data API always have to be executed together with another request. While it
15/// is possible to execute a script standalone through a `GET` request, it is highly advised to not
16/// do that since it is both length restricted and exposing any sent data in server logs.
17///
18/// The script context defines a `find` on a given layout which will be used as primary request, on
19/// which the actual script execution will be added on top. It is important that this `find`
20/// succeeds, otherwise a FileMaker error will be thrown.
21///
22/// Ideally you just create simple layout with a single field and a single record.
23pub struct ScriptLayoutContext {
24    layout: String,
25    search_field: String,
26    search_value: String,
27}
28
29impl ScriptLayoutContext {
30    /// Creates a new script layout context.
31    ///
32    /// # Examples
33    ///
34    /// ```
35    /// use fm_script_client::data_api::ScriptLayoutContext;
36    ///
37    /// let context = ScriptLayoutContext::new(
38    ///     "script_layout",
39    ///     "id",
40    ///     "1",
41    /// );
42    /// ```
43    pub fn new(layout: &str, search_field: &str, search_value: &str) -> Self {
44        Self {
45            layout: layout.to_string(),
46            search_field: search_field.to_string(),
47            search_value: search_value.to_string(),
48        }
49    }
50}
51
52/// Data API script client.
53///
54/// The Data API script client should only be used if the OData API is not available or cannot be
55/// used for other reasons. Otherwise, you should use the
56/// [`crate::odata_api::ODataApiScriptClient`].
57///
58/// When using the Data API client, you must specify a [`ScriptLayoutContext`] in order to execute
59/// script calls. See its documentation for further details.
60pub struct DataApiScriptClient {
61    connection: Arc<Connection>,
62    context: Arc<ScriptLayoutContext>,
63    client: Client,
64    token: Mutex<Option<Token>>,
65}
66
67impl DataApiScriptClient {
68    /// Creates a new Data API script client.
69    ///
70    /// # Examples
71    ///
72    /// ```
73    /// use fm_script_client::Connection;
74    /// use fm_script_client::data_api::{DataApiScriptClient, ScriptLayoutContext};
75    ///
76    /// let client = DataApiScriptClient::new(
77    ///     "https://foo:bar@example.com/example_database".try_into().unwrap(),
78    ///     ScriptLayoutContext::new("script_layout", "id", "1"),
79    /// );
80    /// ```
81    pub fn new(connection: Connection, context: ScriptLayoutContext) -> Self {
82        Self {
83            connection: Arc::new(connection),
84            context: Arc::new(context),
85            client: Client::new(),
86            token: Mutex::new(None),
87        }
88    }
89
90    /// Releases the currently used token.
91    ///
92    /// If the client has no token registered at the moment, it will return immediately. Otherwise,
93    /// it will issue a `DELETE` against the FileMaker Data API and forget the token.
94    pub async fn release_token(&self) -> Result<(), Error> {
95        let token = match self.token.lock().await.take() {
96            Some(token) => token,
97            None => return Ok(()),
98        };
99
100        let url = self.create_url(&format!("/sessions/{}", token.token))?;
101        self.client.delete(url).send().await?;
102
103        Ok(())
104    }
105
106    async fn get_token(&self) -> Result<String, Error> {
107        let mut token = self.token.lock().await;
108        let now = Instant::now();
109
110        if let Some(ref mut token) = *token {
111            token.expiry = now + Duration::from_secs(60 * 14);
112
113            if token.expiry < now {
114                return Ok(token.token.clone());
115            }
116        }
117
118        let url = self.create_url("/sessions")?;
119        let response = self
120            .client
121            .post(url)
122            .basic_auth(&self.connection.username, Some(&self.connection.password))
123            .send()
124            .await?;
125
126        if response.status().is_success() {
127            let access_token = match response.headers().get("X-FM-Data-Access-Token") {
128                Some(token) => match token.to_str() {
129                    Ok(token) => token.to_string(),
130                    Err(_) => return Err(Error::MissingAccessToken),
131                },
132                None => return Err(Error::MissingAccessToken),
133            };
134
135            *token = Some(Token {
136                token: access_token.clone(),
137                expiry: now + Duration::from_secs(60 * 14),
138            });
139
140            return Ok(access_token);
141        }
142
143        Err(self.error_from_response(response).await)
144    }
145
146    async fn error_from_response(&self, response: Response) -> Error {
147        let status = response.status();
148
149        match response.json::<ErrorResponseBody>().await {
150            Ok(result) => {
151                if let Some(error) = result.messages.into_iter().next() {
152                    Error::FileMaker(error)
153                } else {
154                    Error::UnknownResponse(status)
155                }
156            }
157            Err(_) => Error::UnknownResponse(status),
158        }
159    }
160
161    fn create_url(&self, path: &str) -> Result<Url, Error> {
162        let mut url = Url::parse(&format!(
163            "{}://{}/fmi/data/v1/databases/{}{}",
164            if self.connection.disable_tls {
165                "http"
166            } else {
167                "https"
168            },
169            self.connection.hostname,
170            self.connection.database,
171            path
172        ))?;
173
174        if let Some(port) = self.connection.port {
175            let _ = url.set_port(Some(port));
176        }
177
178        Ok(url)
179    }
180}
181
182#[derive(Debug)]
183struct Token {
184    token: String,
185    expiry: Instant,
186}
187
188#[derive(Debug, Serialize)]
189#[serde(rename_all = "camelCase")]
190struct RequestBody<T> {
191    query: HashMap<String, String>,
192    limit: u8,
193    script: String,
194    #[serde(skip_serializing_if = "Option::is_none", rename = "script.param")]
195    script_param: Option<T>,
196}
197
198#[derive(Debug, Deserialize)]
199#[serde(rename_all = "camelCase")]
200struct ResponseBody {
201    script_result: String,
202    script_error: String,
203}
204
205#[derive(Debug, Deserialize)]
206struct ErrorResponseBody {
207    messages: Vec<FileMakerError>,
208}
209
210#[async_trait]
211impl ScriptClient for DataApiScriptClient {
212    async fn execute<T: DeserializeOwned, P: Serialize + Send + Sync>(
213        &self,
214        script_name: impl Into<String> + Send,
215        parameter: Option<P>,
216    ) -> Result<T, Error> {
217        let token = self.get_token().await?;
218        let url = self.create_url(&format!("/layouts/{}/_find", self.context.layout))?;
219
220        let mut query = HashMap::new();
221        query.insert(
222            self.context.search_field.clone(),
223            self.context.search_value.clone(),
224        );
225
226        let body = RequestBody {
227            query,
228            limit: 1,
229            script: script_name.into(),
230            script_param: Some(serde_json::to_string(&parameter)?),
231        };
232
233        let response = self
234            .client
235            .post(url)
236            .header("Authorization", format!("Bearer {}", &token))
237            .header("Content-Type", "application/json")
238            .header("Accept", "application/json")
239            .json(&body)
240            .send()
241            .await?;
242
243        let status = response.status();
244
245        if status.is_success() {
246            let result: ResponseBody = response.json().await?;
247
248            if result.script_error != "0" {
249                return Err(Error::ScriptFailure {
250                    code: result.script_error.parse().unwrap_or(-1),
251                    data: result.script_result,
252                });
253            }
254
255            let result: T = serde_json::from_str(&result.script_result)?;
256            return Ok(result);
257        }
258
259        Err(self.error_from_response(response).await)
260    }
261}