fm_script_client/
lib.rs

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