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}