app_bridge 3.0.0-aarch64-linux → 4.1.0-aarch64-linux

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 045a7796fa110808d6c6529172c91514ab963bc2d7793f4bdda294ddbbbe60a7
4
- data.tar.gz: 5fc04fb84855d00f6f0778262ef7e900d469622e4203b062cee3d96b32f1320c
3
+ metadata.gz: cd334ce501f49a41f2a323dcea31540dbdce65ff0a4d750b1ca3871ec491121f
4
+ data.tar.gz: f21ba99a8807c7894e1871321d4ff830daf37dde2fde7768d3af908618061ec5
5
5
  SHA512:
6
- metadata.gz: 92a182f5c98b37c1f3f54da8012ee515804f0e6c98a559427c0d8c2164858ada027b5508001c4399fa50cb7e4bda30066b78b97a0cce87322cac33f4c0f3ec7e
7
- data.tar.gz: e1ae9d4d23553b1be63b90b4ab7ad0e4ccfde7ed78df5de8df57e640886f7601057c245e976c0fa371d51aa2c389b64d99972b914fd193c3f0ecfe644ac944a5
6
+ metadata.gz: 8296e3014067a1a29917fd4139664cb331c77cfe419a234b4cc8f14f7f5491b80868f8272a278edcf41b5d25b7cd0459134b0eae2632b844072e00852bcd273d
7
+ data.tar.gz: 52eed814e20ef18341f7f6127d9a7aa1038be4f53cbc1d949107ebc91cac01d4883b8100729697ee44478d6a72f9dcb9e87692b472828b649039179dadcfec98
data/.rubocop.yml CHANGED
@@ -13,3 +13,4 @@ Metrics/BlockLength:
13
13
  Exclude:
14
14
  - 'spec/**/*_spec.rb'
15
15
  - '*.gemspec'
16
+ - 'tasks/*.rake'
data/.tool-versions CHANGED
@@ -1 +1 @@
1
- ruby 3.4.2
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,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
 
@@ -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
+ }
@@ -0,0 +1,343 @@
1
+ package standout:app@4.1.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
+ // Optional reference information when the platform retries an action.
53
+ reference-object: option<reference-object>,
54
+ }
55
+
56
+ record trigger-response {
57
+ // The trigger events, each event will be used to spawn a new workflow
58
+ // execution in Standouts integration platform.
59
+ events: list<trigger-event>,
60
+
61
+ // The updated store will be stored and used the next time the trigger is
62
+ // invoked.
63
+ store: trigger-store,
64
+ }
65
+
66
+ record action-response {
67
+ // The output data from the action, serialized as a JSON object string.
68
+ // This contains the data that will be passed to the next step in the workflow.
69
+ // The data must be a valid JSON object (not an array or primitive).
70
+ serialized-output: string
71
+ }
72
+
73
+ record trigger-event {
74
+ // The ID of the trigger event
75
+ //
76
+ // If the connection used for the given instance of the trigger is the same,
77
+ // as seen before. Then the event will be ignored.
78
+ //
79
+ // A scheduler could therefore use a timestamp as the ID, to ensure that
80
+ // the event is only triggered once per given time.
81
+ //
82
+ // A trigger that acts on created orders in a e-commerce system could use
83
+ // the order ID as the ID, to ensure that the event is only triggered once
84
+ // per order.
85
+ //
86
+ // A trigger that acts on updated orders in a e-commerce system could use
87
+ // the order ID in combination with an updated at timestamp as the ID, to
88
+ // ensure that the event is only triggered once per order update.
89
+ id: string,
90
+
91
+ // Serialized data must be a JSON object serialized into a string
92
+ // Note that it is important that the root is an object, not an array,
93
+ // or another primitive type.
94
+ serialized-data: string,
95
+ }
96
+
97
+ /// Retry reference payload returned with error-code.retry-with-reference.
98
+ record reference-object {
99
+ /// Reference ID provided for retrying this request later.
100
+ reference: string,
101
+
102
+ /// Status describing the retry state.
103
+ status: string,
104
+ }
105
+
106
+ record app-error {
107
+ /// The error code identifying the type of failure.
108
+ code: error-code,
109
+
110
+ /// A human-readable message describing the error in more detail.
111
+ message: string,
112
+ }
113
+
114
+ /// An enumeration of error codes that can be returned by a trigger implementation.
115
+ /// These codes help the platform and plugin developers distinguish between different types of failures.
116
+ variant error-code {
117
+ /// Authentication failed. Typically due to an invalid or expired API key or token.
118
+ unauthenticated,
119
+
120
+ /// Authorization failed. The connection is valid but does not have the necessary permissions.
121
+ forbidden,
122
+
123
+ /// The trigger is misconfigured. For example, a required setting is missing or invalid.
124
+ misconfigured,
125
+
126
+ /// The target system does not support a required feature or endpoint.
127
+ unsupported,
128
+
129
+ /// The target system is rate-limiting requests. Try again later.
130
+ rate-limit,
131
+
132
+ /// The request timed out. The target system did not respond in time.
133
+ timeout,
134
+
135
+ /// The target system is currently unavailable or unreachable.
136
+ unavailable,
137
+
138
+ /// An unexpected internal error occurred in the plugin.
139
+ internal-error,
140
+
141
+ /// The response from the external system could not be parsed or was in an invalid format.
142
+ malformed-response,
143
+
144
+ /// A catch-all for all other types of errors. Should include a descriptive message.
145
+ other,
146
+
147
+ /// Retry the request using a reference identifier.
148
+ retry-with-reference(reference-object),
149
+
150
+ /// Complete the current workflow execution.
151
+ complete-workflow,
152
+
153
+ /// Complete the parent step execution.
154
+ complete-parent,
155
+ }
156
+ }
157
+
158
+
159
+ interface triggers {
160
+ use types.{trigger-context, trigger-event, trigger-response, app-error};
161
+
162
+ trigger-ids: func() -> result<list<string>, app-error>;
163
+
164
+ // Get the input schema for a specific trigger
165
+ // Returns a JSON Schema Draft 2020-12 schema as a string
166
+ // The schema may vary based on the connection in the context
167
+ // The trigger-id is extracted from the context
168
+ input-schema: func(context: trigger-context) -> result<string, app-error>;
169
+
170
+ // Get the output schema for a specific trigger
171
+ // Returns a JSON Schema Draft 2020-12 schema as a string
172
+ // The schema may vary based on the connection in the context
173
+ // The trigger-id is extracted from the context
174
+ output-schema: func(context: trigger-context) -> result<string, app-error>;
175
+
176
+ // Fetch events
177
+ //
178
+ // There are some limitations to the function:
179
+ // - It must return a `trigger-response` within 30 seconds
180
+ // - It must return less than or equal to 100 `trigger-response.events`
181
+ // - It must not return more than 64 kB of data in the `trigger-response.store`
182
+ //
183
+ // If you need to fetch more events, you can return up to 100 events and then
184
+ // store the data needed for you to remember where you left off in the store.
185
+ // The next time the trigger is invoked, you can use the store to continue
186
+ // where you left off.
187
+ //
188
+ // If you do not pass the limitations the return value will be ignored. We
189
+ // will not handle any events and we persist the store that was returned in
190
+ // the response.
191
+ //
192
+ // That also means that you should implement your fetch event function in a
193
+ // way that it can be called multiple times using the same context and return
194
+ // the same events. That will ensure that the user that is building an
195
+ // integration with your trigger will not miss any events if your system is
196
+ // down for a short period of time.
197
+ fetch-events: func(context: trigger-context) -> result<trigger-response, app-error>;
198
+ }
199
+
200
+ interface actions {
201
+ use types.{action-context, action-response, app-error};
202
+
203
+ action-ids: func() -> result<list<string>, app-error>;
204
+
205
+ // Get the input schema for a specific action
206
+ // Returns a JSON Schema Draft 2020-12 schema as a string
207
+ // The schema may vary based on the connection in the context
208
+ // The action-id is extracted from the context
209
+ input-schema: func(context: action-context) -> result<string, app-error>;
210
+
211
+ // Get the output schema for a specific action
212
+ // Returns a JSON Schema Draft 2020-12 schema as a string
213
+ // The schema may vary based on the connection in the context
214
+ // The action-id is extracted from the context
215
+ output-schema: func(context: action-context) -> result<string, app-error>;
216
+
217
+ // Execute an action
218
+ //
219
+ // There are some limitations to the function:
220
+ // - It must return an `action-response` within 30 seconds
221
+ // - The serialized-output must be a valid JSON object serialized as a string
222
+ //
223
+ // Actions can perform various operations such as:
224
+ // - Making HTTP requests to external APIs
225
+ // - Processing and transforming data
226
+ // - Storing data for future use
227
+ // - Triggering other systems or workflows
228
+ //
229
+ // The action receives input data from the previous step and can return
230
+ // serialized output data to be passed to the next step in the workflow.
231
+ execute: func(context: action-context) -> result<action-response, app-error>;
232
+ }
233
+
234
+ interface environment {
235
+ // Get all environment variables
236
+ env-vars: func() -> list<tuple<string, string>>;
237
+ // Get a specific environment variable by name
238
+ env-var: func(name: string) -> option<string>;
239
+ }
240
+
241
+ interface http {
242
+ record response {
243
+ status: u16,
244
+ headers: headers,
245
+ body: string,
246
+ }
247
+
248
+ record request {
249
+ method: method,
250
+ url: string,
251
+ headers: headers,
252
+ body: string,
253
+ }
254
+
255
+ variant request-error {
256
+ other(string)
257
+ }
258
+
259
+ type headers = list<tuple<string, string>>;
260
+
261
+ resource request-builder {
262
+ constructor();
263
+
264
+ method: func(method: method) -> request-builder;
265
+ url: func(url: string) -> request-builder;
266
+
267
+ // Add a header to the request
268
+ header: func(key: string, value: string) -> request-builder;
269
+ headers: func(headers: list<tuple<string, string>>) -> request-builder;
270
+
271
+ // Add a body to the request
272
+ body: func(body: string) -> request-builder;
273
+ // Add a binary body to the request
274
+ body-bytes: func(body: list<u8>) -> request-builder;
275
+
276
+ object: func() -> request;
277
+
278
+ // Send the request
279
+ send: func() -> result<response, request-error>;
280
+ }
281
+
282
+ variant method {
283
+ get,
284
+ post,
285
+ put,
286
+ delete,
287
+ patch,
288
+ options,
289
+ head,
290
+ }
291
+ }
292
+
293
+ interface file {
294
+ // HTTP headers for file requests (same as http interface)
295
+ type headers = list<tuple<string, string>>;
296
+
297
+ // Normalized file data
298
+ record file-data {
299
+ // Base64-encoded file content
300
+ base64: string,
301
+ // MIME type (e.g., "application/pdf")
302
+ content-type: string,
303
+ // Filename
304
+ filename: string,
305
+ }
306
+
307
+ variant file-error {
308
+ // Failed to fetch file from URL
309
+ fetch-failed(string),
310
+ // Invalid input format (not a valid URL, data URI, or base64)
311
+ invalid-input(string),
312
+ // Request timed out
313
+ timeout(string),
314
+ // Any other error
315
+ other(string),
316
+ }
317
+
318
+ // Normalize any file source to FileData
319
+ //
320
+ // The source is automatically detected:
321
+ // - URL: "https://example.com/file.pdf" - fetched with optional headers
322
+ // - Data URI: "data:application/pdf;base64,JVBERi0..." - parsed and extracted
323
+ // - Base64: Any other string is treated as raw base64 - decoded to detect type
324
+ //
325
+ // Parameters:
326
+ // - source: URL, data URI, or base64-encoded content
327
+ // - headers: Optional HTTP headers for URL requests (e.g., Authorization)
328
+ // - filename: Optional filename override (auto-detected if not provided)
329
+ //
330
+ // Returns file-data which will be processed by the platform:
331
+ // 1. Fields with format: "file-output" in the output schema are identified
332
+ // 2. File data is uploaded using the configured file_uploader
333
+ // 3. The file-data is replaced with the blob ID in the response
334
+ normalize: func(source: string, headers: option<headers>, filename: option<string>) -> result<file-data, file-error>;
335
+ }
336
+
337
+ world bridge {
338
+ import http;
339
+ import environment;
340
+ import file;
341
+ export triggers;
342
+ export actions;
343
+ }
Binary file
Binary file
@@ -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
- validate_action_response_size!(response.serialized_output)
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
- response
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
@@ -4,5 +4,5 @@
4
4
  # ext/app_bridge/Cargo.toml file to keep them in sync.
5
5
 
6
6
  module AppBridge
7
- VERSION = "3.0.0"
7
+ VERSION = "4.1.0"
8
8
  end
data/lib/app_bridge.rb CHANGED
@@ -2,13 +2,38 @@
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 InternalError < Error; end
15
+
16
+ class << self
17
+ # Configurable file uploader callback.
18
+ # The platform should set this to handle file uploads and return an ID.
19
+ #
20
+ # @example Configure with ActiveStorage
21
+ # AppBridge.file_uploader = ->(file_data) {
22
+ # content = Base64.decode64(file_data['base64'])
23
+ # blob = ActiveStorage::Blob.create_and_upload!(
24
+ # io: StringIO.new(content),
25
+ # filename: file_data['filename'],
26
+ # content_type: file_data['content_type'] || 'application/octet-stream'
27
+ # )
28
+ # blob.signed_id # Returns just the ID
29
+ # }
30
+ attr_accessor :file_uploader
31
+ end
32
+
33
+ # Default no-op uploader (returns nil - no file storage configured)
34
+ # rubocop:disable Style/NilLambda
35
+ self.file_uploader = ->(_file_data) { nil }
36
+ # rubocop:enable Style/NilLambda
12
37
 
13
38
  # Represents a trigger event that is recieved from the app.
14
39
  class TriggerEvent
data/tasks/fixtures.rake CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "English"
4
4
 
5
- namespace :fixtures do # rubocop:disable Metrics/BlockLength
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: 3.0.0
4
+ version: 4.1.0
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - Alexander Ross
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-10-29 00:00:00.000000000 Z
11
+ date: 2026-02-10 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,14 @@ 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
31
+ - ext/app_bridge/wit/v4_1/world.wit
30
32
  - lib/app_bridge.rb
31
33
  - lib/app_bridge/3.2/app_bridge.so
32
34
  - lib/app_bridge/3.4/app_bridge.so
33
35
  - lib/app_bridge/app.rb
36
+ - lib/app_bridge/file_processor.rb
34
37
  - lib/app_bridge/version.rb
35
38
  - sig/app_bridge.rbs
36
39
  - tasks/fixtures.rake