fm_script_client/
lib.rs

1use crate::result::ScriptResultDeserialize;
2use async_trait::async_trait;
3use percent_encoding::percent_decode_str;
4use reqwest::StatusCode;
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7use url::Url;
8
9pub mod data_api;
10pub mod odata_api;
11pub mod result;
12
13#[async_trait]
14pub trait ScriptClient {
15    /// Executes a script with an optional parameter.
16    ///
17    /// Parameters must be serializable and results deserializable through `serde`.
18    ///
19    /// # Examples
20    ///
21    /// ```
22    /// use fm_script_client::{ScriptClient, Connection, odata_api::ODataApiScriptClient};
23    /// use serde::Deserialize;
24    ///
25    /// #[derive(Deserialize)]
26    /// struct Result {
27    ///     success: bool,
28    /// }
29    ///
30    /// #[tokio::main]
31    /// async fn main() {
32    ///     # let mut server = mockito::Server::new_async().await;
33    ///     # #[cfg(not(doc))]
34    ///     # let connection: Connection = format!(
35    ///     #     "http://foo:bar@{}/test",
36    ///     #     server.host_with_port()
37    ///     # ).as_str().try_into().unwrap();
38    ///     # let mock = server
39    ///     #     .mock("POST", "/fmi/odata/v4/test/Script.my_script")
40    ///     #     .match_header("content-length", "36")
41    ///     #     .with_body(serde_json::json!({
42    ///     #         "scriptResult": {
43    ///     #             "code": 0,
44    ///     #             "resultParameter": {"success": true},
45    ///     #         },
46    ///     #     }).to_string())
47    ///     #     .create_async()
48    ///     #     .await;
49    ///     # #[cfg(doc)]
50    ///     let connection: Connection = "http://foo:bar@localhost:9999/test"
51    ///         .try_into()
52    ///         .unwrap();
53    ///
54    ///     let client = ODataApiScriptClient::new(connection);
55    ///     let result: Result = client.execute("my_script", Some("parameter")).await.unwrap();
56    ///     assert_eq!(result.success, true);
57    /// }
58    /// ```
59    async fn execute<T: ScriptResultDeserialize, P: Serialize + Send + Sync>(
60        &self,
61        script_name: impl Into<String> + Send,
62        parameter: Option<P>,
63    ) -> Result<T, Error>;
64
65    /// Convenience method to execute a script without a parameter.
66    async fn execute_without_parameter<T: ScriptResultDeserialize>(
67        &self,
68        script_name: &str,
69    ) -> Result<T, Error> {
70        self.execute::<T, ()>(script_name, None).await
71    }
72}
73
74#[derive(Debug, Error)]
75pub enum Error {
76    #[error("Failed to parse URL")]
77    Url(#[from] url::ParseError),
78
79    #[error("Failed to perform request")]
80    Request(#[from] reqwest::Error),
81
82    #[error("Failed to (de)serialize JSON")]
83    SerdeJson(#[from] serde_json::Error),
84
85    #[error("FileMaker returned an error")]
86    FileMaker(FileMakerError),
87
88    #[error("FileMaker script returned an error")]
89    ScriptFailure { code: i64, data: Option<String> },
90
91    #[error("FileMaker did not respond with an access token")]
92    MissingAccessToken,
93
94    #[error("Received an unknown response")]
95    UnknownResponse(StatusCode),
96
97    #[error("Invalid connection URL")]
98    InvalidConnectionUrl,
99}
100
101#[derive(Debug, Deserialize)]
102pub struct FileMakerError {
103    pub code: String,
104    pub message: String,
105}
106
107/// Connection details for script clients.
108///
109/// Defines the credentials, hostname, and database to connect to.
110#[derive(Debug, Clone)]
111pub struct Connection {
112    hostname: String,
113    database: String,
114    username: String,
115    password: String,
116    port: Option<u16>,
117    disable_tls: bool,
118}
119
120impl Connection {
121    /// Creates a new connection.
122    ///
123    /// Will use the HTTPS by default, unless changes.
124    ///
125    /// # Examples
126    ///
127    /// ```
128    /// use fm_script_client::Connection;
129    ///
130    /// let connection = Connection::new("example.com", "test_sb", "foo", "bar");
131    /// ```
132    pub fn new(
133        hostname: impl Into<String>,
134        database: impl Into<String>,
135        username: impl Into<String>,
136        password: impl Into<String>,
137    ) -> Connection {
138        Self {
139            hostname: hostname.into(),
140            database: database.into(),
141            username: username.into(),
142            password: password.into(),
143            port: None,
144            disable_tls: false,
145        }
146    }
147
148    /// Configures an alternative port to use.
149    pub fn with_port(mut self, port: Option<u16>) -> Self {
150        self.port = port;
151        self
152    }
153
154    /// Disables TLS which forces the client to fall back to HTTP.
155    pub fn without_tls(mut self, disable_tls: bool) -> Self {
156        self.disable_tls = disable_tls;
157        self
158    }
159}
160
161impl TryFrom<Url> for Connection {
162    type Error = Error;
163
164    /// Converts a [`Url`] into a [`Connection`].
165    ///
166    /// URLs must contain a hostname, username and password, as well as a database as the path
167    /// portion.
168    ///
169    /// # Examples
170    ///
171    /// ```
172    /// use fm_script_client::Connection;
173    /// use url::Url;
174    ///
175    /// let connection: Connection = Url::parse("https://username:password@example.com/database")
176    ///     .unwrap()
177    ///     .try_into()
178    ///     .unwrap();
179    /// ```
180    fn try_from(url: Url) -> Result<Self, Self::Error> {
181        let decode = |value: &str| -> Result<String, Error> {
182            Ok(percent_decode_str(value)
183                .decode_utf8()
184                .map_err(|_| Error::InvalidConnectionUrl)?
185                .to_string())
186        };
187
188        Ok(Connection {
189            hostname: decode(url.host_str().ok_or_else(|| Error::InvalidConnectionUrl)?)?,
190            database: decode(&url.path()[1..])?,
191            username: decode(url.username())?,
192            password: decode(url.password().ok_or_else(|| Error::InvalidConnectionUrl)?)?,
193            port: url.port(),
194            disable_tls: url.scheme() == "http",
195        })
196    }
197}
198
199impl TryFrom<&str> for Connection {
200    type Error = Error;
201
202    /// Converts a `&str` into a [`Connection`].
203    ///
204    /// Connection strings must follow this format:
205    ///
206    /// `https://username:password@example.com/database`
207    ///
208    /// # Examples
209    ///
210    /// ```
211    /// use fm_script_client::Connection;
212    ///
213    /// let connection: Connection = "https://username:password@example.com/database"
214    ///     .try_into()
215    ///     .unwrap();
216    /// ```
217    fn try_from(url: &str) -> Result<Self, Self::Error> {
218        Url::parse(url)?.try_into()
219    }
220}
221
222impl TryFrom<String> for Connection {
223    type Error = Error;
224
225    /// Converts a `String` into a [`Connection`].
226    ///
227    /// Connection strings must follow this format:
228    ///
229    /// `https://username:password@example.com/database`
230    ///
231    /// # Examples
232    ///
233    /// ```
234    /// use fm_script_client::Connection;
235    ///
236    /// let connection: Connection = "https://username:password@example.com/database".to_string()
237    ///     .try_into()
238    ///     .unwrap();
239    /// ```
240    fn try_from(url: String) -> Result<Self, Self::Error> {
241        Url::parse(&url)?.try_into()
242    }
243}