app_bridge 3.0.0 → 4.0.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.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -0
- data/.tool-versions +1 -1
- data/Cargo.lock +292 -751
- data/Cargo.toml +1 -1
- data/README.md +213 -2
- data/ext/app_bridge/Cargo.toml +8 -4
- data/ext/app_bridge/src/app_state.rs +29 -13
- data/ext/app_bridge/src/component.rs +256 -46
- data/ext/app_bridge/src/error_mapping.rs +24 -24
- data/ext/app_bridge/src/file_ops.rs +325 -0
- data/ext/app_bridge/src/lib.rs +5 -1
- data/ext/app_bridge/src/request_builder.rs +270 -152
- data/ext/app_bridge/src/types.rs +78 -0
- data/ext/app_bridge/src/wrappers/action_context.rs +3 -3
- data/ext/app_bridge/src/wrappers/action_response.rs +7 -3
- data/ext/app_bridge/src/wrappers/app.rs +112 -148
- data/ext/app_bridge/src/wrappers/connection.rs +4 -4
- data/ext/app_bridge/src/wrappers/trigger_context.rs +7 -7
- data/ext/app_bridge/src/wrappers/trigger_event.rs +4 -4
- data/ext/app_bridge/src/wrappers/trigger_response.rs +29 -28
- 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/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 +8 -4
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
use crate::
|
|
2
|
-
use magnus::{
|
|
1
|
+
use crate::types::{AppError, ErrorCode};
|
|
2
|
+
use magnus::{Error, ExceptionClass, Ruby};
|
|
3
3
|
|
|
4
4
|
impl From<ErrorCode> for ExceptionClass {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
fn from(value: ErrorCode) -> Self {
|
|
6
|
+
fn get_class(name: &str) -> ExceptionClass {
|
|
7
|
+
let ruby = Ruby::get().unwrap();
|
|
8
|
+
ruby.eval::<ExceptionClass>(name).unwrap()
|
|
9
|
+
}
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
11
|
+
match value {
|
|
12
|
+
ErrorCode::Unauthenticated => get_class("AppBridge::UnauthenticatedError"),
|
|
13
|
+
ErrorCode::Forbidden => get_class("AppBridge::ForbiddenError"),
|
|
14
|
+
ErrorCode::Misconfigured => get_class("AppBridge::MisconfiguredError"),
|
|
15
|
+
ErrorCode::Unsupported => get_class("AppBridge::UnsupportedError"),
|
|
16
|
+
ErrorCode::RateLimit => get_class("AppBridge::RateLimitError"),
|
|
17
|
+
ErrorCode::Timeout => get_class("AppBridge::TimeoutError"),
|
|
18
|
+
ErrorCode::Unavailable => get_class("AppBridge::UnavailableError"),
|
|
19
|
+
ErrorCode::InternalError => get_class("AppBridge::InternalError"),
|
|
20
|
+
ErrorCode::MalformedResponse => get_class("AppBridge::MalformedResponseError"),
|
|
21
|
+
ErrorCode::Other => get_class("AppBridge::OtherError"),
|
|
22
|
+
ErrorCode::CompleteWorkflow => get_class("AppBridge::CompleteWorkflowException"),
|
|
23
|
+
ErrorCode::CompleteParent => get_class("AppBridge::CompleteParentException"),
|
|
24
|
+
}
|
|
24
25
|
}
|
|
25
|
-
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
impl From<AppError> for Error {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
fn from(value: AppError) -> Self {
|
|
30
|
+
Error::new(value.code.into(), value.message)
|
|
31
|
+
}
|
|
32
32
|
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
use crate::app_state::AppState;
|
|
2
|
+
use crate::component::v4;
|
|
3
|
+
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
|
4
|
+
|
|
5
|
+
/// Detects the type of input string
|
|
6
|
+
enum InputType {
|
|
7
|
+
/// HTTP or HTTPS URL
|
|
8
|
+
Url,
|
|
9
|
+
/// Data URI (data:content-type;base64,...)
|
|
10
|
+
DataUri,
|
|
11
|
+
/// Raw base64 encoded string
|
|
12
|
+
Base64,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
fn detect_input_type(input: &str) -> InputType {
|
|
16
|
+
let trimmed = input.trim();
|
|
17
|
+
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
|
18
|
+
InputType::Url
|
|
19
|
+
} else if trimmed.starts_with("data:") {
|
|
20
|
+
InputType::DataUri
|
|
21
|
+
} else {
|
|
22
|
+
InputType::Base64
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Extracts filename from a URL path
|
|
27
|
+
fn filename_from_url(url: &str) -> Option<String> {
|
|
28
|
+
url.split('?')
|
|
29
|
+
.next()
|
|
30
|
+
.and_then(|path| path.split('/').last())
|
|
31
|
+
.filter(|name| !name.is_empty() && name.contains('.'))
|
|
32
|
+
.map(|s| s.to_string())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Generates a filename based on content type
|
|
36
|
+
fn filename_from_content_type(content_type: &str) -> String {
|
|
37
|
+
let extension = match content_type {
|
|
38
|
+
"application/pdf" => "pdf",
|
|
39
|
+
"application/json" => "json",
|
|
40
|
+
"application/xml" => "xml",
|
|
41
|
+
"application/zip" => "zip",
|
|
42
|
+
"application/gzip" => "gz",
|
|
43
|
+
"image/jpeg" => "jpg",
|
|
44
|
+
"image/png" => "png",
|
|
45
|
+
"image/gif" => "gif",
|
|
46
|
+
"image/webp" => "webp",
|
|
47
|
+
"image/svg+xml" => "svg",
|
|
48
|
+
"text/plain" => "txt",
|
|
49
|
+
"text/html" => "html",
|
|
50
|
+
"text/css" => "css",
|
|
51
|
+
"text/javascript" => "js",
|
|
52
|
+
"text/csv" => "csv",
|
|
53
|
+
"audio/mpeg" => "mp3",
|
|
54
|
+
"audio/wav" => "wav",
|
|
55
|
+
"video/mp4" => "mp4",
|
|
56
|
+
"video/webm" => "webm",
|
|
57
|
+
_ => "bin",
|
|
58
|
+
};
|
|
59
|
+
format!("file.{}", extension)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// Detects content type from bytes using magic number detection
|
|
63
|
+
fn detect_content_type(bytes: &[u8]) -> String {
|
|
64
|
+
infer::get(bytes)
|
|
65
|
+
.map(|kind| kind.mime_type().to_string())
|
|
66
|
+
.unwrap_or_else(|| "application/octet-stream".to_string())
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ============================================================================
|
|
70
|
+
// Internal error type (version-agnostic)
|
|
71
|
+
// ============================================================================
|
|
72
|
+
|
|
73
|
+
/// Internal error type for file operations - converted to version-specific errors by macros
|
|
74
|
+
#[derive(Debug)]
|
|
75
|
+
#[allow(dead_code)] // Timeout and Other are needed for WIT compatibility but not currently used
|
|
76
|
+
enum NormalizeError {
|
|
77
|
+
FetchFailed(String),
|
|
78
|
+
InvalidInput(String),
|
|
79
|
+
Timeout(String),
|
|
80
|
+
Other(String),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Parses a data URI and returns (content_type, decoded_bytes)
|
|
84
|
+
fn parse_data_uri(data_uri: &str) -> Result<(String, Vec<u8>), NormalizeError> {
|
|
85
|
+
// Format: data:[<mediatype>][;base64],<data>
|
|
86
|
+
let without_prefix = data_uri
|
|
87
|
+
.strip_prefix("data:")
|
|
88
|
+
.ok_or_else(|| NormalizeError::InvalidInput("Invalid data URI format".to_string()))?;
|
|
89
|
+
|
|
90
|
+
let (metadata, data) = without_prefix
|
|
91
|
+
.split_once(',')
|
|
92
|
+
.ok_or_else(|| NormalizeError::InvalidInput("Data URI missing comma separator".to_string()))?;
|
|
93
|
+
|
|
94
|
+
let is_base64 = metadata.ends_with(";base64");
|
|
95
|
+
let content_type = if is_base64 {
|
|
96
|
+
metadata.strip_suffix(";base64").unwrap_or("application/octet-stream")
|
|
97
|
+
} else if metadata.is_empty() {
|
|
98
|
+
"text/plain;charset=US-ASCII"
|
|
99
|
+
} else {
|
|
100
|
+
metadata
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
let bytes = if is_base64 {
|
|
104
|
+
BASE64
|
|
105
|
+
.decode(data)
|
|
106
|
+
.map_err(|e| NormalizeError::InvalidInput(format!("Invalid base64 in data URI: {}", e)))?
|
|
107
|
+
} else {
|
|
108
|
+
// URL-encoded data
|
|
109
|
+
urlencoding_decode(data)
|
|
110
|
+
.map_err(|e| NormalizeError::InvalidInput(format!("Invalid URL encoding: {}", e)))?
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
Ok((content_type.to_string(), bytes))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// Simple URL decoding for non-base64 data URIs
|
|
117
|
+
fn urlencoding_decode(input: &str) -> Result<Vec<u8>, String> {
|
|
118
|
+
let mut result = Vec::new();
|
|
119
|
+
let mut chars = input.chars().peekable();
|
|
120
|
+
|
|
121
|
+
while let Some(c) = chars.next() {
|
|
122
|
+
if c == '%' {
|
|
123
|
+
let hex: String = chars.by_ref().take(2).collect();
|
|
124
|
+
if hex.len() == 2 {
|
|
125
|
+
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
|
|
126
|
+
result.push(byte);
|
|
127
|
+
} else {
|
|
128
|
+
return Err(format!("Invalid hex sequence: %{}", hex));
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
return Err("Incomplete percent encoding".to_string());
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
result.push(c as u8);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
Ok(result)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Fetches content from a URL with optional headers
|
|
142
|
+
fn fetch_url(
|
|
143
|
+
client: &reqwest::blocking::Client,
|
|
144
|
+
url: &str,
|
|
145
|
+
headers: Option<&Vec<(String, String)>>,
|
|
146
|
+
) -> Result<(Vec<u8>, Option<String>, Option<String>), NormalizeError> {
|
|
147
|
+
let mut request = client.get(url);
|
|
148
|
+
|
|
149
|
+
// Add custom headers if provided
|
|
150
|
+
if let Some(hdrs) = headers {
|
|
151
|
+
for (key, value) in hdrs {
|
|
152
|
+
request = request.header(key, value);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let response = request
|
|
157
|
+
.send()
|
|
158
|
+
.map_err(|e| NormalizeError::FetchFailed(format!("Request failed: {}", e)))?;
|
|
159
|
+
|
|
160
|
+
if !response.status().is_success() {
|
|
161
|
+
return Err(NormalizeError::FetchFailed(format!(
|
|
162
|
+
"HTTP {} {}",
|
|
163
|
+
response.status().as_u16(),
|
|
164
|
+
response.status().canonical_reason().unwrap_or("Unknown")
|
|
165
|
+
)));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let content_type = response
|
|
169
|
+
.headers()
|
|
170
|
+
.get("content-type")
|
|
171
|
+
.and_then(|v| v.to_str().ok())
|
|
172
|
+
.map(|s| s.split(';').next().unwrap_or(s).trim().to_string());
|
|
173
|
+
|
|
174
|
+
let filename = filename_from_url(url);
|
|
175
|
+
|
|
176
|
+
let bytes = response
|
|
177
|
+
.bytes()
|
|
178
|
+
.map_err(|e| NormalizeError::FetchFailed(format!("Failed to read response body: {}", e)))?
|
|
179
|
+
.to_vec();
|
|
180
|
+
|
|
181
|
+
Ok((bytes, content_type, filename))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ============================================================================
|
|
185
|
+
// Shared file normalization logic (used by all versions with file interface)
|
|
186
|
+
// ============================================================================
|
|
187
|
+
|
|
188
|
+
fn normalize_file(
|
|
189
|
+
client: &std::sync::Arc<std::sync::Mutex<reqwest::blocking::Client>>,
|
|
190
|
+
source: &str,
|
|
191
|
+
headers: Option<&Vec<(String, String)>>,
|
|
192
|
+
filename: Option<String>,
|
|
193
|
+
) -> Result<(String, String, String), NormalizeError> {
|
|
194
|
+
let (bytes, content_type, url_filename) = match detect_input_type(source) {
|
|
195
|
+
InputType::Url => {
|
|
196
|
+
let client = client.lock().unwrap();
|
|
197
|
+
fetch_url(&client, source, headers)?
|
|
198
|
+
}
|
|
199
|
+
InputType::DataUri => {
|
|
200
|
+
let (ct, bytes) = parse_data_uri(source)?;
|
|
201
|
+
(bytes, Some(ct), None)
|
|
202
|
+
}
|
|
203
|
+
InputType::Base64 => {
|
|
204
|
+
let bytes = BASE64
|
|
205
|
+
.decode(source)
|
|
206
|
+
.map_err(|e| NormalizeError::InvalidInput(format!("Invalid base64: {}", e)))?;
|
|
207
|
+
(bytes, None, None)
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
let detected_type = content_type.unwrap_or_else(|| detect_content_type(&bytes));
|
|
212
|
+
|
|
213
|
+
// Priority: explicit filename > URL filename > generated from content type
|
|
214
|
+
let final_filename = filename
|
|
215
|
+
.or(url_filename)
|
|
216
|
+
.unwrap_or_else(|| filename_from_content_type(&detected_type));
|
|
217
|
+
|
|
218
|
+
Ok((BASE64.encode(&bytes), detected_type, final_filename))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ============================================================================
|
|
222
|
+
// Macro to implement file::Host for any version that has the file interface
|
|
223
|
+
//
|
|
224
|
+
// When adding v5 (if it has the file interface), just add:
|
|
225
|
+
// impl_file_host!(v5);
|
|
226
|
+
// ============================================================================
|
|
227
|
+
|
|
228
|
+
macro_rules! impl_file_host {
|
|
229
|
+
($v:ident) => {
|
|
230
|
+
impl $v::standout::app::file::Host for AppState {
|
|
231
|
+
fn normalize(
|
|
232
|
+
&mut self,
|
|
233
|
+
source: String,
|
|
234
|
+
headers: Option<Vec<(String, String)>>,
|
|
235
|
+
filename: Option<String>,
|
|
236
|
+
) -> Result<$v::standout::app::file::FileData, $v::standout::app::file::FileError> {
|
|
237
|
+
match normalize_file(&self.client, &source, headers.as_ref(), filename) {
|
|
238
|
+
Ok((base64, content_type, filename)) => Ok($v::standout::app::file::FileData {
|
|
239
|
+
base64,
|
|
240
|
+
content_type,
|
|
241
|
+
filename,
|
|
242
|
+
}),
|
|
243
|
+
Err(e) => Err(match e {
|
|
244
|
+
NormalizeError::FetchFailed(msg) => $v::standout::app::file::FileError::FetchFailed(msg),
|
|
245
|
+
NormalizeError::InvalidInput(msg) => $v::standout::app::file::FileError::InvalidInput(msg),
|
|
246
|
+
NormalizeError::Timeout(msg) => $v::standout::app::file::FileError::Timeout(msg),
|
|
247
|
+
NormalizeError::Other(msg) => $v::standout::app::file::FileError::Other(msg),
|
|
248
|
+
}),
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Generate file::Host implementation for v4
|
|
256
|
+
// Note: v3 doesn't have the file interface, so no impl needed
|
|
257
|
+
// When adding v5, add: impl_file_host!(v5);
|
|
258
|
+
impl_file_host!(v4);
|
|
259
|
+
|
|
260
|
+
#[cfg(test)]
|
|
261
|
+
mod tests {
|
|
262
|
+
use super::*;
|
|
263
|
+
|
|
264
|
+
#[test]
|
|
265
|
+
fn test_detect_input_type_url() {
|
|
266
|
+
assert!(matches!(
|
|
267
|
+
detect_input_type("https://example.com/file.pdf"),
|
|
268
|
+
InputType::Url
|
|
269
|
+
));
|
|
270
|
+
assert!(matches!(
|
|
271
|
+
detect_input_type("http://example.com/file.pdf"),
|
|
272
|
+
InputType::Url
|
|
273
|
+
));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
#[test]
|
|
277
|
+
fn test_detect_input_type_data_uri() {
|
|
278
|
+
assert!(matches!(
|
|
279
|
+
detect_input_type("data:application/pdf;base64,JVBERi0="),
|
|
280
|
+
InputType::DataUri
|
|
281
|
+
));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#[test]
|
|
285
|
+
fn test_detect_input_type_base64() {
|
|
286
|
+
assert!(matches!(
|
|
287
|
+
detect_input_type("JVBERi0xLjQK"),
|
|
288
|
+
InputType::Base64
|
|
289
|
+
));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#[test]
|
|
293
|
+
fn test_filename_from_url() {
|
|
294
|
+
assert_eq!(
|
|
295
|
+
filename_from_url("https://example.com/path/to/file.pdf"),
|
|
296
|
+
Some("file.pdf".to_string())
|
|
297
|
+
);
|
|
298
|
+
assert_eq!(
|
|
299
|
+
filename_from_url("https://example.com/file.pdf?token=abc"),
|
|
300
|
+
Some("file.pdf".to_string())
|
|
301
|
+
);
|
|
302
|
+
assert_eq!(filename_from_url("https://example.com/"), None);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
#[test]
|
|
306
|
+
fn test_filename_from_content_type() {
|
|
307
|
+
assert_eq!(filename_from_content_type("application/pdf"), "file.pdf");
|
|
308
|
+
assert_eq!(filename_from_content_type("image/png"), "file.png");
|
|
309
|
+
assert_eq!(filename_from_content_type("unknown/type"), "file.bin");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#[test]
|
|
313
|
+
fn test_parse_data_uri_base64() {
|
|
314
|
+
let (content_type, bytes) = parse_data_uri("data:text/plain;base64,SGVsbG8=").unwrap();
|
|
315
|
+
assert_eq!(content_type, "text/plain");
|
|
316
|
+
assert_eq!(bytes, b"Hello");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#[test]
|
|
320
|
+
fn test_parse_data_uri_no_base64() {
|
|
321
|
+
let (content_type, bytes) = parse_data_uri("data:text/plain,Hello%20World").unwrap();
|
|
322
|
+
assert_eq!(content_type, "text/plain");
|
|
323
|
+
assert_eq!(bytes, b"Hello World");
|
|
324
|
+
}
|
|
325
|
+
}
|
data/ext/app_bridge/src/lib.rs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
use magnus::{function, method, prelude::*, Error, Ruby};
|
|
2
2
|
mod app_state;
|
|
3
3
|
mod component;
|
|
4
|
-
mod request_builder;
|
|
5
4
|
mod error_mapping;
|
|
5
|
+
mod file_ops;
|
|
6
|
+
mod request_builder;
|
|
7
|
+
mod types;
|
|
6
8
|
|
|
7
9
|
mod wrappers;
|
|
8
10
|
use wrappers::connection::RConnection;
|
|
@@ -68,10 +70,12 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
68
70
|
let action_response_class = module.define_class("ActionResponse", ruby.class_object())?;
|
|
69
71
|
action_response_class.define_singleton_method("new", function!(RActionResponse::new, 1))?;
|
|
70
72
|
action_response_class.define_method("serialized_output", method!(RActionResponse::serialized_output, 0))?;
|
|
73
|
+
action_response_class.define_method("with_output", method!(RActionResponse::with_output, 1))?;
|
|
71
74
|
|
|
72
75
|
// Define the App class
|
|
73
76
|
let app_class = module.define_class("App", ruby.class_object())?;
|
|
74
77
|
app_class.define_alloc_func::<MutRApp>();
|
|
78
|
+
app_class.define_method("wit_version", method!(MutRApp::wit_version, 0))?;
|
|
75
79
|
app_class.define_method("trigger_ids", method!(MutRApp::trigger_ids, 0))?;
|
|
76
80
|
app_class.define_method("action_ids", method!(MutRApp::action_ids, 0))?;
|
|
77
81
|
app_class.define_method("action_input_schema", method!(MutRApp::action_input_schema, 1))?;
|