Skip to content

Commit f30be84

Browse files
author
Michiel de Jong
committed
Support in-memory Syncables imports in browser WASM
1 parent 721a7fa commit f30be84

7 files changed

Lines changed: 96 additions & 14 deletions

File tree

.github/workflows/rust.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,12 @@ 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
20-
- name: Install wasm32-unknown-unknown target
21-
run: rustup target add wasm32-unknown-unknown
2221
- name: Build for wasm32 (library only — tests use dev-dependencies that aren't wasm-compatible)
2322
run: cargo build --target wasm32-unknown-unknown --lib
2423
- run: cargo clippy --target wasm32-unknown-unknown --lib -- -D warnings

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ serde = { version = "1", features = ["derive"] }
2020
serde_json = { version = "1", features = ["preserve_order"] }
2121
serde_yaml_ng = "0.10"
2222
thiserror = "2"
23-
uuid = { version = "1", features = ["v4", "js"] }
2423

2524
# `tokio::fs` (used only by `openapi::load::load_yaml_file`) has no wasm32
2625
# support at all — see that module's wasm32 stub. Every other tokio use in
@@ -30,6 +29,7 @@ uuid = { version = "1", features = ["v4", "js"] }
3029
tokio = { version = "1", features = ["fs"] }
3130

3231
[dev-dependencies]
32+
uuid = { version = "1", features = ["v4"] }
3333
tokio = { version = "1", features = ["full"] }
3434

3535
[lints.rust]

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,8 @@ networking:
9595
[`OpenApiSource::Value`] instead (the mock server and sync engine's own
9696
document loading only ever need a path when a *native* host chooses to
9797
read one off disk).
98-
- `uuid`'s `v4` feature needs a source of randomness, and the `js`
99-
feature (backed by `getrandom`'s wasm-bindgen support) supplies one on
100-
wasm32 without affecting other targets.
98+
- `uuid` is only needed by native test fixtures and is a dev-dependency;
99+
browser library builds do not pull in its randomness backend.
101100

102101
A wasm32 host implementing [`Fetch`](client::client::Fetch) or
103102
[`Storage`](sync::storage::Storage) — e.g. wrapping a JS `Promise` or a
@@ -233,3 +232,14 @@ As an NLnet-funded project, this follows
233232
## License
234233

235234
Apache-2.0, matching the original.
235+
236+
## Browser / WASM
237+
238+
`cargo check --target wasm32-unknown-unknown --lib` builds the engine without
239+
Tokio filesystem or native runtime dependencies. Load catalog text with
240+
`openapi::load::parse_yaml`, pass the resulting value to
241+
`load_open_api_document`, then call `SyncClient::sync_document(&doc, &storage)`.
242+
This path never opens `ClientConfig.document` or `overlays`; apply overlays in
243+
memory before calling it. File sources return an explicit unsupported error
244+
in WASM. Implement browser `Fetch` with `#[async_trait(?Send)]`; it may hold
245+
a JS callback. Native `Fetch` keeps its Send + Sync contract.

src/client/client.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ pub struct HttpResponse {
8080
/// dependency in `reflector-rs`) uses for its own `Storelike` trait.
8181
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
8282
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
83-
pub trait Fetch: Send + Sync {
83+
pub trait Fetch: FetchBounds {
8484
/// Sends one request and returns the response.
8585
async fn fetch(&self, request: HttpRequest) -> Result<HttpResponse>;
8686
}
@@ -346,6 +346,9 @@ impl ApiClient {
346346
}
347347

348348
/// Builds a client for `document` against the server in `options`.
349+
// Browser options may own a JS callback. Keep the shared Arc-shaped API on
350+
// both targets; WASM hosts never send it across threads.
351+
#[cfg_attr(target_arch = "wasm32", allow(clippy::arc_with_non_send_sync))]
349352
pub fn create_api_client(document: OpenApiDocument, options: ApiClientOptions) -> ApiClient {
350353
let routes = discover_resources(&document.paths);
351354
let storage = options
@@ -361,3 +364,14 @@ pub fn create_api_client(document: OpenApiDocument, options: ApiClientOptions) -
361364
options: Arc::new(options),
362365
}
363366
}
367+
368+
/// Native transports must be thread safe; browser transports run on one JS thread.
369+
#[cfg(not(target_arch = "wasm32"))]
370+
pub trait FetchBounds: Send + Sync {}
371+
#[cfg(not(target_arch = "wasm32"))]
372+
impl<T: Send + Sync> FetchBounds for T {}
373+
/// Browser transports may own JavaScript callbacks.
374+
#[cfg(target_arch = "wasm32")]
375+
pub trait FetchBounds {}
376+
#[cfg(target_arch = "wasm32")]
377+
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/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
}

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)