1use async_trait::async_trait;
15use base64::Engine;
16use fslite_core::{
17 BatchResult, ByteRange, ErrorCode, FsError, FsResult, LinkTarget, Node, Page, PageRequest,
18 RequestContext, Revision, SearchMatch, VirtualPath, WorkspaceId,
19};
20use reqwest::{Client, StatusCode};
21use serde::de::DeserializeOwned;
22
23use crate::executor::Executor;
24use crate::{Command, CommandOutput};
25
26pub struct RemoteExecutor {
28 base_url: String,
29 token: String,
30 client: Client,
31}
32
33impl RemoteExecutor {
34 pub fn new(base_url: impl Into<String>, token: impl Into<String>) -> Self {
37 Self {
38 base_url: base_url.into(),
39 token: token.into(),
40 client: Client::new(),
41 }
42 }
43
44 fn url(&self, workspace_id: WorkspaceId, suffix: &str) -> String {
45 format!("{}/v1/workspaces/{workspace_id}{suffix}", self.base_url)
46 }
47
48 async fn send_checked(&self, builder: reqwest::RequestBuilder) -> FsResult<reqwest::Response> {
51 let response = builder
52 .bearer_auth(&self.token)
53 .send()
54 .await
55 .map_err(map_reqwest_err)?;
56 if response.status().is_success() {
57 Ok(response)
58 } else {
59 Err(Self::error_from_response(response).await)
60 }
61 }
62
63 async fn send_json<T: DeserializeOwned>(
65 &self,
66 builder: reqwest::RequestBuilder,
67 ) -> FsResult<T> {
68 let response = self.send_checked(builder).await?;
69 response.json::<T>().await.map_err(map_reqwest_err)
70 }
71
72 async fn stat(
75 &self,
76 ctx: &RequestContext,
77 path: &VirtualPath,
78 follow_symlinks: bool,
79 ) -> FsResult<Node> {
80 let builder = self
81 .client
82 .get(self.url(ctx.workspace_id, &format!("/fs{}", path.as_str())))
83 .query(&[("follow_symlinks", follow_symlinks.to_string())]);
84 self.send_json(builder).await
85 }
86
87 async fn error_from_response(response: reqwest::Response) -> FsError {
88 #[derive(serde::Deserialize)]
89 struct Envelope {
90 error: ErrorBody,
91 }
92 #[derive(serde::Deserialize)]
93 struct ErrorBody {
94 code: String,
95 message: String,
96 details: serde_json::Value,
97 }
98
99 let status = response.status();
100 match response.json::<Envelope>().await {
101 Ok(envelope) => {
102 let code = code_from_str(&envelope.error.code);
103 FsError::new(code, envelope.error.message, envelope.error.details)
104 }
105 Err(_) => FsError::internal_storage_failure(format!(
106 "unrecognized error response, status {status}"
107 )),
108 }
109 }
110}
111
112fn map_reqwest_err(err: reqwest::Error) -> FsError {
113 FsError::internal_storage_failure(err.to_string())
114}
115
116fn code_from_str(raw: &str) -> ErrorCode {
117 serde_json::from_value(serde_json::Value::String(raw.to_string()))
120 .unwrap_or(ErrorCode::InternalStorageFailure)
121}
122
123fn revision_query(expected_revision: Option<Revision>) -> Vec<(&'static str, String)> {
124 match expected_revision {
125 Some(revision) => vec![("expected_revision", revision.get().to_string())],
126 None => Vec::new(),
127 }
128}
129
130fn page_query(page: &PageRequest) -> Vec<(&'static str, String)> {
131 let mut pairs = vec![("limit", page.limit.to_string())];
132 if let Some(cursor) = &page.cursor {
133 pairs.push(("cursor", cursor.clone()));
134 }
135 pairs
136}
137
138fn page_body(page: &PageRequest) -> serde_json::Value {
139 serde_json::json!({ "cursor": page.cursor, "limit": page.limit })
140}
141
142fn parse_content_range_bounds(header: &str) -> Option<(u64, u64)> {
145 let range = header.strip_prefix("bytes ")?;
146 let (range, _total) = range.split_once('/')?;
147 let (start, end_inclusive) = range.split_once('-')?;
148 let start: u64 = start.parse().ok()?;
149 let end_inclusive: u64 = end_inclusive.parse().ok()?;
150 Some((start, end_inclusive + 1))
151}
152
153#[derive(serde::Deserialize)]
155struct LinkTargetWire {
156 target: String,
157}
158
159#[derive(serde::Deserialize)]
163struct SearchMatchWire {
164 node: Node,
165 path: VirtualPath,
166 range: ByteRange,
167 preview_base64: String,
168}
169
170#[derive(serde::Deserialize)]
172struct BatchResponse {
173 results: Vec<BatchResult>,
174}
175
176#[async_trait]
177impl Executor for RemoteExecutor {
178 async fn execute(&self, ctx: &RequestContext, command: Command) -> FsResult<CommandOutput> {
179 match command {
180 Command::WorkspaceUsage => {
181 let builder = self.client.get(self.url(ctx.workspace_id, "/usage"));
182 Ok(CommandOutput::Usage(self.send_json(builder).await?))
183 }
184
185 Command::Stat { path, options } => Ok(CommandOutput::Node(
186 self.stat(ctx, &path, options.follow_symlinks).await?,
187 )),
188
189 Command::Exists { path, options } => {
190 match self.stat(ctx, &path, options.follow_symlinks).await {
203 Ok(_) => Ok(CommandOutput::Exists(true)),
204 Err(err) if err.code() == ErrorCode::NotFound => {
205 Ok(CommandOutput::Exists(false))
206 }
207 Err(err) => Err(err),
208 }
209 }
210
211 Command::ReadDir { path, page } => {
212 let builder = self
213 .client
214 .get(self.url(
215 ctx.workspace_id,
216 &format!("/directories{}/children", path.as_str()),
217 ))
218 .query(&page_query(&page));
219 Ok(CommandOutput::Nodes(self.send_json(builder).await?))
220 }
221
222 Command::Tree {
223 path,
224 options,
225 page,
226 } => {
227 let mut query = page_query(&page);
228 if let Some(max_depth) = options.max_depth {
229 query.push(("max_depth", max_depth.to_string()));
230 }
231 query.push(("follow_symlinks", options.follow_symlinks.to_string()));
232 let builder = self
233 .client
234 .get(self.url(
235 ctx.workspace_id,
236 &format!("/directories{}/tree", path.as_str()),
237 ))
238 .query(&query);
239 Ok(CommandOutput::Tree(self.send_json(builder).await?))
240 }
241
242 Command::Mkdir { path, options } => {
243 let body = serde_json::json!({
244 "parents": options.parents,
245 "exist_ok": options.exist_ok,
246 "expected_revision": options.expected_revision.map(Revision::get),
247 });
248 let builder = self
249 .client
250 .put(self.url(ctx.workspace_id, &format!("/fs{}", path.as_str())))
251 .query(&[("type", "directory")])
252 .json(&body);
253 Ok(CommandOutput::Node(self.send_json(builder).await?))
254 }
255
256 Command::Read { path, options } => {
257 if !options.follow_symlinks {
272 return Err(FsError::internal_storage_failure(
273 "RemoteExecutor cannot honor follow_symlinks=false for read: \
274 fslite-server's content route always follows symlinks",
275 ));
276 }
277 let node = self.stat(ctx, &path, true).await?;
278 let mut builder = self
279 .client
280 .get(self.url(ctx.workspace_id, &format!("/content{}", path.as_str())));
281 if let Some(range) = options.range {
282 builder = builder.header(
283 reqwest::header::RANGE,
284 format!("bytes={}-{}", range.start, range.end.saturating_sub(1)),
285 );
286 }
287 let response = builder
288 .bearer_auth(&self.token)
289 .send()
290 .await
291 .map_err(map_reqwest_err)?;
292 if response.status() == StatusCode::RANGE_NOT_SATISFIABLE {
304 return Err(FsError::invalid_range(format!(
305 "range not satisfiable for {}",
306 path.as_str()
307 )));
308 }
309 if !response.status().is_success() {
310 return Err(Self::error_from_response(response).await);
311 }
312 let content_range_bounds = response
313 .headers()
314 .get(reqwest::header::CONTENT_RANGE)
315 .and_then(|value| value.to_str().ok())
316 .and_then(parse_content_range_bounds);
317 let bytes = response.bytes().await.map_err(map_reqwest_err)?.to_vec();
318 let range = content_range_bounds
319 .map(|(start, end)| ByteRange::new(start, end))
320 .unwrap_or_else(|| ByteRange::new(0, bytes.len() as u64));
321 Ok(CommandOutput::Content {
322 logical_length: node.logical_size,
323 revision: node.revision,
324 range,
325 bytes,
326 })
327 }
328
329 Command::Write {
330 path,
331 bytes,
332 options,
333 } => {
334 let mut query = vec![("create", options.create.to_string())];
335 query.extend(revision_query(options.expected_revision));
336 let builder = self
337 .client
338 .put(self.url(ctx.workspace_id, &format!("/content{}", path.as_str())))
339 .query(&query)
340 .body(bytes);
341 Ok(CommandOutput::Node(self.send_json(builder).await?))
342 }
343
344 Command::WriteAt {
345 path,
346 offset,
347 bytes,
348 options,
349 } => {
350 let mut query = vec![("offset", offset.to_string())];
351 query.extend(revision_query(options.expected_revision));
352 let builder = self
353 .client
354 .patch(self.url(ctx.workspace_id, &format!("/content{}", path.as_str())))
355 .query(&query)
356 .body(bytes);
357 Ok(CommandOutput::Node(self.send_json(builder).await?))
358 }
359
360 Command::Append {
361 path,
362 bytes,
363 options,
364 } => {
365 let mut query = vec![("action", "append".to_string())];
366 query.extend(revision_query(options.expected_revision));
367 let builder = self
368 .client
369 .post(self.url(ctx.workspace_id, &format!("/content{}", path.as_str())))
370 .query(&query)
371 .body(bytes);
372 Ok(CommandOutput::Node(self.send_json(builder).await?))
373 }
374
375 Command::Truncate {
376 path,
377 length,
378 options,
379 } => {
380 let body = serde_json::json!({
381 "length": length,
382 "expected_revision": options.expected_revision.map(Revision::get),
383 });
384 let builder = self
385 .client
386 .post(self.url(ctx.workspace_id, &format!("/content{}", path.as_str())))
387 .query(&[("action", "truncate")])
388 .json(&body);
389 Ok(CommandOutput::Node(self.send_json(builder).await?))
390 }
391
392 Command::Touch { path, options } => {
393 let body = serde_json::json!({
394 "op": "touch",
395 "create": options.create,
396 "expected_revision": options.expected_revision.map(Revision::get),
397 });
398 let builder = self
399 .client
400 .patch(self.url(ctx.workspace_id, &format!("/fs{}", path.as_str())))
401 .json(&body);
402 Ok(CommandOutput::Node(self.send_json(builder).await?))
403 }
404
405 Command::Copy { from, to, options } => {
406 let body = serde_json::json!({
407 "to": to.as_str(),
408 "recursive": options.recursive,
409 "overwrite": options.overwrite,
410 "expected_revision": options.expected_revision.map(Revision::get),
411 });
412 let builder = self
413 .client
414 .post(self.url(ctx.workspace_id, &format!("/fs{}", from.as_str())))
415 .query(&[("action", "copy")])
416 .json(&body);
417 Ok(CommandOutput::Node(self.send_json(builder).await?))
418 }
419
420 Command::Move { from, to, options } => {
421 let body = serde_json::json!({
422 "to": to.as_str(),
423 "overwrite": options.overwrite,
424 "expected_revision": options.expected_revision.map(Revision::get),
425 });
426 let builder = self
427 .client
428 .post(self.url(ctx.workspace_id, &format!("/fs{}", from.as_str())))
429 .query(&[("action", "move")])
430 .json(&body);
431 Ok(CommandOutput::Node(self.send_json(builder).await?))
432 }
433
434 Command::Remove { path, options } => {
435 let mut query = vec![("recursive", options.recursive.to_string())];
436 query.extend(revision_query(options.expected_revision));
437 let builder = self
438 .client
439 .delete(self.url(ctx.workspace_id, &format!("/fs{}", path.as_str())))
440 .query(&query);
441 self.send_checked(builder).await?;
442 Ok(CommandOutput::Unit)
443 }
444
445 Command::Symlink {
446 target,
447 link,
448 options,
449 } => {
450 let body = serde_json::json!({
451 "target": target.as_str(),
452 "parents": options.parents,
453 "exist_ok": options.exist_ok,
454 "expected_revision": options.expected_revision.map(Revision::get),
455 });
456 let builder = self
457 .client
458 .put(self.url(ctx.workspace_id, &format!("/fs{}", link.as_str())))
459 .query(&[("type", "symlink")])
460 .json(&body);
461 Ok(CommandOutput::Node(self.send_json(builder).await?))
462 }
463
464 Command::ReadLink { path } => {
465 let builder = self.client.get(self.url(
466 ctx.workspace_id,
467 &format!("/fs{}/link-target", path.as_str()),
468 ));
469 let wire: LinkTargetWire = self.send_json(builder).await?;
470 let target = LinkTarget::parse(&wire.target)
471 .map_err(|err| FsError::internal_storage_failure(err.message().to_string()))?;
472 Ok(CommandOutput::LinkTarget(target))
473 }
474
475 Command::Trash { path, options } => {
476 let body = serde_json::json!({
477 "expected_revision": options.expected_revision.map(Revision::get),
478 });
479 let builder = self
480 .client
481 .post(self.url(ctx.workspace_id, &format!("/fs{}", path.as_str())))
482 .query(&[("action", "trash")])
483 .json(&body);
484 Ok(CommandOutput::Trash(self.send_json(builder).await?))
485 }
486
487 Command::ListTrash { page } => {
488 let builder = self
489 .client
490 .get(self.url(ctx.workspace_id, "/trash"))
491 .query(&page_query(&page));
492 Ok(CommandOutput::TrashList(self.send_json(builder).await?))
493 }
494
495 Command::Restore {
496 trash,
497 destination,
498 options,
499 } => {
500 let body = serde_json::json!({
501 "destination": destination.as_ref().map(VirtualPath::as_str),
502 "expected_revision": options.expected_revision.map(Revision::get),
503 });
504 let builder = self
505 .client
506 .post(self.url(ctx.workspace_id, &format!("/trash/{trash}/restore")))
507 .json(&body);
508 Ok(CommandOutput::Node(self.send_json(builder).await?))
509 }
510
511 Command::Purge { trash } => {
512 let builder = self
513 .client
514 .delete(self.url(ctx.workspace_id, &format!("/trash/{trash}")));
515 self.send_checked(builder).await?;
516 Ok(CommandOutput::Unit)
517 }
518
519 Command::SetAttribute {
520 path,
521 key,
522 value,
523 options,
524 } => {
525 let body = serde_json::json!({
526 "op": "set_attribute",
527 "key": key,
528 "value_base64": base64::engine::general_purpose::STANDARD.encode(&value),
529 "expected_revision": options.expected_revision.map(Revision::get),
530 });
531 let builder = self
532 .client
533 .patch(self.url(ctx.workspace_id, &format!("/fs{}", path.as_str())))
534 .json(&body);
535 Ok(CommandOutput::Node(self.send_json(builder).await?))
536 }
537
538 Command::RemoveAttribute { path, key, options } => {
539 let body = serde_json::json!({
540 "op": "remove_attribute",
541 "key": key,
542 "expected_revision": options.expected_revision.map(Revision::get),
543 });
544 let builder = self
545 .client
546 .patch(self.url(ctx.workspace_id, &format!("/fs{}", path.as_str())))
547 .json(&body);
548 Ok(CommandOutput::Node(self.send_json(builder).await?))
549 }
550
551 Command::Glob { pattern, page } => {
552 let mut query = vec![("pattern", pattern)];
553 query.extend(page_query(&page));
554 let builder = self
555 .client
556 .get(self.url(ctx.workspace_id, "/search/glob"))
557 .query(&query);
558 Ok(CommandOutput::Nodes(self.send_json(builder).await?))
559 }
560
561 Command::Find { query, page } => {
562 let body = serde_json::json!({ "query": query, "page": page_body(&page) });
563 let builder = self
564 .client
565 .post(self.url(ctx.workspace_id, "/search/find"))
566 .json(&body);
567 Ok(CommandOutput::Nodes(self.send_json(builder).await?))
568 }
569
570 Command::SearchContent { query, page } => {
571 let body = serde_json::json!({
572 "root": query.root,
573 "needle_base64": base64::engine::general_purpose::STANDARD.encode(&query.needle),
574 "page": page_body(&page),
575 });
576 let builder = self
577 .client
578 .post(self.url(ctx.workspace_id, "/search/content"))
579 .json(&body);
580 let wire: Page<SearchMatchWire> = self.send_json(builder).await?;
581 let items = wire
582 .items
583 .into_iter()
584 .map(|item| -> FsResult<SearchMatch> {
585 let preview = base64::engine::general_purpose::STANDARD
586 .decode(&item.preview_base64)
587 .map_err(|err| FsError::internal_storage_failure(err.to_string()))?;
588 Ok(SearchMatch {
589 node: item.node,
590 path: item.path,
591 range: item.range,
592 preview,
593 })
594 })
595 .collect::<FsResult<Vec<_>>>()?;
596 Ok(CommandOutput::SearchMatches(Page::new(
597 items,
598 wire.next_cursor,
599 )))
600 }
601
602 Command::Changes { after, page } => {
603 let mut query = page_query(&page);
604 if let Some(after) = &after {
605 query.push(("after", after.as_str().to_string()));
606 }
607 let builder = self
608 .client
609 .get(self.url(ctx.workspace_id, "/changes"))
610 .query(&query);
611 Ok(CommandOutput::Changes(self.send_json(builder).await?))
612 }
613
614 Command::Batch(operations) => {
615 let body = serde_json::json!({ "operations": operations });
616 let builder = self
617 .client
618 .post(self.url(ctx.workspace_id, "/batch"))
619 .json(&body);
620 let response: BatchResponse = self.send_json(builder).await?;
621 Ok(CommandOutput::Batch(response.results))
622 }
623 }
624 }
625}