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.
@@ -1,32 +1,32 @@
1
- use crate::component::standout::app::types::{AppError, ErrorCode};
2
- use magnus::{self, Error, Ruby, ExceptionClass};
1
+ use crate::types::{AppError, ErrorCode};
2
+ use magnus::{Error, ExceptionClass, Ruby};
3
3
 
4
4
  impl From<ErrorCode> for ExceptionClass {
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
- }
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
- 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"),
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
- fn from(value: AppError) -> Self {
30
- Error::new(value.code.into(), value.message)
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
+ }
@@ -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))?;