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}