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