app_bridge 3.0.0-arm64-darwin → 4.0.0-arm64-darwin
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.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -0
- data/.tool-versions +1 -1
- data/README.md +213 -2
- data/ext/app_bridge/wit/{world.wit → v3/world.wit} +3 -0
- data/ext/app_bridge/wit/v4/world.wit +328 -0
- data/lib/app_bridge/3.2/app_bridge.bundle +0 -0
- data/lib/app_bridge/3.4/app_bridge.bundle +0 -0
- data/lib/app_bridge/app.rb +21 -2
- data/lib/app_bridge/file_processor.rb +131 -0
- data/lib/app_bridge/version.rb +1 -1
- data/lib/app_bridge.rb +26 -0
- data/tasks/fixtures.rake +16 -2
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 23269efcf4aff4a956b7fc658419dcde263f2f52d7344a5e70012fd48ebb6710
|
|
4
|
+
data.tar.gz: dbe50843264d85aa54d1fb2503f35f0ff664bb9669a34dc19dc82b38b2d43adf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: db2c3b3de75bfc87ecb73cf697ab43334bc28c3ae80f2fb3f7e31448de755aa5c00dfa94064cbf6cd1fd07a31ff6b9a85b81a37ca8fab5baa319c133aba24263
|
|
7
|
+
data.tar.gz: 539fbe8bcd3813df1c089f6965a102a709b9ba274309fdfd91841575f67cf1a7731a5fd9d756cf570b6774aa6cd7189ba1f7aaa0012ec96fe09d2a5444e15fb6
|
data/.rubocop.yml
CHANGED
data/.tool-versions
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
ruby 3.4.
|
|
1
|
+
ruby 3.4.8
|
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,218 @@ app = AppBridge::App.new('path/to/your/component.wasm')
|
|
|
31
31
|
app.triggers # => ['trigger1', 'trigger2']
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
|
|
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
|
+
## Backward Compatibility
|
|
107
|
+
|
|
108
|
+
The gem supports **multi-version WIT interfaces**, allowing connectors built against older WIT versions to continue working when the gem is updated.
|
|
109
|
+
|
|
110
|
+
### How it works
|
|
111
|
+
|
|
112
|
+
When loading a WASM component, the gem automatically detects which WIT version it was built against:
|
|
113
|
+
|
|
114
|
+
1. **V4 components** (current, `standout:app@4.0.0`): Full feature support including the `file` interface
|
|
115
|
+
2. **V3 components** (`standout:app@3.0.0`): Legacy support without file interface
|
|
116
|
+
|
|
117
|
+
### Adding support for new WIT versions
|
|
118
|
+
|
|
119
|
+
When adding a new WIT version (e.g., v5), follow these steps:
|
|
120
|
+
|
|
121
|
+
#### 1. Create the WIT file
|
|
122
|
+
|
|
123
|
+
Copy the latest version and modify:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
cp -r ext/app_bridge/wit/v4 ext/app_bridge/wit/v5
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Edit `ext/app_bridge/wit/v5/world.wit`:
|
|
130
|
+
- Update the package version: `package standout:app@5.0.0;`
|
|
131
|
+
- Add new interfaces or modify existing ones
|
|
132
|
+
|
|
133
|
+
#### 2. Add the bindgen module
|
|
134
|
+
|
|
135
|
+
In `ext/app_bridge/src/component.rs`, add after the existing modules:
|
|
136
|
+
|
|
137
|
+
```rust
|
|
138
|
+
pub mod v5 {
|
|
139
|
+
wasmtime::component::bindgen!({
|
|
140
|
+
path: "./wit/v5",
|
|
141
|
+
world: "bridge",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
#### 3. Generate type conversions
|
|
147
|
+
|
|
148
|
+
Add the conversion macro call:
|
|
149
|
+
|
|
150
|
+
```rust
|
|
151
|
+
impl_conversions!(v5);
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
#### 4. Add BridgeWrapper variant
|
|
155
|
+
|
|
156
|
+
Update the enum:
|
|
157
|
+
|
|
158
|
+
```rust
|
|
159
|
+
pub enum BridgeWrapper {
|
|
160
|
+
V3(v3::Bridge),
|
|
161
|
+
V4(v4::Bridge),
|
|
162
|
+
V5(v5::Bridge), // <-- add this
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Add an arm to each `bridge_method!` macro expansion. In the macro definitions, add:
|
|
167
|
+
|
|
168
|
+
```rust
|
|
169
|
+
BridgeWrapper::V5(b) => {
|
|
170
|
+
let r = b.$interface().$method(store, ...)?;
|
|
171
|
+
Ok(r.map(Into::into).map_err(Into::into))
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
#### 5. Register interfaces in the linker
|
|
176
|
+
|
|
177
|
+
In `build_linker()`:
|
|
178
|
+
|
|
179
|
+
```rust
|
|
180
|
+
// v5: http + environment + file + new_feature
|
|
181
|
+
v5::standout::app::http::add_to_linker(&mut linker, |s| s)?;
|
|
182
|
+
v5::standout::app::environment::add_to_linker(&mut linker, |s| s)?;
|
|
183
|
+
v5::standout::app::file::add_to_linker(&mut linker, |s| s)?;
|
|
184
|
+
v5::standout::app::new_feature::add_to_linker(&mut linker, |s| s)?; // if applicable
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
#### 6. Update the instantiation chain
|
|
188
|
+
|
|
189
|
+
In `app()`, add v5 at the top (newest first):
|
|
190
|
+
|
|
191
|
+
```rust
|
|
192
|
+
// v5 (newest)
|
|
193
|
+
if let Ok(instance) = v5::Bridge::instantiate(&mut *store, &component, &linker) {
|
|
194
|
+
return Ok(BridgeWrapper::V5(instance));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// v4
|
|
198
|
+
if let Ok(instance) = v4::Bridge::instantiate(&mut *store, &component, &linker) {
|
|
199
|
+
return Ok(BridgeWrapper::V4(instance));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// v3 (oldest)
|
|
203
|
+
// ...
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
#### 7. Register Host implementations
|
|
207
|
+
|
|
208
|
+
In `app_state.rs`:
|
|
209
|
+
|
|
210
|
+
```rust
|
|
211
|
+
impl_host_for_version!(v5);
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
In `request_builder.rs`:
|
|
215
|
+
|
|
216
|
+
```rust
|
|
217
|
+
impl_host_request_builder!(v5);
|
|
218
|
+
impl_http_type_conversions!(v5);
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
#### 8. If the version has the file interface
|
|
222
|
+
|
|
223
|
+
In `file_ops.rs` (only if v5 includes the `file` interface):
|
|
224
|
+
|
|
225
|
+
```rust
|
|
226
|
+
impl_file_host!(v5);
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
#### 9. If adding new host functions
|
|
230
|
+
|
|
231
|
+
If the new version introduces entirely new interfaces (not just `file`), implement the `Host` trait:
|
|
232
|
+
|
|
233
|
+
```rust
|
|
234
|
+
impl v5::standout::app::new_feature::Host for AppState {
|
|
235
|
+
fn some_function(&mut self, ...) -> ... {
|
|
236
|
+
// implementation
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Benefits
|
|
242
|
+
|
|
243
|
+
- **No forced rebuilds**: Existing connectors continue to work after gem updates
|
|
244
|
+
- **Gradual migration**: Update connectors to new WIT versions at your own pace
|
|
245
|
+
- **Type safety**: Each version's types are converted to the latest version internally
|
|
35
246
|
|
|
36
247
|
## Development
|
|
37
248
|
|
|
@@ -275,9 +275,12 @@ interface http {
|
|
|
275
275
|
}
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
// Note: v3 does NOT include the file interface
|
|
279
|
+
|
|
278
280
|
world bridge {
|
|
279
281
|
import http;
|
|
280
282
|
import environment;
|
|
283
|
+
// Note: file interface is NOT available in v3
|
|
281
284
|
export triggers;
|
|
282
285
|
export actions;
|
|
283
286
|
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
package standout:app@4.0.0;
|
|
2
|
+
|
|
3
|
+
interface types {
|
|
4
|
+
// The trigger-store is a string that is used to store data between trigger
|
|
5
|
+
// invocations. It is unique per trigger instance and is persisted between
|
|
6
|
+
// invocations.
|
|
7
|
+
//
|
|
8
|
+
// You can store any string here. We suggest that you use a serialized
|
|
9
|
+
// JSON object or similar since that will give you some flexibility if you
|
|
10
|
+
// need to add more data to the store.
|
|
11
|
+
type trigger-store = string;
|
|
12
|
+
|
|
13
|
+
record connection {
|
|
14
|
+
id: string,
|
|
15
|
+
name: string,
|
|
16
|
+
// The connection data is a JSON object serialized into a string. The JSON root
|
|
17
|
+
// will always be an object.
|
|
18
|
+
serialized-data: string,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
record trigger-context {
|
|
22
|
+
// Trigger ID is a unique identifier for the trigger that is requested to be
|
|
23
|
+
// invoked.
|
|
24
|
+
trigger-id: string,
|
|
25
|
+
|
|
26
|
+
// The connection that the trigger is invoked for.
|
|
27
|
+
// Connection is required for all trigger operations.
|
|
28
|
+
connection: connection,
|
|
29
|
+
|
|
30
|
+
// The store will contain the data that was stored in the trigger store the
|
|
31
|
+
// last time the trigger was invoked.
|
|
32
|
+
store: trigger-store,
|
|
33
|
+
|
|
34
|
+
// The input data for the trigger, serialized as a JSON object string.
|
|
35
|
+
// This contains the input data from the trigger configuration form.
|
|
36
|
+
serialized-input: string,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
record action-context {
|
|
40
|
+
// Action ID is a unique identifier for the action that is requested to be
|
|
41
|
+
// invoked.
|
|
42
|
+
action-id: string,
|
|
43
|
+
|
|
44
|
+
// The connection that the action is invoked for.
|
|
45
|
+
// Connection is required for all action operations.
|
|
46
|
+
connection: connection,
|
|
47
|
+
|
|
48
|
+
// The input data for the action, serialized as a JSON object string.
|
|
49
|
+
// This contains the data passed from the previous step in the workflow.
|
|
50
|
+
serialized-input: string,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
record trigger-response {
|
|
54
|
+
// The trigger events, each event will be used to spawn a new workflow
|
|
55
|
+
// execution in Standouts integration plattform.
|
|
56
|
+
events: list<trigger-event>,
|
|
57
|
+
|
|
58
|
+
// The updated store will be stored and used the next time the trigger is
|
|
59
|
+
// invoked.
|
|
60
|
+
store: trigger-store,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
record action-response {
|
|
64
|
+
// The output data from the action, serialized as a JSON object string.
|
|
65
|
+
// This contains the data that will be passed to the next step in the workflow.
|
|
66
|
+
// The data must be a valid JSON object (not an array or primitive).
|
|
67
|
+
serialized-output: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
record trigger-event {
|
|
71
|
+
// The ID of the trigger event
|
|
72
|
+
//
|
|
73
|
+
// If the connection used for the given instance of the trigger is the same,
|
|
74
|
+
// as seen before. Then the event will be ignored.
|
|
75
|
+
//
|
|
76
|
+
// A scheduler could therefore use an timestamp as the ID, to ensure that
|
|
77
|
+
// the event is only triggered once per given time.
|
|
78
|
+
//
|
|
79
|
+
// A trigger that acts on created orders in a e-commerce system could use
|
|
80
|
+
// the order ID as the ID, to ensure that the event is only triggered once
|
|
81
|
+
// per order.
|
|
82
|
+
//
|
|
83
|
+
// A trigger that acts on updated orders in a e-commerce system could use
|
|
84
|
+
// the order ID in combination with an updated at timestamp as the ID, to
|
|
85
|
+
// ensure that the event is only triggered once per order update.
|
|
86
|
+
id: string,
|
|
87
|
+
|
|
88
|
+
// Serialized data must be a JSON object serialized into a string
|
|
89
|
+
// Note that it is important that the root is a object, not an array,
|
|
90
|
+
// or another primitive type.
|
|
91
|
+
serialized-data: string,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// A structured error that can be returned by for example a call to a trigger or action.
|
|
95
|
+
/// Contains a machine-readable code and a human-readable message.
|
|
96
|
+
record app-error {
|
|
97
|
+
/// The error code identifying the type of failure.
|
|
98
|
+
code: error-code,
|
|
99
|
+
|
|
100
|
+
/// A human-readable message describing the error in more detail.
|
|
101
|
+
message: string,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// An enumeration of error codes that can be returned by a trigger implementation.
|
|
105
|
+
/// These codes help the platform and plugin developers distinguish between different types of failures.
|
|
106
|
+
variant error-code {
|
|
107
|
+
/// Authentication failed. Typically due to an invalid or expired API key or token.
|
|
108
|
+
unauthenticated,
|
|
109
|
+
|
|
110
|
+
/// Authorization failed. The connection is valid but does not have the necessary permissions.
|
|
111
|
+
forbidden,
|
|
112
|
+
|
|
113
|
+
/// The trigger is misconfigured. For example, a required setting is missing or invalid.
|
|
114
|
+
misconfigured,
|
|
115
|
+
|
|
116
|
+
/// The target system does not support a required feature or endpoint.
|
|
117
|
+
unsupported,
|
|
118
|
+
|
|
119
|
+
/// The target system is rate-limiting requests. Try again later.
|
|
120
|
+
rate-limit,
|
|
121
|
+
|
|
122
|
+
/// The request timed out. The target system did not respond in time.
|
|
123
|
+
timeout,
|
|
124
|
+
|
|
125
|
+
/// The target system is currently unavailable or unreachable.
|
|
126
|
+
unavailable,
|
|
127
|
+
|
|
128
|
+
/// An unexpected internal error occurred in the plugin.
|
|
129
|
+
internal-error,
|
|
130
|
+
|
|
131
|
+
/// The response from the external system could not be parsed or was in an invalid format.
|
|
132
|
+
malformed-response,
|
|
133
|
+
|
|
134
|
+
/// A catch-all for all other types of errors. Should include a descriptive message.
|
|
135
|
+
other,
|
|
136
|
+
|
|
137
|
+
/// Complete the current workflow execution.
|
|
138
|
+
complete-workflow,
|
|
139
|
+
|
|
140
|
+
/// Complete the parent step execution.
|
|
141
|
+
complete-parent,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
interface triggers {
|
|
147
|
+
use types.{trigger-context, trigger-event, trigger-response, app-error};
|
|
148
|
+
|
|
149
|
+
trigger-ids: func() -> result<list<string>, app-error>;
|
|
150
|
+
|
|
151
|
+
// Get the input schema for a specific trigger
|
|
152
|
+
// Returns a JSON Schema Draft 2020-12 schema as a string
|
|
153
|
+
// The schema may vary based on the connection in the context
|
|
154
|
+
// The trigger-id is extracted from the context
|
|
155
|
+
input-schema: func(context: trigger-context) -> result<string, app-error>;
|
|
156
|
+
|
|
157
|
+
// Get the output schema for a specific trigger
|
|
158
|
+
// Returns a JSON Schema Draft 2020-12 schema as a string
|
|
159
|
+
// The schema may vary based on the connection in the context
|
|
160
|
+
// The trigger-id is extracted from the context
|
|
161
|
+
output-schema: func(context: trigger-context) -> result<string, app-error>;
|
|
162
|
+
|
|
163
|
+
// Fetch events
|
|
164
|
+
//
|
|
165
|
+
// There are some limitations to the function:
|
|
166
|
+
// - It must a `trigger-response` within 30 seconds
|
|
167
|
+
// - It must return less than or equal to 100 `trigger-response.events`
|
|
168
|
+
// - It must not return more than 64 kB of data in the `trigger-response.store`
|
|
169
|
+
//
|
|
170
|
+
// If you need to fetch more events, you can return up to 100 events and then
|
|
171
|
+
// store the data needed for you to remember where you left off in the store.
|
|
172
|
+
// The next time the trigger is invoked, you can use the store to continue
|
|
173
|
+
// where you left off.
|
|
174
|
+
//
|
|
175
|
+
// If you do not pass the limitations the return value will be ignored. We
|
|
176
|
+
// will not handle any events and we persist the store that was returned in
|
|
177
|
+
// the response.
|
|
178
|
+
//
|
|
179
|
+
// That also means that you should implement your fetch event function in a
|
|
180
|
+
// way that it can be called multiple times using the same context and return
|
|
181
|
+
// the same events. That will ensure that the user that is building an
|
|
182
|
+
// integration with your trigger will not miss any events if your system is
|
|
183
|
+
// down for a short period of time.
|
|
184
|
+
fetch-events: func(context: trigger-context) -> result<trigger-response, app-error>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface actions {
|
|
188
|
+
use types.{action-context, action-response, app-error};
|
|
189
|
+
|
|
190
|
+
action-ids: func() -> result<list<string>, app-error>;
|
|
191
|
+
|
|
192
|
+
// Get the input schema for a specific action
|
|
193
|
+
// Returns a JSON Schema Draft 2020-12 schema as a string
|
|
194
|
+
// The schema may vary based on the connection in the context
|
|
195
|
+
// The action-id is extracted from the context
|
|
196
|
+
input-schema: func(context: action-context) -> result<string, app-error>;
|
|
197
|
+
|
|
198
|
+
// Get the output schema for a specific action
|
|
199
|
+
// Returns a JSON Schema Draft 2020-12 schema as a string
|
|
200
|
+
// The schema may vary based on the connection in the context
|
|
201
|
+
// The action-id is extracted from the context
|
|
202
|
+
output-schema: func(context: action-context) -> result<string, app-error>;
|
|
203
|
+
|
|
204
|
+
// Execute an action
|
|
205
|
+
//
|
|
206
|
+
// There are some limitations to the function:
|
|
207
|
+
// - It must return an `action-response` within 30 seconds
|
|
208
|
+
// - The serialized-output must be a valid JSON object serialized as a string
|
|
209
|
+
//
|
|
210
|
+
// Actions can perform various operations such as:
|
|
211
|
+
// - Making HTTP requests to external APIs
|
|
212
|
+
// - Processing and transforming data
|
|
213
|
+
// - Storing data for future use
|
|
214
|
+
// - Triggering other systems or workflows
|
|
215
|
+
//
|
|
216
|
+
// The action receives input data from the previous step and can return
|
|
217
|
+
// serialized output data to be passed to the next step in the workflow.
|
|
218
|
+
execute: func(context: action-context) -> result<action-response, app-error>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface environment {
|
|
222
|
+
// Get all environment variables
|
|
223
|
+
env-vars: func() -> list<tuple<string, string>>;
|
|
224
|
+
// Get a specific environment variable by name
|
|
225
|
+
env-var: func(name: string) -> option<string>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
interface http {
|
|
229
|
+
record response {
|
|
230
|
+
status: u16,
|
|
231
|
+
headers: headers,
|
|
232
|
+
body: string,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
record request {
|
|
236
|
+
method: method,
|
|
237
|
+
url: string,
|
|
238
|
+
headers: headers,
|
|
239
|
+
body: string,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
variant request-error {
|
|
243
|
+
other(string)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
type headers = list<tuple<string, string>>;
|
|
247
|
+
|
|
248
|
+
resource request-builder {
|
|
249
|
+
constructor();
|
|
250
|
+
|
|
251
|
+
method: func(method: method) -> request-builder;
|
|
252
|
+
url: func(url: string) -> request-builder;
|
|
253
|
+
|
|
254
|
+
// Add a header to the request
|
|
255
|
+
header: func(key: string, value: string) -> request-builder;
|
|
256
|
+
headers: func(headers: list<tuple<string, string>>) -> request-builder;
|
|
257
|
+
|
|
258
|
+
// Add a body to the request
|
|
259
|
+
body: func(body: string) -> request-builder;
|
|
260
|
+
|
|
261
|
+
object: func() -> request;
|
|
262
|
+
|
|
263
|
+
// Send the request
|
|
264
|
+
send: func() -> result<response, request-error>;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
variant method {
|
|
268
|
+
get,
|
|
269
|
+
post,
|
|
270
|
+
put,
|
|
271
|
+
delete,
|
|
272
|
+
patch,
|
|
273
|
+
options,
|
|
274
|
+
head,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
interface file {
|
|
279
|
+
// HTTP headers for file requests (same as http interface)
|
|
280
|
+
type headers = list<tuple<string, string>>;
|
|
281
|
+
|
|
282
|
+
// Normalized file data
|
|
283
|
+
record file-data {
|
|
284
|
+
// Base64-encoded file content
|
|
285
|
+
base64: string,
|
|
286
|
+
// MIME type (e.g., "application/pdf")
|
|
287
|
+
content-type: string,
|
|
288
|
+
// Filename
|
|
289
|
+
filename: string,
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
variant file-error {
|
|
293
|
+
// Failed to fetch file from URL
|
|
294
|
+
fetch-failed(string),
|
|
295
|
+
// Invalid input format (not a valid URL, data URI, or base64)
|
|
296
|
+
invalid-input(string),
|
|
297
|
+
// Request timed out
|
|
298
|
+
timeout(string),
|
|
299
|
+
// Any other error
|
|
300
|
+
other(string),
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Normalize any file source to FileData
|
|
304
|
+
//
|
|
305
|
+
// The source is automatically detected:
|
|
306
|
+
// - URL: "https://example.com/file.pdf" - fetched with optional headers
|
|
307
|
+
// - Data URI: "data:application/pdf;base64,JVBERi0..." - parsed and extracted
|
|
308
|
+
// - Base64: Any other string is treated as raw base64 - decoded to detect type
|
|
309
|
+
//
|
|
310
|
+
// Parameters:
|
|
311
|
+
// - source: URL, data URI, or base64-encoded content
|
|
312
|
+
// - headers: Optional HTTP headers for URL requests (e.g., Authorization)
|
|
313
|
+
// - filename: Optional filename override (auto-detected if not provided)
|
|
314
|
+
//
|
|
315
|
+
// Returns file-data which will be processed by the platform:
|
|
316
|
+
// 1. Fields with format: "file-output" in the output schema are identified
|
|
317
|
+
// 2. File data is uploaded using the configured file_uploader
|
|
318
|
+
// 3. The file-data is replaced with the blob ID in the response
|
|
319
|
+
normalize: func(source: string, headers: option<headers>, filename: option<string>) -> result<file-data, file-error>;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
world bridge {
|
|
323
|
+
import http;
|
|
324
|
+
import environment;
|
|
325
|
+
import file;
|
|
326
|
+
export triggers;
|
|
327
|
+
export actions;
|
|
328
|
+
}
|
|
Binary file
|
|
Binary file
|
data/lib/app_bridge/app.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require "timeout"
|
|
4
5
|
|
|
5
6
|
module AppBridge
|
|
@@ -25,9 +26,13 @@ module AppBridge
|
|
|
25
26
|
def execute_action(context)
|
|
26
27
|
response = request_action_with_timeout(context)
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
# Process files: find file-output fields in schema and replace with blob IDs
|
|
30
|
+
processed_output = process_files(response.serialized_output, context)
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
validate_action_response_size!(processed_output)
|
|
33
|
+
|
|
34
|
+
# Return new response with processed output
|
|
35
|
+
response.with_output(processed_output)
|
|
31
36
|
end
|
|
32
37
|
|
|
33
38
|
def timeout_seconds
|
|
@@ -65,5 +70,19 @@ module AppBridge
|
|
|
65
70
|
_rust_fetch_events(context)
|
|
66
71
|
end
|
|
67
72
|
end
|
|
73
|
+
|
|
74
|
+
# Process files in the response based on output schema
|
|
75
|
+
def process_files(serialized_output, context)
|
|
76
|
+
data = JSON.parse(serialized_output)
|
|
77
|
+
schema = parse_output_schema(context)
|
|
78
|
+
JSON.generate(FileProcessor.call(data, schema))
|
|
79
|
+
rescue JSON::ParserError
|
|
80
|
+
# Schema parsing failed, return original output
|
|
81
|
+
serialized_output
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def parse_output_schema(context)
|
|
85
|
+
JSON.parse(action_output_schema(context))
|
|
86
|
+
end
|
|
68
87
|
end
|
|
69
88
|
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module AppBridge
|
|
6
|
+
# Processes file data from WASM component responses.
|
|
7
|
+
#
|
|
8
|
+
# Uses the output schema to find fields with format: "file-output" and
|
|
9
|
+
# replaces the file data (base64, content_type, filename) with blob IDs
|
|
10
|
+
# via the configured file_uploader.
|
|
11
|
+
#
|
|
12
|
+
# The WASM component should use file.read to normalize any input format
|
|
13
|
+
# (URL, data URI, raw base64) into a consistent hash structure before output.
|
|
14
|
+
class FileProcessor
|
|
15
|
+
FILE_OUTPUT_FORMAT = "file-output"
|
|
16
|
+
|
|
17
|
+
# Process file data in a response hash.
|
|
18
|
+
#
|
|
19
|
+
# @param data [Hash] The response data from a WASM component
|
|
20
|
+
# @param schema [Hash] The JSON schema for the response data
|
|
21
|
+
# @return [Hash] The processed data with file IDs instead of file data
|
|
22
|
+
def self.call(data, schema)
|
|
23
|
+
new(data, schema).call
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param data [Hash] The response data from a WASM component
|
|
27
|
+
# @param schema [Hash] The JSON schema for the response data
|
|
28
|
+
def initialize(data, schema)
|
|
29
|
+
@data = deep_dup(data)
|
|
30
|
+
@schema = schema
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Processes the data, uploading files and replacing file data with IDs.
|
|
34
|
+
#
|
|
35
|
+
# @return [Hash] The processed data with file IDs
|
|
36
|
+
def call
|
|
37
|
+
process_node(@data, @schema)
|
|
38
|
+
@data
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def process_node(data_node, schema_node)
|
|
44
|
+
return unless data_node.is_a?(Hash) && schema_node.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
properties = schema_node["properties"]
|
|
47
|
+
return unless properties.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
properties.each do |key, sub_schema|
|
|
50
|
+
process_property(data_node, key, sub_schema)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def process_property(data_node, key, sub_schema)
|
|
55
|
+
data_value, actual_key = find_data_value(data_node, key)
|
|
56
|
+
return if data_value.nil?
|
|
57
|
+
|
|
58
|
+
if sub_schema["format"] == FILE_OUTPUT_FORMAT
|
|
59
|
+
process_file_field(data_node, actual_key, data_value)
|
|
60
|
+
elsif array_schema?(sub_schema)
|
|
61
|
+
process_array_field(data_value, sub_schema)
|
|
62
|
+
elsif data_value.is_a?(Hash)
|
|
63
|
+
process_node(data_value, sub_schema)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def find_data_value(data_node, key)
|
|
68
|
+
str_key = key.to_s
|
|
69
|
+
return [nil, nil] unless data_node.key?(str_key)
|
|
70
|
+
|
|
71
|
+
[data_node[str_key], str_key]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def process_file_field(data_node, actual_key, data_value)
|
|
75
|
+
return unless file_data?(data_value)
|
|
76
|
+
|
|
77
|
+
blob_id = upload_file(data_value)
|
|
78
|
+
data_node[actual_key] = blob_id
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def array_schema?(sub_schema)
|
|
82
|
+
sub_schema["type"] == "array" && sub_schema["items"].is_a?(Hash)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def process_array_field(data_value, sub_schema)
|
|
86
|
+
return unless data_value.is_a?(Array)
|
|
87
|
+
|
|
88
|
+
items_schema = sub_schema["items"]
|
|
89
|
+
data_value.each_with_index do |item, index|
|
|
90
|
+
if items_schema["format"] == FILE_OUTPUT_FORMAT && file_data?(item)
|
|
91
|
+
data_value[index] = upload_file(item)
|
|
92
|
+
else
|
|
93
|
+
process_node(item, items_schema)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def file_data?(value)
|
|
99
|
+
return false unless value.is_a?(Hash)
|
|
100
|
+
|
|
101
|
+
present?(value["base64"]) && present?(value["filename"])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def upload_file(file_data)
|
|
105
|
+
result = AppBridge.file_uploader.call(file_data)
|
|
106
|
+
# If uploader returns nil (no uploader configured), leave data unchanged
|
|
107
|
+
result.nil? ? file_data : result
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
raise AppBridge::InternalError, "Failed to upload '#{file_data["filename"]}': #{e.message}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def present?(value)
|
|
113
|
+
!value.nil? && !value.to_s.strip.empty?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Deep duplicates the object and normalizes all hash keys to strings
|
|
117
|
+
def deep_dup(obj)
|
|
118
|
+
case obj
|
|
119
|
+
when Hash then obj.transform_keys(&:to_s).transform_values { |v| deep_dup(v) }
|
|
120
|
+
when Array then obj.map { |v| deep_dup(v) }
|
|
121
|
+
else safe_dup(obj)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def safe_dup(obj)
|
|
126
|
+
obj.dup
|
|
127
|
+
rescue TypeError
|
|
128
|
+
obj
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
data/lib/app_bridge/version.rb
CHANGED
data/lib/app_bridge.rb
CHANGED
|
@@ -2,13 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "app_bridge/version"
|
|
4
4
|
require_relative "app_bridge/app"
|
|
5
|
+
require_relative "app_bridge/file_processor"
|
|
5
6
|
|
|
7
|
+
# Communication layer for Standout integration apps using WebAssembly components.
|
|
6
8
|
module AppBridge
|
|
7
9
|
class Error < StandardError; end
|
|
8
10
|
class TimeoutError < Error; end
|
|
9
11
|
class TooManyEventsError < Error; end
|
|
10
12
|
class StoreTooLargeError < Error; end
|
|
11
13
|
class ActionResponseTooLargeError < Error; end
|
|
14
|
+
class FileUploadError < Error; end
|
|
15
|
+
class InternalError < Error; end
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Configurable file uploader callback.
|
|
19
|
+
# The platform should set this to handle file uploads and return an ID.
|
|
20
|
+
#
|
|
21
|
+
# @example Configure with ActiveStorage
|
|
22
|
+
# AppBridge.file_uploader = ->(file_data) {
|
|
23
|
+
# content = Base64.decode64(file_data['base64'])
|
|
24
|
+
# blob = ActiveStorage::Blob.create_and_upload!(
|
|
25
|
+
# io: StringIO.new(content),
|
|
26
|
+
# filename: file_data['filename'],
|
|
27
|
+
# content_type: file_data['content_type'] || 'application/octet-stream'
|
|
28
|
+
# )
|
|
29
|
+
# blob.signed_id # Returns just the ID
|
|
30
|
+
# }
|
|
31
|
+
attr_accessor :file_uploader
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Default no-op uploader (returns nil - no file storage configured)
|
|
35
|
+
# rubocop:disable Style/NilLambda
|
|
36
|
+
self.file_uploader = ->(_file_data) { nil }
|
|
37
|
+
# rubocop:enable Style/NilLambda
|
|
12
38
|
|
|
13
39
|
# Represents a trigger event that is recieved from the app.
|
|
14
40
|
class TriggerEvent
|
data/tasks/fixtures.rake
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "English"
|
|
4
4
|
|
|
5
|
-
namespace :fixtures do
|
|
5
|
+
namespace :fixtures do
|
|
6
6
|
namespace :apps do
|
|
7
7
|
desc "Clean up build artifacts"
|
|
8
8
|
task :clean do
|
|
@@ -37,8 +37,22 @@ namespace :fixtures do # rubocop:disable Metrics/BlockLength
|
|
|
37
37
|
Process.wait(pid)
|
|
38
38
|
raise "Failed to build artifacts" unless $CHILD_STATUS.success?
|
|
39
39
|
end
|
|
40
|
+
|
|
41
|
+
desc "Compile the v3 fixture app (for backward compatibility testing)"
|
|
42
|
+
task :compile_rust_v3 do
|
|
43
|
+
pwd = "spec/fixtures/components/rust_app_v3"
|
|
44
|
+
next unless File.exist?(pwd)
|
|
45
|
+
|
|
46
|
+
compile_pid = Process.spawn("cargo clean && cargo build --release --target wasm32-wasip2",
|
|
47
|
+
chdir: pwd)
|
|
48
|
+
Process.wait(compile_pid)
|
|
49
|
+
raise "Failed to build v3 artifacts" unless $CHILD_STATUS.success?
|
|
50
|
+
|
|
51
|
+
move_pid = Process.spawn("mv #{pwd}/target/wasm32-wasip2/release/rust_app_v3.wasm #{pwd}/../rust_app_v3.wasm")
|
|
52
|
+
Process.wait(move_pid)
|
|
53
|
+
end
|
|
40
54
|
end
|
|
41
55
|
end
|
|
42
56
|
|
|
43
57
|
desc "Build all fixtures"
|
|
44
|
-
task fixtures: %i[fixtures:apps:clean fixtures:apps:compile_rust fixtures:apps:compile_js]
|
|
58
|
+
task fixtures: %i[fixtures:apps:clean fixtures:apps:compile_rust fixtures:apps:compile_js fixtures:apps:compile_rust_v3]
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: app_bridge
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 4.0.0
|
|
5
5
|
platform: arm64-darwin
|
|
6
6
|
authors:
|
|
7
7
|
- Alexander Ross
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-01-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: The app_bridge gem is designed to enable seamless interaction with WebAssembly
|
|
14
14
|
components that adhere to the WIT specification `standout:app`. It is developed
|
|
@@ -26,11 +26,13 @@ files:
|
|
|
26
26
|
- CHANGELOG.md
|
|
27
27
|
- README.md
|
|
28
28
|
- Rakefile
|
|
29
|
-
- ext/app_bridge/wit/world.wit
|
|
29
|
+
- ext/app_bridge/wit/v3/world.wit
|
|
30
|
+
- ext/app_bridge/wit/v4/world.wit
|
|
30
31
|
- lib/app_bridge.rb
|
|
31
32
|
- lib/app_bridge/3.2/app_bridge.bundle
|
|
32
33
|
- lib/app_bridge/3.4/app_bridge.bundle
|
|
33
34
|
- lib/app_bridge/app.rb
|
|
35
|
+
- lib/app_bridge/file_processor.rb
|
|
34
36
|
- lib/app_bridge/version.rb
|
|
35
37
|
- sig/app_bridge.rbs
|
|
36
38
|
- tasks/fixtures.rake
|