fm_script_client/
lib.rs

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