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}