app_bridge 3.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/Cargo.toml CHANGED
@@ -5,4 +5,4 @@
5
5
  [workspace]
6
6
  members = ["./ext/app_bridge"]
7
7
  resolver = "2"
8
- exclude = ["./spec/fixtures/components/rust_app"]
8
+ exclude = ["./spec/fixtures/components/rust_app", "./spec/fixtures/components/rust_app_v3"]
data/README.md CHANGED
@@ -18,7 +18,7 @@ bundle install
18
18
 
19
19
  ## Usage
20
20
 
21
- To use this gem, you need a WebAssembly component that adheres to the specification defined in `ext/app_bridge/wit/world.wit`.
21
+ To use this gem, you need a WebAssembly component that adheres to the specification defined in `ext/app_bridge/wit/v4/world.wit` (or an older supported version like `v3`).
22
22
 
23
23
  You can check out the example components in `spec/fixtures/components` to see how such a component should be structured.
24
24
 
@@ -31,7 +31,279 @@ app = AppBridge::App.new('path/to/your/component.wasm')
31
31
  app.triggers # => ['trigger1', 'trigger2']
32
32
  ```
33
33
 
34
- More documentation and features will be added as the gem evolves.
34
+ ### File Handling
35
+
36
+ The gem provides a `file.normalize` function for handling files in connectors. It automatically detects the input format (URL, data URI, or base64) and returns normalized file data.
37
+
38
+ #### In your WASM connector (JavaScript):
39
+
40
+ ```javascript
41
+ import { normalize } from 'standout:app/file@4.0.0';
42
+
43
+ // Normalize any file source - input type is auto-detected
44
+ const fileData = normalize(
45
+ input.fileUrl, // URL, data URI, or base64 string
46
+ [["Authorization", token]], // Optional headers for URL requests
47
+ "invoice.pdf" // Optional filename override
48
+ );
49
+
50
+ // Returns: { base64, contentType, filename }
51
+
52
+ // Include in your action output
53
+ return {
54
+ serializedOutput: JSON.stringify({
55
+ document: fileData // Will be replaced with blob ID by platform
56
+ })
57
+ };
58
+ ```
59
+
60
+ #### In your WASM connector (Rust):
61
+
62
+ ```rust
63
+ use crate::standout::app::file::normalize;
64
+
65
+ let file_data = normalize(
66
+ &input.file_url,
67
+ Some(&[("Authorization".to_string(), token)]),
68
+ Some("invoice.pdf"),
69
+ )?;
70
+
71
+ // file_data contains { base64, content_type, filename }
72
+ ```
73
+
74
+ #### Output schema:
75
+
76
+ Mark file fields with `format: "file-output"` so the platform knows to process them:
77
+
78
+ ```json
79
+ {
80
+ "properties": {
81
+ "document": {
82
+ "type": "object",
83
+ "format": "file-output"
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ #### Platform configuration:
90
+
91
+ Configure the file uploader in your Rails app:
92
+
93
+ ```ruby
94
+ AppBridge.file_uploader = ->(file_data) {
95
+ blob = ActiveStorage::Blob.create_and_upload!(
96
+ io: StringIO.new(Base64.decode64(file_data['base64'])),
97
+ filename: file_data['filename'],
98
+ content_type: file_data['content_type']
99
+ )
100
+ blob.signed_id
101
+ }
102
+ ```
103
+
104
+ The gem automatically replaces file data with the return value (in this example blob IDs) before returning the action response.
105
+
106
+ ### Multipart Form Data
107
+
108
+ For `multipart/form-data`, you must build the body manually and set the `Content-Type` header with a boundary. In `standout:app@4.1.0` you can send raw bytes via `body-bytes`; earlier versions only support a string body.
109
+
110
+ #### In your WASM connector (Rust):
111
+
112
+ ```rust
113
+ let boundary = "----app-bridge-boundary";
114
+
115
+ let mut body = Vec::new();
116
+ body.extend_from_slice(format!(
117
+ "--{b}\r\n\
118
+ Content-Disposition: form-data; name=\"metadata\"\r\n\r\n\
119
+ {metadata}\r\n\
120
+ --{b}\r\n\
121
+ Content-Disposition: form-data; name=\"file\"; filename=\"invoice.pdf\"\r\n\
122
+ Content-Type: application/pdf\r\n\r\n",
123
+ b = boundary,
124
+ metadata = metadata_json,
125
+ ).as_bytes());
126
+ body.extend_from_slice(&file_bytes);
127
+ body.extend_from_slice(format!("\r\n--{b}--\r\n", b = boundary).as_bytes());
128
+
129
+ let response = RequestBuilder::new()
130
+ .method(Method::Post)
131
+ .url(upload_url)
132
+ .header("Content-Type", format!("multipart/form-data; boundary={}", boundary))
133
+ .body_bytes(body)
134
+ .send()?;
135
+ ```
136
+
137
+ If you are targeting `standout:app@4.0.0` or earlier, you can only send a string body. That means multipart uploads must be base64-encoded and the receiving API must support `Content-Transfer-Encoding: base64`.
138
+
139
+ ## Retry With Reference
140
+
141
+ `standout:app@4.1.0` introduces a structured retry error for actions. Connectors can return a retry error with a reference and status, and the platform can pass that back on subsequent retries via `action-context.reference-object`.
142
+
143
+ ### In your WASM connector (Rust)
144
+
145
+ ```rust
146
+ return Err(AppError {
147
+ code: ErrorCode::RetryWithReference(ReferenceObject {
148
+ reference: request_id.to_string(),
149
+ status: status.to_string(),
150
+ }),
151
+ message: "Background request still processing".to_string(),
152
+ });
153
+ ```
154
+
155
+ ### In the Ruby platform
156
+
157
+ When the connector returns `retry-with-reference`, the bridge raises `AppBridge::RetryWithReferenceError`. You can access the structured fields:
158
+
159
+ ```ruby
160
+ rescue AppBridge::RetryWithReferenceError => e
161
+ e.reference # => "ref-123"
162
+ e.status # => "queued"
163
+ e.message # => "Background request still processing after 1 attempts."
164
+ end
165
+ ```
166
+
167
+ ## Backward Compatibility
168
+
169
+ The gem supports **multi-version WIT interfaces**, allowing connectors built against older WIT versions to continue working when the gem is updated.
170
+
171
+ ### How it works
172
+
173
+ When loading a WASM component, the gem automatically detects which WIT version it was built against:
174
+
175
+ 1. **V4 components** (current, `standout:app@4.0.0`): Full feature support including the `file` interface
176
+ 2. **V3 components** (`standout:app@3.0.0`): Legacy support without file interface
177
+
178
+ ### Adding support for new WIT versions
179
+
180
+ When adding a new WIT version (e.g., v5), follow these steps:
181
+
182
+ #### 1. Create the WIT file
183
+
184
+ Copy the latest version and modify:
185
+
186
+ ```bash
187
+ cp -r ext/app_bridge/wit/v4 ext/app_bridge/wit/v5
188
+ ```
189
+
190
+ Edit `ext/app_bridge/wit/v5/world.wit`:
191
+ - Update the package version: `package standout:app@5.0.0;`
192
+ - Add new interfaces or modify existing ones
193
+
194
+ #### 2. Add the bindgen module
195
+
196
+ In `ext/app_bridge/src/component.rs`, add after the existing modules:
197
+
198
+ ```rust
199
+ pub mod v5 {
200
+ wasmtime::component::bindgen!({
201
+ path: "./wit/v5",
202
+ world: "bridge",
203
+ });
204
+ }
205
+ ```
206
+
207
+ #### 3. Generate type conversions
208
+
209
+ Add the conversion macro call:
210
+
211
+ ```rust
212
+ impl_conversions!(v5);
213
+ ```
214
+
215
+ #### 4. Add BridgeWrapper variant
216
+
217
+ Update the enum:
218
+
219
+ ```rust
220
+ pub enum BridgeWrapper {
221
+ V3(v3::Bridge),
222
+ V4(v4::Bridge),
223
+ V5(v5::Bridge), // <-- add this
224
+ }
225
+ ```
226
+
227
+ Add an arm to each `bridge_method!` macro expansion. In the macro definitions, add:
228
+
229
+ ```rust
230
+ BridgeWrapper::V5(b) => {
231
+ let r = b.$interface().$method(store, ...)?;
232
+ Ok(r.map(Into::into).map_err(Into::into))
233
+ }
234
+ ```
235
+
236
+ #### 5. Register interfaces in the linker
237
+
238
+ In `build_linker()`:
239
+
240
+ ```rust
241
+ // v5: http + environment + file + new_feature
242
+ v5::standout::app::http::add_to_linker(&mut linker, |s| s)?;
243
+ v5::standout::app::environment::add_to_linker(&mut linker, |s| s)?;
244
+ v5::standout::app::file::add_to_linker(&mut linker, |s| s)?;
245
+ v5::standout::app::new_feature::add_to_linker(&mut linker, |s| s)?; // if applicable
246
+ ```
247
+
248
+ #### 6. Update the instantiation chain
249
+
250
+ In `app()`, add v5 at the top (newest first):
251
+
252
+ ```rust
253
+ // v5 (newest)
254
+ if let Ok(instance) = v5::Bridge::instantiate(&mut *store, &component, &linker) {
255
+ return Ok(BridgeWrapper::V5(instance));
256
+ }
257
+
258
+ // v4
259
+ if let Ok(instance) = v4::Bridge::instantiate(&mut *store, &component, &linker) {
260
+ return Ok(BridgeWrapper::V4(instance));
261
+ }
262
+
263
+ // v3 (oldest)
264
+ // ...
265
+ ```
266
+
267
+ #### 7. Register Host implementations
268
+
269
+ In `app_state.rs`:
270
+
271
+ ```rust
272
+ impl_host_for_version!(v5);
273
+ ```
274
+
275
+ In `request_builder.rs`:
276
+
277
+ ```rust
278
+ impl_host_request_builder!(v5);
279
+ impl_http_type_conversions!(v5);
280
+ ```
281
+
282
+ #### 8. If the version has the file interface
283
+
284
+ In `file_ops.rs` (only if v5 includes the `file` interface):
285
+
286
+ ```rust
287
+ impl_file_host!(v5);
288
+ ```
289
+
290
+ #### 9. If adding new host functions
291
+
292
+ If the new version introduces entirely new interfaces (not just `file`), implement the `Host` trait:
293
+
294
+ ```rust
295
+ impl v5::standout::app::new_feature::Host for AppState {
296
+ fn some_function(&mut self, ...) -> ... {
297
+ // implementation
298
+ }
299
+ }
300
+ ```
301
+
302
+ ### Benefits
303
+
304
+ - **No forced rebuilds**: Existing connectors continue to work after gem updates
305
+ - **Gradual migration**: Update connectors to new WIT versions at your own pace
306
+ - **Type safety**: Each version's types are converted to the latest version internally
35
307
 
36
308
  ## Development
37
309
 
@@ -2,7 +2,7 @@
2
2
  name = "app_bridge"
3
3
  # When updating the version, please also update the version in the
4
4
  # lib/app_bridge/version.rb file to keep them in sync.
5
- version = "3.0.0"
5
+ version = "4.1.0"
6
6
  edition = "2021"
7
7
  authors = ["Alexander Ross <ross@standout.se>"]
8
8
  publish = false
@@ -12,9 +12,13 @@ crate-type = ["cdylib"]
12
12
 
13
13
  [dependencies]
14
14
  magnus = { version = "0.7.1" }
15
- wasmtime = "32.0.0"
16
- wasmtime-wasi = "32.0.0"
15
+ wasmtime = "33.0.2"
16
+ wasmtime-wasi = "33.0.2"
17
17
  reqwest = { version = "0.12", features = ["blocking", "json", "native-tls-vendored"] }
18
+ base64 = "0.22"
19
+ infer = "0.16"
20
+ serde = { version = "1.0", features = ["derive"] }
21
+ serde_json = "1.0"
18
22
 
19
23
  [dev-dependencies]
20
- httpmock = "0.7.0"
24
+ httpmock = "0.8.2"
@@ -1,16 +1,17 @@
1
- use crate::component::standout;
2
- use crate::component::standout::app::http::Request;
1
+ use crate::component::{v3, v4, v4_1};
2
+ use crate::component::v4::standout::app::http::Request;
3
3
  use reqwest::blocking::Client;
4
4
  use std::collections::HashMap;
5
5
  use std::sync::{Arc, Mutex};
6
6
  use wasmtime::component::ResourceTable;
7
- use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiView, IoView};
7
+ use wasmtime_wasi::p2::{WasiCtx, WasiCtxBuilder, WasiView, IoView};
8
8
 
9
9
  pub struct AppState {
10
10
  ctx: WasiCtx,
11
11
  table: ResourceTable,
12
12
  pub client: Arc<Mutex<Client>>,
13
13
  pub request_list: HashMap<u32, Request>,
14
+ pub request_body_bytes: HashMap<u32, Vec<u8>>,
14
15
  pub next_request_id: u32,
15
16
  pub environment_variables: HashMap<String, String>,
16
17
  }
@@ -22,26 +23,44 @@ impl AppState {
22
23
  table: ResourceTable::new(),
23
24
  client: Arc::new(Mutex::new(Client::new())),
24
25
  request_list: HashMap::new(),
26
+ request_body_bytes: HashMap::new(),
25
27
  next_request_id: 0,
26
28
  environment_variables: env_vars.unwrap_or_default(),
27
29
  }
28
30
  }
29
31
  }
30
32
 
31
- impl standout::app::http::Host for AppState {
32
- // Impl http host methods here
33
- }
33
+ // ============================================================================
34
+ // Macro to implement identical Host traits for multiple WIT versions
35
+ // ============================================================================
34
36
 
35
- impl standout::app::environment::Host for AppState {
36
- fn env_vars(&mut self) -> Vec<(String, String)> {
37
- self.environment_variables.clone().into_iter().collect()
38
- }
37
+ /// Implements http::Host and environment::Host for a given WIT version module.
38
+ /// These implementations are identical across versions.
39
+ macro_rules! impl_host_for_version {
40
+ ($version:ident) => {
41
+ impl $version::standout::app::http::Host for AppState {}
39
42
 
40
- fn env_var(&mut self, name: String) -> Option<String> {
41
- self.environment_variables.get(&name).cloned()
42
- }
43
+ impl $version::standout::app::environment::Host for AppState {
44
+ fn env_vars(&mut self) -> Vec<(String, String)> {
45
+ self.environment_variables.clone().into_iter().collect()
46
+ }
47
+
48
+ fn env_var(&mut self, name: String) -> Option<String> {
49
+ self.environment_variables.get(&name).cloned()
50
+ }
51
+ }
52
+ };
43
53
  }
44
54
 
55
+ // Apply to both versions
56
+ impl_host_for_version!(v3);
57
+ impl_host_for_version!(v4);
58
+ impl_host_for_version!(v4_1);
59
+
60
+ // ============================================================================
61
+ // WASI implementations
62
+ // ============================================================================
63
+
45
64
  impl IoView for AppState {
46
65
  fn table(&mut self) -> &mut ResourceTable {
47
66
  &mut self.table