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 .send()
124 .await?;
125
126 if response.status().is_success() {
127 let access_token = match response.headers().get("X-FM-Data-Access-Token") {
128 Some(token) => match token.to_str() {
129 Ok(token) => token.to_string(),
130 Err(_) => return Err(Error::MissingAccessToken),
131 },
132 None => return Err(Error::MissingAccessToken),
133 };
134
135 *token = Some(Token {
136 token: access_token.clone(),
137 expiry: now + Duration::from_secs(60 * 14),
138 });
139
140 return Ok(access_token);
141 }
142
143 Err(self.error_from_response(response).await)
144 }
145
146 async fn error_from_response(&self, response: Response) -> Error {
147 let status = response.status();
148
149 match response.json::<ErrorResponseBody>().await {
150 Ok(result) => {
151 if let Some(error) = result.messages.into_iter().next() {
152 Error::FileMaker(error)
153 } else {
154 Error::UnknownResponse(status)
155 }
156 }
157 Err(_) => Error::UnknownResponse(status),
158 }
159 }
160
161 fn create_url(&self, path: &str) -> Result<Url, Error> {
162 let mut url = Url::parse(&format!(
163 "{}://{}/fmi/data/v1/databases/{}{}",
164 if self.connection.disable_tls {
165 "http"
166 } else {
167 "https"
168 },
169 self.connection.hostname,
170 self.connection.database,
171 path
172 ))?;
173
174 if let Some(port) = self.connection.port {
175 let _ = url.set_port(Some(port));
176 }
177
178 Ok(url)
179 }
180}
181
182#[derive(Debug)]
183struct Token {
184 token: String,
185 expiry: Instant,
186}
187
188#[derive(Debug, Serialize)]
189#[serde(rename_all = "camelCase")]
190struct RequestBody<T> {
191 query: HashMap<String, String>,
192 limit: u8,
193 script: String,
194 #[serde(skip_serializing_if = "Option::is_none", rename = "script.param")]
195 script_param: Option<T>,
196}
197
198#[derive(Debug, Deserialize)]
199#[serde(rename_all = "camelCase")]
200struct ResponseBody {
201 script_result: String,
202 script_error: String,
203}
204
205#[derive(Debug, Deserialize)]
206struct ErrorResponseBody {
207 messages: Vec<FileMakerError>,
208}
209
210#[async_trait]
211impl ScriptClient for DataApiScriptClient {
212 async fn execute<T: DeserializeOwned, P: Serialize + Send + Sync>(
213 &self,
214 script_name: impl Into<String> + Send,
215 parameter: Option<P>,
216 ) -> Result<T, Error> {
217 let token = self.get_token().await?;
218 let url = self.create_url(&format!("/layouts/{}/_find", self.context.layout))?;
219
220 let mut query = HashMap::new();
221 query.insert(
222 self.context.search_field.clone(),
223 self.context.search_value.clone(),
224 );
225
226 let body = RequestBody {
227 query,
228 limit: 1,
229 script: script_name.into(),
230 script_param: Some(serde_json::to_string(¶meter)?),
231 };
232
233 let response = self
234 .client
235 .post(url)
236 .header("Authorization", format!("Bearer {}", &token))
237 .header("Content-Type", "application/json")
238 .header("Accept", "application/json")
239 .json(&body)
240 .send()
241 .await?;
242
243 let status = response.status();
244
245 if status.is_success() {
246 let result: ResponseBody = response.json().await?;
247
248 if result.script_error != "0" {
249 return Err(Error::ScriptFailure {
250 code: result.script_error.parse().unwrap_or(-1),
251 data: result.script_result,
252 });
253 }
254
255 let result: T = serde_json::from_str(&result.script_result)?;
256 return Ok(result);
257 }
258
259 Err(self.error_from_response(response).await)
260 }
261}