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            .header("Content-Type", "application/json")
124            .body("{}")
125            .send()
126            .await?;
127
128        if response.status().is_success() {
129            let access_token = match response.headers().get("X-FM-Data-Access-Token") {
130                Some(token) => match token.to_str() {
131                    Ok(token) => token.to_string(),
132                    Err(_) => return Err(Error::MissingAccessToken),
133                },
134                None => return Err(Error::MissingAccessToken),
135            };
136
137            *token = Some(Token {
138                token: access_token.clone(),
139                expiry: now + Duration::from_secs(60 * 14),
140            });
141
142            return Ok(access_token);
143        }
144
145        Err(self.error_from_response(response).await)
146    }
147
148    async fn error_from_response(&self, response: Response) -> Error {
149        let status = response.status();
150
151        match response.json::<ErrorResponseBody>().await {
152            Ok(result) => {
153                if let Some(error) = result.messages.into_iter().next() {
154                    Error::FileMaker(error)
155                } else {
156                    Error::UnknownResponse(status)
157                }
158            }
159            Err(_) => Error::UnknownResponse(status),
160        }
161    }
162
163    fn create_url(&self, path: &str) -> Result<Url, Error> {
164        let mut url = Url::parse(&format!(
165            "{}://{}/fmi/data/v1/databases/{}{}",
166            if self.connection.disable_tls {
167                "http"
168            } else {
169                "https"
170            },
171            self.connection.hostname,
172            self.connection.database,
173            path
174        ))?;
175
176        if let Some(port) = self.connection.port {
177            let _ = url.set_port(Some(port));
178        }
179
180        Ok(url)
181    }
182}
183
184#[derive(Debug)]
185struct Token {
186    token: String,
187    expiry: Instant,
188}
189
190#[derive(Debug, Serialize)]
191#[serde(rename_all = "camelCase")]
192struct RequestBody<T> {
193    query: Vec<HashMap<String, String>>,
194    limit: u8,
195    script: String,
196    #[serde(skip_serializing_if = "Option::is_none", rename = "script.param")]
197    script_param: Option<T>,
198}
199
200#[derive(Debug, Deserialize)]
201#[serde(rename_all = "camelCase")]
202struct ResponseBody {
203    script_result: String,
204    script_error: String,
205}
206
207#[derive(Debug, Deserialize)]
208struct ErrorResponseBody {
209    messages: Vec<FileMakerError>,
210}
211
212#[async_trait]
213impl ScriptClient for DataApiScriptClient {
214    async fn execute<T: DeserializeOwned, P: Serialize + Send + Sync>(
215        &self,
216        script_name: impl Into<String> + Send,
217        parameter: Option<P>,
218    ) -> Result<T, Error> {
219        let token = self.get_token().await?;
220        let url = self.create_url(&format!("/layouts/{}/_find", self.context.layout))?;
221
222        let mut query = HashMap::new();
223        query.insert(
224            self.context.search_field.clone(),
225            self.context.search_value.clone(),
226        );
227
228        let body = RequestBody {
229            query: vec![query],
230            limit: 1,
231            script: script_name.into(),
232            script_param: Some(serde_json::to_string(&parameter)?),
233        };
234
235        let response = self
236            .client
237            .post(url)
238            .header("Authorization", format!("Bearer {}", &token))
239            .header("Content-Type", "application/json")
240            .header("Accept", "application/json")
241            .json(&body)
242            .send()
243            .await?;
244
245        let status = response.status();
246
247        if status.is_success() {
248            let result: ResponseBody = response.json().await?;
249
250            if result.script_error != "0" {
251                return Err(Error::ScriptFailure {
252                    code: result.script_error.parse().unwrap_or(-1),
253                    data: result.script_result,
254                });
255            }
256
257            let result: T = serde_json::from_str(&result.script_result)?;
258            return Ok(result);
259        }
260
261        Err(self.error_from_response(response).await)
262    }
263}