Skip to content

Commit 0ab3521

Browse files
author
Michiel de Jong
committed
Support in-memory Syncables imports in browser WASM
1 parent d48e4d9 commit 0ab3521

9 files changed

Lines changed: 116 additions & 13 deletions

File tree

.github/workflows/rust.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ jobs:
1212
uses: dtolnay/rust-toolchain@stable
1313
with:
1414
components: rustfmt, clippy
15+
targets: wasm32-unknown-unknown
1516
- uses: Swatinem/rust-cache@v2
1617
- run: cargo fmt --all --check
1718
- run: cargo clippy --all-targets --all-features -- -D warnings
1819
- run: cargo test --all-features
1920
- run: cargo build --release
21+
- run: cargo check --lib --target wasm32-unknown-unknown

Cargo.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ serde = { version = "1", features = ["derive"] }
2020
serde_json = { version = "1", features = ["preserve_order"] }
2121
serde_yaml_ng = "0.10"
2222
thiserror = "2"
23-
tokio = { version = "1", features = ["fs", "rt-multi-thread", "macros", "net", "sync", "time"] }
24-
uuid = { version = "1", features = ["v4"] }
23+
24+
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
25+
tokio = { version = "1", features = ["fs"] }
2526

2627
[dev-dependencies]
28+
uuid = { version = "1", features = ["v4"] }
2729
tokio = { version = "1", features = ["full"] }
2830

2931
[lints.rust]

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,3 +196,14 @@ As an NLnet-funded project, this follows
196196
## License
197197

198198
Apache-2.0, matching the original.
199+
200+
## Browser / WASM
201+
202+
`cargo check --target wasm32-unknown-unknown --lib` builds the engine without
203+
Tokio filesystem or native runtime dependencies. Load catalog text with
204+
`openapi::load::parse_yaml`, pass the resulting value to
205+
`load_open_api_document`, then call `SyncClient::sync_document(&doc, &storage)`.
206+
This path never opens `ClientConfig.document` or `overlays`; apply overlays in
207+
memory before calling it. File sources return an explicit unsupported error
208+
in WASM. Implement browser `Fetch` with `#[async_trait(?Send)]`; it may hold
209+
a JS callback. Native `Fetch` keeps its Send + Sync contract.

src/client/client.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,9 @@ pub struct HttpResponse {
7373
/// The client's only extension point for how requests reach the network.
7474
///
7575
/// This is the Rust equivalent of the original's injectable `fetch`.
76-
#[async_trait]
77-
pub trait Fetch: Send + Sync {
76+
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
77+
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
78+
pub trait Fetch: FetchBounds {
7879
/// Sends one request and returns the response.
7980
async fn fetch(&self, request: HttpRequest) -> Result<HttpResponse>;
8081
}
@@ -355,3 +356,14 @@ pub fn create_api_client(document: OpenApiDocument, options: ApiClientOptions) -
355356
options: Arc::new(options),
356357
}
357358
}
359+
360+
/// Native transports must be thread safe; browser transports run on one JS thread.
361+
#[cfg(not(target_arch = "wasm32"))]
362+
pub trait FetchBounds: Send + Sync {}
363+
#[cfg(not(target_arch = "wasm32"))]
364+
impl<T: Send + Sync> FetchBounds for T {}
365+
/// Browser transports may own JavaScript callbacks.
366+
#[cfg(target_arch = "wasm32")]
367+
pub trait FetchBounds {}
368+
#[cfg(target_arch = "wasm32")]
369+
impl<T> FetchBounds for T {}

src/client/storage.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ use crate::error::Result;
1313
///
1414
/// Implement this to persist somewhere other than memory;
1515
/// [`InMemoryStorageAdapter`] is the default.
16-
#[async_trait]
16+
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
17+
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1718
pub trait StorageAdapter: Send + Sync {
1819
/// Every record held for `resource`.
1920
async fn list(&self, resource: &str) -> Result<Vec<Map<String, Value>>>;
@@ -41,7 +42,8 @@ impl InMemoryStorageAdapter {
4142
}
4243
}
4344

44-
#[async_trait]
45+
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
46+
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
4547
impl StorageAdapter for InMemoryStorageAdapter {
4648
async fn list(&self, resource: &str) -> Result<Vec<Map<String, Value>>> {
4749
let mut collections = self.collections.lock().expect("storage mutex poisoned");

src/openapi/load.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ pub fn parse_yaml(text: &str) -> Result<Value> {
6363
/// [`Error::FileLoad`] so it names the offending path — a bare
6464
/// [`std::io::Error`] from a failed read doesn't otherwise mention which
6565
/// file was missing or unreadable.
66+
#[cfg(not(target_arch = "wasm32"))]
6667
pub(crate) async fn load_yaml_file(path: &Path) -> Result<Value> {
6768
async {
6869
let text = tokio::fs::read_to_string(path).await?;
@@ -74,3 +75,17 @@ pub(crate) async fn load_yaml_file(path: &Path) -> Result<Value> {
7475
source: Box::new(source),
7576
})
7677
}
78+
79+
#[cfg(target_arch = "wasm32")]
80+
pub(crate) async fn load_yaml_file(path: &Path) -> Result<Value> {
81+
Err(Error::FileLoad {
82+
path: path.to_path_buf(),
83+
source: Box::new(
84+
std::io::Error::new(
85+
std::io::ErrorKind::Unsupported,
86+
"Use an in-memory OpenAPI document in the browser",
87+
)
88+
.into(),
89+
),
90+
})
91+
}

src/sync/client.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,15 +165,24 @@ impl SyncClient {
165165
&self.config.overlays,
166166
)
167167
.await?;
168-
let model = discover_resource_model(&document)?;
169-
validate_constants(&document, &model, &self.config.constants)?;
168+
self.sync_document(&document, storage).await
169+
}
170+
171+
/// Sync an already loaded, resolved document without filesystem access.
172+
pub async fn sync_document(
173+
&self,
174+
document: &OpenApiDocument,
175+
storage: &dyn Storage,
176+
) -> Result<SyncReport, SyncError> {
177+
let model = discover_resource_model(document)?;
178+
validate_constants(document, &model, &self.config.constants)?;
170179

171180
// Fail before writing anything if there's nowhere to sync from —
172181
// no point minting an ontology for a sync that can't run at all.
173-
let base = base_url(&document)
182+
let base = base_url(document)
174183
.ok_or_else(|| SyncError::Document("document declares no servers".to_string()))?;
175184

176-
let ontology = derive_ontology(&document)?;
185+
let ontology = derive_ontology(document)?;
177186
storage
178187
.put_ontology(&ontology)
179188
.await
@@ -183,7 +192,7 @@ impl SyncClient {
183192
ontology_terms: ontology.terms.len(),
184193
..SyncReport::default()
185194
};
186-
self.walk_all(&document, &model, base, storage, &mut report)
195+
self.walk_all(document, &model, base, storage, &mut report)
187196
.await;
188197
Ok(report)
189198
}

src/sync/storage.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ impl StorageError {
102102
/// it. That ordering is the engine's responsibility
103103
/// ([issue #9](https://github.com/localthought/syncables-rs/issues/9));
104104
/// `reflector-rs`'s `AtomicStorage` is written against it.
105-
#[async_trait]
105+
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
106+
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
106107
pub trait Storage: Send + Sync {
107108
/// Inserts or replaces a record, keyed by its `namespace`, `resource`
108109
/// and `id`.
@@ -159,7 +160,8 @@ impl InMemoryStorage {
159160
}
160161
}
161162

162-
#[async_trait]
163+
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
164+
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
163165
impl Storage for InMemoryStorage {
164166
async fn put(&self, record: &Record) -> Result<(), StorageError> {
165167
let key = (

tests/unit/sync/client.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,3 +570,51 @@ async fn tracks_every_url_it_requested() {
570570
vec!["https://api.example.com/repos/acme/widgets/issues".to_string()]
571571
);
572572
}
573+
574+
#[tokio::test]
575+
async fn syncs_in_memory_without_opening_the_configured_path() {
576+
let document = syncables::load_open_api_document(flat_document(Some(
577+
json!([{ "url": "https://api.example.com" }]),
578+
)))
579+
.await
580+
.unwrap();
581+
let fetch = MockFetch::default().respond_json(
582+
"https://api.example.com/repos/acme/widgets/issues",
583+
200,
584+
&[],
585+
json!([
586+
{ "number": 1, "title": "First issue" },
587+
{ "number": 2, "title": "Second issue" }
588+
]),
589+
);
590+
let client = SyncClient::new(
591+
config(
592+
Path::new("/this-file-must-never-be-opened"),
593+
&[("owner", "acme"), ("repo", "widgets")],
594+
),
595+
Arc::new(fetch),
596+
)
597+
.expect("valid config");
598+
599+
let storage = InMemoryStorage::new();
600+
let report = client
601+
.sync_document(&document, &storage)
602+
.await
603+
.expect("sync succeeds");
604+
605+
assert!(
606+
report.errors.is_empty(),
607+
"unexpected errors: {:?}",
608+
report.errors
609+
);
610+
assert_eq!(report.read.get("issue"), Some(&2));
611+
assert_eq!(report.ontology_terms, 1);
612+
assert_eq!(storage.ontologies().len(), 1);
613+
614+
let first = storage
615+
.get("acme/widgets", "issue", "1")
616+
.await
617+
.expect("get succeeds")
618+
.expect("record present");
619+
assert_eq!(first.value.get("title"), Some(&json!("First issue")));
620+
}

0 commit comments

Comments
 (0)