Skip to content

Commit 369c3a9

Browse files
committed
Abstract away json in atomic-plugin #73
1 parent 45570a8 commit 369c3a9

File tree

7 files changed

+199
-70
lines changed

7 files changed

+199
-70
lines changed

atomic-plugin/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# atomic-plugin
2+
3+
A helper library that removes a lot of the boilerplate when building AtomicServer Wasm plugins.
4+
5+
## Class Extenders
6+
7+
Atomic Data Classextenders are plugins that can modify the behavior of an Atomic Data class.
8+
For example you might want to add some custom verification logic to a class
9+
10+
## How to use
11+
12+
Simply implement the `ClassExtender` trait on a struct and export it using the `export_plugin!` macro.
13+
14+
```rust
15+
use atomic_plugin::{ClassExtender, Commit, Resource};
16+
17+
struct FolderExtender;
18+
19+
impl ClassExtender for FolderExtender {
20+
// REQUIRED: Returns the class that this class extender applies to.
21+
fn class_url() -> String {
22+
"https://atomicdata.dev/classes/Folder".to_string()
23+
}
24+
25+
// Prevent commits where the name contains "Tailwind CSS".
26+
fn before_commit(commit: &Commit, _snapshot: Option<&Resource>) -> Result<(), String> {
27+
let Some(set) = &commit.set else {
28+
return Ok(());
29+
};
30+
31+
let Some(name) = set.get(NAME_PROP).and_then(|val| val.as_str()) else {
32+
return Ok(());
33+
};
34+
35+
if name.contains("Tailwind CSS") {
36+
return Err("Tailwind CSS is not allowed".into());
37+
}
38+
39+
Ok(())
40+
}
41+
}
42+
43+
atomic_plugin::export_plugin!(FolderExtender);
44+
```

atomic-plugin/src/bindings.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,7 @@ macro_rules! __export_class_extender_impl {
591591
};
592592
}
593593
#[doc(inline)]
594+
#[allow(unused)]
594595
pub(crate) use __export_class_extender_impl as export;
595596
#[cfg(target_arch = "wasm32")]
596597
#[unsafe(link_section = "component-type:wit-bindgen:0.41.0:atomic:class-extender@0.1.0:class-extender:encoded world")]

atomic-plugin/src/lib.rs

Lines changed: 109 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -10,76 +10,114 @@ pub use bindings::atomic::class_extender::types::{
1010
};
1111
pub use bindings::Guest;
1212

13+
use serde::Deserialize;
1314
use serde_json::Value as JsonValue;
1415

16+
pub struct Resource {
17+
pub subject: String,
18+
pub props: serde_json::Map<String, JsonValue>,
19+
}
20+
21+
#[derive(Clone, Debug, Deserialize)]
22+
pub struct Commit {
23+
/// The subject URL that is to be modified by this Delta
24+
#[serde(rename = "https://atomicdata.dev/properties/subject")]
25+
pub subject: String,
26+
/// The date it was created, as a unix timestamp
27+
#[serde(rename = "https://atomicdata.dev/properties/createdAt")]
28+
pub created_at: i64,
29+
/// The URL of the one signing this Commit
30+
#[serde(rename = "https://atomicdata.dev/properties/signer")]
31+
pub signer: String,
32+
/// The set of PropVals that need to be added.
33+
/// Overwrites existing values
34+
#[serde(rename = "https://atomicdata.dev/properties/set")]
35+
pub set: Option<std::collections::HashMap<String, JsonValue>>,
36+
#[serde(rename = "https://atomicdata.dev/properties/yUpdate")]
37+
pub y_update: Option<std::collections::HashMap<String, JsonValue>>,
38+
#[serde(rename = "https://atomicdata.dev/properties/remove")]
39+
/// The set of property URLs that need to be removed
40+
pub remove: Option<Vec<String>>,
41+
/// If set to true, deletes the entire resource
42+
#[serde(rename = "https://atomicdata.dev/properties/destroy")]
43+
pub destroy: Option<bool>,
44+
/// Base64 encoded signature of the JSON serialized Commit
45+
#[serde(rename = "https://atomicdata.dev/properties/signature")]
46+
pub signature: Option<String>,
47+
/// List of Properties and Arrays to be appended to them
48+
#[serde(rename = "https://atomicdata.dev/properties/push")]
49+
pub push: Option<std::collections::HashMap<String, JsonValue>>,
50+
/// The previously applied commit to this Resource.
51+
#[serde(rename = "https://atomicdata.dev/properties/previousCommit")]
52+
pub previous_commit: Option<String>,
53+
/// The URL of the Commit
54+
pub url: Option<String>,
55+
}
56+
1557
/// High-level trait for implementing a Class Extender plugin.
16-
pub trait AtomicPlugin {
58+
pub trait ClassExtender {
1759
fn class_url() -> String;
1860

19-
fn on_resource_get(
20-
_subject: &str,
21-
_resource: &mut JsonValue,
22-
) -> Result<Option<JsonValue>, String> {
23-
Ok(None)
61+
/// Called when a resource is fetched from the server. You can modify the resource in place.
62+
fn on_resource_get<'a>(resource: &'a mut Resource) -> Result<Option<&'a Resource>, String> {
63+
Ok(Some(resource))
2464
}
2565

26-
fn before_commit(_subject: &str, _resource: &JsonValue) -> Result<(), String> {
66+
/// Called before a Commit that targets the class is persisted. If you return an error, the commit will be rejected.
67+
fn before_commit(_commit: &Commit, _snapshot: Option<&Resource>) -> Result<(), String> {
2768
Ok(())
2869
}
2970

30-
fn after_commit(_subject: &str, _resource: &JsonValue) -> Result<(), String> {
71+
/// Called after a Commit that targets the class has been applied. Returning an error will not cancel the commit.
72+
fn after_commit(_commit: &Commit, _resource: Option<&Resource>) -> Result<(), String> {
3173
Ok(())
3274
}
3375
}
3476

3577
#[doc(hidden)]
3678
pub struct PluginWrapper<T>(std::marker::PhantomData<T>);
3779

38-
impl<T: AtomicPlugin> Guest for PluginWrapper<T> {
80+
impl<T: ClassExtender> Guest for PluginWrapper<T> {
3981
fn class_url() -> String {
4082
T::class_url()
4183
}
4284

4385
fn on_resource_get(ctx: GetContext) -> Result<Option<ResourceResponse>, String> {
44-
let mut json_value: JsonValue =
45-
serde_json::from_str(&ctx.snapshot.json_ad).map_err(|e| e.to_string())?;
46-
47-
let result = T::on_resource_get(&ctx.snapshot.subject, &mut json_value)?;
48-
49-
match result {
50-
Some(updated_json) => {
51-
let updated_payload = serde_json::to_string(&updated_json)
52-
.map_err(|e| format!("Serialize error: {e}"))?;
53-
Ok(Some(ResourceResponse {
54-
primary: ResourceJson {
55-
subject: ctx.snapshot.subject,
56-
json_ad: updated_payload,
57-
},
58-
referenced: Vec::new(),
59-
}))
60-
}
61-
None => Ok(None),
62-
}
86+
let mut resource = Resource::try_from(ctx.snapshot)?;
87+
88+
let Some(result) = T::on_resource_get(&mut resource)? else {
89+
return Ok(None);
90+
};
91+
92+
let updated_payload = result.to_json()?;
93+
94+
Ok(Some(ResourceResponse {
95+
primary: ResourceJson {
96+
subject: resource.subject,
97+
json_ad: updated_payload,
98+
},
99+
referenced: Vec::new(),
100+
}))
63101
}
64102

65103
fn before_commit(ctx: CommitContext) -> Result<(), String> {
66-
if let Some(snapshot) = ctx.snapshot {
67-
let json_value: JsonValue =
68-
serde_json::from_str(&snapshot.json_ad).map_err(|e| e.to_string())?;
69-
T::before_commit(&ctx.subject, &json_value)
70-
} else {
71-
Ok(())
72-
}
104+
let commit: Commit = serde_json::from_str(&ctx.commit_json).map_err(|e| e.to_string())?;
105+
let snapshot: Option<Resource> = match ctx.snapshot {
106+
Some(snapshot) => Some(Resource::try_from(snapshot)?),
107+
None => None,
108+
};
109+
110+
T::before_commit(&commit, snapshot.as_ref())
73111
}
74112

75113
fn after_commit(ctx: CommitContext) -> Result<(), String> {
76-
if let Some(snapshot) = ctx.snapshot {
77-
let json_value: JsonValue =
78-
serde_json::from_str(&snapshot.json_ad).map_err(|e| e.to_string())?;
79-
T::after_commit(&ctx.subject, &json_value)
80-
} else {
81-
Ok(())
82-
}
114+
let commit: Commit = serde_json::from_str(&ctx.commit_json).map_err(|e| e.to_string())?;
115+
let snapshot: Option<Resource> = match ctx.snapshot {
116+
Some(snapshot) => Some(Resource::try_from(snapshot)?),
117+
None => None,
118+
};
119+
120+
T::after_commit(&commit, snapshot.as_ref())
83121
}
84122
}
85123

@@ -105,3 +143,32 @@ macro_rules! export_plugin {
105143
$crate::__export_world_class_extender_cabi!(Shim with_types_in $crate::bindings);
106144
};
107145
}
146+
147+
impl TryFrom<ResourceJson> for Resource {
148+
type Error = String;
149+
150+
fn try_from(resource_json: ResourceJson) -> Result<Self, Self::Error> {
151+
let json_value: JsonValue = serde_json::from_str(&resource_json.json_ad)
152+
.map_err(|e| format!("Invalid JSON: {}", e))?;
153+
154+
let Some(obj) = json_value.as_object() else {
155+
return Err("Resource is not a JSON object".into());
156+
};
157+
158+
let mut props = obj.clone();
159+
props.remove("@id");
160+
161+
Ok(Self {
162+
subject: resource_json.subject,
163+
props,
164+
})
165+
}
166+
}
167+
168+
impl Resource {
169+
pub fn to_json(&self) -> Result<String, String> {
170+
let mut props = self.props.clone();
171+
props.insert("@id".to_string(), JsonValue::String(self.subject.clone()));
172+
serde_json::to_string(&props).map_err(|e| format!("Serialize error: {e}"))
173+
}
174+
}

lib/src/plugins/wasm.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ use bindings::atomic::class_extender::types::{
3232
ResourceJson as WasmResourceJson, ResourceResponse as WasmResourceResponse,
3333
};
3434

35-
const WASM_EXTENDER_DIR: &str = "../plugins/class-extenders";
35+
const WASM_EXTENDER_DIR: &str = "../plugins/class-extenders"; // Relative to the store path.
3636

3737
pub fn load_wasm_class_extenders(store_path: &Path) -> Vec<ClassExtender> {
3838
let plugins_dir = store_path.join(WASM_EXTENDER_DIR);

plugin-examples/random-folder-extender/Cargo.toml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,10 @@ version = "0.1.0"
44
edition = "2021"
55

66
[lib]
7+
# This is important for the Wasm build.
78
crate-type = ["cdylib"]
89

910
[dependencies]
1011
atomic-plugin = { path = "../../atomic-plugin" }
1112
rand = { version = "0.8", features = ["std", "std_rng"] }
1213
serde_json = "1"
13-
14-
[package.metadata.component]
15-
package = "atomic:class-extender"
Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
# Random Folder Class Extender
22

3-
This crate shows how to build a Wasm-based class extender for Atomic Server. It targets the `class-extender` world defined in `lib/wit/class-extender.wit` and appends a random four-digit suffix to every folder name whenever a resource of class [`https://atomicdata.dev/classes/Folder`](https://atomicdata.dev/classes/Folder) is fetched.
3+
This crate shows how to build a Wasm-based class extender for Atomic Server.
4+
It appends a random number to the end of the folder name each time it is fetched.
5+
It also prevents commits to the folder if the name contains uppercase letters.
46

57
## Building
68

7-
You'll need [`cargo-component`](https://github.com/bytecodealliance/cargo-component) to compile the component:
9+
AtomicServer plugins are compiled to WebAssempbly (Wasm) using the component model.
10+
You should target the `wasm32-wasip2` architecture when building the project.
811

912
```bash
10-
cargo component build --release -p random-folder-extender --target wasm32-wasip2
11-
```
12-
13-
The compiled Wasm component will be written to:
13+
# Install the target if you haven't already.
14+
rustup target add wasm32-wasip2
1415

16+
# Build the plugin.
17+
cargo build --release -p random-folder-extender --target wasm32-wasip2
1518
```
16-
target/wasm32-wasip2/release/random-folder-extender.wasm
17-
```
18-
19-
Copy that file into your server's `wasm-class-extenders/` directory (sits next to the sled database). Atomic Server will discover it on startup and automatically append random suffixes to folder names.
2019

20+
In this example the build output location is `target/wasm32-wasip2/release/random-folder-extender.wasm`.
2121

22+
Copy that file into your servers `plugins/class-extenders/` directory and restart AtomicServer.
23+
The plugin should be automatically loaded.
24+
The plugin folder is located in the same directory as your AtomicServer store.
25+
Check the [docs](https://docs.atomicdata.dev/atomicserver/faq.html#where-is-my-data-stored-on-my-machine) to find this directory.

plugin-examples/random-folder-extender/src/lib.rs

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,50 @@
1-
use atomic_plugin::AtomicPlugin;
1+
use atomic_plugin::{ClassExtender, Commit, Resource};
22
use rand::Rng;
3-
use serde_json::{json, Value as JsonValue};
43

54
struct RandomFolderExtender;
65

76
const FOLDER_CLASS: &str = "https://atomicdata.dev/classes/Folder";
87
const NAME_PROP: &str = "https://atomicdata.dev/properties/name";
98

10-
impl AtomicPlugin for RandomFolderExtender {
9+
impl ClassExtender for RandomFolderExtender {
1110
fn class_url() -> String {
1211
FOLDER_CLASS.to_string()
1312
}
1413

15-
fn on_resource_get(
16-
_subject: &str,
17-
resource: &mut JsonValue,
18-
) -> Result<Option<JsonValue>, String> {
19-
let Some(obj) = resource.as_object_mut() else {
20-
return Err("Resource is not a JSON object".into());
21-
};
22-
23-
let base_name = obj
14+
// Modify the response from the server every time a folder is fetched.
15+
// Appends a random number to the end of the folder name.
16+
fn on_resource_get(resource: &mut Resource) -> Result<Option<&Resource>, String> {
17+
let base_name = resource
18+
.props
2419
.get(NAME_PROP)
2520
.and_then(|val| val.as_str())
2621
.unwrap_or("Folder");
2722

2823
let random_suffix = rand::thread_rng().gen_range(0..=9999);
2924
let updated_name = format!("{} {}", base_name.trim_end(), random_suffix);
3025

31-
obj.insert(NAME_PROP.to_string(), json!(updated_name));
32-
Ok(Some(resource.clone()))
26+
resource
27+
.props
28+
.insert(NAME_PROP.to_string(), updated_name.into());
29+
30+
Ok(Some(resource))
31+
}
32+
33+
// Prevent commits if the folder name contains uppercase letters.
34+
fn before_commit(commit: &Commit, _snapshot: Option<&Resource>) -> Result<(), String> {
35+
let Some(set) = &commit.set else {
36+
return Ok(());
37+
};
38+
39+
let Some(name) = set.get(NAME_PROP).and_then(|val| val.as_str()) else {
40+
return Ok(());
41+
};
42+
43+
if name.chars().any(|c| c.is_uppercase()) {
44+
return Err("Folder name cannot contain uppercase letters".into());
45+
}
46+
47+
Ok(())
3348
}
3449
}
3550

0 commit comments

Comments
 (0)