fm_script_client/
data_api.rs1use crate::{Connection, Error, FileMakerError, ScriptClient};
2use async_trait::async_trait;
3use reqwest::{Client, Response};
4use serde::de::DeserializeOwned;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9use tokio::sync::Mutex;
10use url::Url;
11
12pub struct ScriptLayoutContext {
24 layout: String,
25 search_field: String,
26 search_value: String,
27}
28
29impl ScriptLayoutContext {
30 pub fn new(layout: &str, search_field: &str, search_value: &str) -> Self {
44 Self {
45 layout: layout.to_string(),
46 search_field: search_field.to_string(),
47 search_value: search_value.to_string(),
48 }
49 }
50}
51
52pub struct DataApiScriptClient {
61 connection: Arc<Connection>,
62 context: Arc<ScriptLayoutContext>,
63 client: Client,
64 token: Mutex<Option<Token>>,
65}
66
67impl DataApiScriptClient {
68 pub fn new(connection: Connection, context: ScriptLayoutContext) -> Self {
82 Self {
83 connection: Arc::new(connection),
84 context: Arc::new(context),
85 client: Client::new(),
86 token: Mutex::new(None),
87 }
88 }
89
90 pub async fn release_token(&self) -> Result<(), Error> {
95 let token = match self.token.lock().await.take() {
96 Some(token) => token,
97 None => return Ok(()),
98 };
99
100 let url = self.create_url(&format!("/sessions/{}", token.token))?;
101 self.client.delete(url).send().await?;
102
103 Ok(())
104 }
105
106 async fn get_token(&self) -> Result<String, Error> {
107 let mut token = self.token.lock().await;
108 let now = Instant::now();
109
110 if let Some(ref mut token) = *token {
111 token.expiry = now + Duration::from_secs(60 * 14);
112
113 if token.expiry < now {
114 return Ok(token.token.clone());
115 }
116 }
117
118 let url = self.create_url("/sessions")?;
119 let response = self
120 .client
121 .post(url)
122 .basic_auth(&self.connection.username, Some(&self.connection.password))
123 .header("Content-Type", "application/json")
124 .body("{}")
125 .send()
126 .await?;
127
128 if response.status().is_success() {
129 let access_token = match response.headers().get("X-FM-Data-Access-Token") {
130 Some(token) => match token.to_str() {
131 Ok(token) => token.to_string(),
132 Err(_) => return Err(Error::MissingAccessToken),
133 },
134 None => return Err(Error::MissingAccessToken),
135 };
136
137 *token = Some(Token {
138 token: access_token.clone(),
139 expiry: now + Duration::from_secs(60 * 14),
140 });
141
142 return Ok(access_token);
143 }
144
145 Err(self.error_from_response(response).await)
146 }
147
148 async fn error_from_response(&self, response: Response) -> Error {
149 let status = response.status();
150
151 match response.json::<ErrorResponseBody>().await {
152 Ok(result) => {
153 if let Some(error) = result.messages.into_iter().next() {
154 Error::FileMaker(error)
155 } else {
156 Error::UnknownResponse(status)
157 }
158 }
159 Err(_) => Error::UnknownResponse(status),
160 }
161 }
162
163 fn create_url(&self, path: &str) -> Result<Url, Error> {
164 let mut url = Url::parse(&format!(
165 "{}://{}/fmi/data/v1/databases/{}{}",
166 if self.connection.disable_tls {
167 "http"
168 } else {
169 "https"
170 },
171 self.connection.hostname,
172 self.connection.database,
173 path
174 ))?;
175
176 if let Some(port) = self.connection.port {
177 let _ = url.set_port(Some(port));
178 }
179
180 Ok(url)
181 }
182}
183
184#[derive(Debug)]
185struct Token {
186 token: String,
187 expiry: Instant,
188}
189
190#[derive(Debug, Serialize)]
191#[serde(rename_all = "camelCase")]
192struct RequestBody<T> {
193 query: Vec<HashMap<String, String>>,
194 limit: u8,
195 script: String,
196 #[serde(skip_serializing_if = "Option::is_none", rename = "script.param")]
197 script_param: Option<T>,
198}
199
200#[derive(Debug, Deserialize)]
201#[serde(rename_all = "camelCase")]
202struct ResponseBody {
203 script_result: String,
204 script_error: String,
205}
206
207#[derive(Debug, Deserialize)]
208struct ErrorResponseBody {
209 messages: Vec<FileMakerError>,
210}
211
212#[async_trait]
213impl ScriptClient for DataApiScriptClient {
214 async fn execute<T: DeserializeOwned, P: Serialize + Send + Sync>(
215 &self,
216 script_name: impl Into<String> + Send,
217 parameter: Option<P>,
218 ) -> Result<T, Error> {
219 let token = self.get_token().await?;
220 let url = self.create_url(&format!("/layouts/{}/_find", self.context.layout))?;
221
222 let mut query = HashMap::new();
223 query.insert(
224 self.context.search_field.clone(),
225 self.context.search_value.clone(),
226 );
227
228 let body = RequestBody {
229 query: vec![query],
230 limit: 1,
231 script: script_name.into(),
232 script_param: Some(serde_json::to_string(¶meter)?),
233 };
234
235 let response = self
236 .client
237 .post(url)
238 .header("Authorization", format!("Bearer {}", &token))
239 .header("Content-Type", "application/json")
240 .header("Accept", "application/json")
241 .json(&body)
242 .send()
243 .await?;
244
245 let status = response.status();
246
247 if status.is_success() {
248 let result: ResponseBody = response.json().await?;
249
250 if result.script_error != "0" {
251 return Err(Error::ScriptFailure {
252 code: result.script_error.parse().unwrap_or(-1),
253 data: result.script_result,
254 });
255 }
256
257 let result: T = serde_json::from_str(&result.script_result)?;
258 return Ok(result);
259 }
260
261 Err(self.error_from_response(response).await)
262 }
263}