mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-21 08:59:22 +01:00
More dynamic plugin access
This commit is contained in:
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -8172,6 +8172,7 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"templates",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"uuid",
|
||||
|
||||
@@ -56,3 +56,4 @@ tauri-plugin-window-state = "2.0.0-beta"
|
||||
tokio = { version = "1.36.0", features = ["sync"] }
|
||||
tokio-stream = "0.1.15"
|
||||
uuid = "1.7.0"
|
||||
thiserror = "1.0.61"
|
||||
|
||||
@@ -9,7 +9,6 @@ use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::deno_ops::op_yaml_parse;
|
||||
use anyhow::anyhow;
|
||||
use anyhow::bail;
|
||||
use anyhow::Context;
|
||||
@@ -32,6 +31,9 @@ use deno_core::SourceMapGetter;
|
||||
use deno_core::{resolve_import, v8};
|
||||
use tokio::task::block_in_place;
|
||||
|
||||
use crate::deno_ops::op_yaml_parse;
|
||||
use crate::plugin::PluginCapability;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SourceMapStore(Rc<RefCell<HashMap<String, Vec<u8>>>>);
|
||||
|
||||
@@ -134,13 +136,13 @@ impl ModuleLoader for TypescriptModuleLoader {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_plugin_deno_block(
|
||||
pub fn run_plugin_block(
|
||||
plugin_index_file: &str,
|
||||
fn_name: &str,
|
||||
fn_args: Vec<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, Error> {
|
||||
block_in_place(|| {
|
||||
tauri::async_runtime::block_on(run_plugin_deno_2(plugin_index_file, fn_name, fn_args))
|
||||
tauri::async_runtime::block_on(run_plugin(plugin_index_file, fn_name, fn_args))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -151,39 +153,13 @@ deno_core::extension!(
|
||||
esm = [dir "src/plugin-runtime", "yaml.js"]
|
||||
);
|
||||
|
||||
async fn run_plugin_deno_2(
|
||||
async fn run_plugin(
|
||||
plugin_index_file: &str,
|
||||
fn_name: &str,
|
||||
fn_args: Vec<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, Error> {
|
||||
let source_map_store = SourceMapStore(Rc::new(RefCell::new(HashMap::new())));
|
||||
|
||||
let mut ext_console = deno_console::deno_console::init_ops_and_esm();
|
||||
ext_console.esm_entry_point = Some("ext:deno_console/01_console.js");
|
||||
|
||||
let ext_yaak = yaak_runtime::init_ops_and_esm();
|
||||
|
||||
let mut js_runtime = JsRuntime::new(RuntimeOptions {
|
||||
module_loader: Some(Rc::new(TypescriptModuleLoader {
|
||||
source_maps: source_map_store.clone(),
|
||||
})),
|
||||
source_map_getter: Some(Rc::new(source_map_store)),
|
||||
extensions: vec![ext_console, ext_yaak],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let main_module = resolve_path(
|
||||
plugin_index_file,
|
||||
&std::env::current_dir().context("Unable to get CWD")?,
|
||||
)?;
|
||||
|
||||
// Load the main module so we can do stuff with it
|
||||
let mod_id = js_runtime.load_main_es_module(&main_module).await?;
|
||||
let result = js_runtime.mod_evaluate(mod_id);
|
||||
js_runtime.run_event_loop(Default::default()).await?;
|
||||
result.await?;
|
||||
|
||||
let module_namespace = js_runtime.get_module_namespace(mod_id).unwrap();
|
||||
let mut js_runtime = load_js_runtime()?;
|
||||
let module_namespace = load_main_module(&mut js_runtime, plugin_index_file).await?;
|
||||
let scope = &mut js_runtime.handle_scope();
|
||||
let module_namespace = v8::Local::<v8::Object>::new(scope, module_namespace);
|
||||
|
||||
@@ -235,3 +211,95 @@ async fn run_plugin_deno_2(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_plugin_capabilities_block(plugin_index_file: &str) -> Result<Vec<PluginCapability>, Error> {
|
||||
block_in_place(|| tauri::async_runtime::block_on(get_plugin_capabilities(plugin_index_file)))
|
||||
}
|
||||
|
||||
pub async fn get_plugin_capabilities(
|
||||
plugin_index_file: &str,
|
||||
) -> Result<Vec<PluginCapability>, Error> {
|
||||
let mut js_runtime = load_js_runtime()?;
|
||||
let module_namespace = load_main_module(&mut js_runtime, plugin_index_file).await?;
|
||||
let scope = &mut js_runtime.handle_scope();
|
||||
let module_namespace = v8::Local::<v8::Object>::new(scope, module_namespace);
|
||||
|
||||
let property_names =
|
||||
match module_namespace.get_own_property_names(scope, v8::GetPropertyNamesArgs::default()) {
|
||||
None => return Ok(Vec::new()),
|
||||
Some(names) => names,
|
||||
};
|
||||
|
||||
let mut capabilities: Vec<PluginCapability> = Vec::new();
|
||||
for i in 0..property_names.length() {
|
||||
let name = property_names.get_index(scope, i);
|
||||
let name = match name {
|
||||
Some(name) => name,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
match name.to_rust_string_lossy(scope).as_str() {
|
||||
"pluginHookImport" => _ = capabilities.push(PluginCapability::Import),
|
||||
"pluginHookExport" => _ = capabilities.push(PluginCapability::Export),
|
||||
"pluginHookResponseFilter" => _ = capabilities.push(PluginCapability::Filter),
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
|
||||
Ok(capabilities)
|
||||
}
|
||||
|
||||
async fn load_main_module(
|
||||
js_runtime: &mut JsRuntime,
|
||||
plugin_index_file: &str,
|
||||
) -> Result<v8::Global<v8::Object>, Error> {
|
||||
let main_module = resolve_path(
|
||||
plugin_index_file,
|
||||
&std::env::current_dir().context("Unable to get CWD")?,
|
||||
)?;
|
||||
|
||||
// Load the main module so we can do stuff with it
|
||||
let mod_id = js_runtime.load_main_es_module(&main_module).await?;
|
||||
let result = js_runtime.mod_evaluate(mod_id);
|
||||
js_runtime.run_event_loop(Default::default()).await?;
|
||||
result.await?;
|
||||
|
||||
let module_namespace = js_runtime.get_module_namespace(mod_id).unwrap();
|
||||
|
||||
Ok(module_namespace)
|
||||
}
|
||||
|
||||
fn load_js_runtime<'s>() -> Result<JsRuntime, Error> {
|
||||
let source_map_store = SourceMapStore(Rc::new(RefCell::new(HashMap::new())));
|
||||
|
||||
let mut ext_console = deno_console::deno_console::init_ops_and_esm();
|
||||
ext_console.esm_entry_point = Some("ext:deno_console/01_console.js");
|
||||
|
||||
let ext_yaak = yaak_runtime::init_ops_and_esm();
|
||||
|
||||
let js_runtime = JsRuntime::new(RuntimeOptions {
|
||||
module_loader: Some(Rc::new(TypescriptModuleLoader {
|
||||
source_maps: source_map_store.clone(),
|
||||
})),
|
||||
source_map_getter: Some(Rc::new(source_map_store)),
|
||||
extensions: vec![ext_console, ext_yaak],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// let main_module = resolve_path(
|
||||
// plugin_index_file.to_str().unwrap(),
|
||||
// &std::env::current_dir().context("Unable to get CWD")?,
|
||||
// )?;
|
||||
//
|
||||
// // Load the main module so we can do stuff with it
|
||||
// let mod_id = js_runtime.load_main_es_module(&main_module).await?;
|
||||
// let result = js_runtime.mod_evaluate(mod_id);
|
||||
// js_runtime.run_event_loop(Default::default()).await?;
|
||||
// result.await?;
|
||||
//
|
||||
// let module_namespace = js_runtime.get_module_namespace(mod_id).unwrap();
|
||||
// let scope = &mut js_runtime.handle_scope();
|
||||
// let module_namespace = v8::Local::<v8::Object>::new(scope, module_namespace);
|
||||
|
||||
Ok(js_runtime)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ extern crate objc;
|
||||
use std::collections::HashMap;
|
||||
use std::env::current_dir;
|
||||
use std::fs;
|
||||
use std::fs::{create_dir_all, read_to_string, File};
|
||||
use std::fs::{create_dir_all, File, read_to_string};
|
||||
use std::path::PathBuf;
|
||||
use std::process::exit;
|
||||
use std::str::FromStr;
|
||||
@@ -17,43 +17,46 @@ use fern::colors::ColoredLevelConfig;
|
||||
use log::{debug, error, info, warn};
|
||||
use rand::random;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Sqlite, SqlitePool};
|
||||
use sqlx::migrate::Migrator;
|
||||
use sqlx::sqlite::SqliteConnectOptions;
|
||||
use sqlx::types::Json;
|
||||
use sqlx::{Pool, Sqlite, SqlitePool};
|
||||
use tauri::{AppHandle, LogicalSize, RunEvent, State, WebviewUrl, WebviewWindow};
|
||||
use tauri::{Manager, WindowEvent};
|
||||
use tauri::path::BaseDirectory;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::TitleBarStyle;
|
||||
use tauri::{AppHandle, LogicalSize, RunEvent, State, WebviewUrl, WebviewWindow};
|
||||
use tauri::{Manager, WindowEvent};
|
||||
use tauri_plugin_log::{fern, Target, TargetKind};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use ::grpc::{Code, deserialize_message, serialize_message, ServiceDefinition};
|
||||
use ::grpc::manager::{DynamicMessage, GrpcHandle};
|
||||
use ::grpc::{deserialize_message, serialize_message, Code, ServiceDefinition};
|
||||
|
||||
use crate::analytics::{AnalyticsAction, AnalyticsResource};
|
||||
use crate::grpc::metadata_to_map;
|
||||
use crate::http_request::send_http_request;
|
||||
use crate::models::{
|
||||
cancel_pending_grpc_connections, cancel_pending_responses, create_http_response,
|
||||
delete_all_grpc_connections, delete_all_http_responses, delete_cookie_jar, delete_environment,
|
||||
delete_folder, delete_grpc_connection, delete_grpc_request, delete_http_request,
|
||||
delete_http_response, delete_workspace, duplicate_grpc_request, duplicate_http_request,
|
||||
generate_model_id, get_cookie_jar, get_environment, get_folder, get_grpc_connection,
|
||||
cancel_pending_grpc_connections, cancel_pending_responses, CookieJar,
|
||||
create_http_response, delete_all_grpc_connections, delete_all_http_responses, delete_cookie_jar,
|
||||
delete_environment, delete_folder, delete_grpc_connection, delete_grpc_request,
|
||||
delete_http_request, delete_http_response, delete_workspace, duplicate_grpc_request,
|
||||
duplicate_http_request, Environment, EnvironmentVariable, Folder, generate_model_id,
|
||||
get_cookie_jar, get_environment, get_folder, get_grpc_connection,
|
||||
get_grpc_request, get_http_request, get_http_response, get_key_value_raw,
|
||||
get_or_create_settings, get_workspace, get_workspace_export_resources, list_cookie_jars,
|
||||
list_environments, list_folders, list_grpc_connections, list_grpc_events, list_grpc_requests,
|
||||
list_http_requests, list_responses, list_workspaces, set_key_value_raw, update_response_if_id,
|
||||
update_settings, upsert_cookie_jar, upsert_environment, upsert_folder, upsert_grpc_connection,
|
||||
upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_workspace, CookieJar,
|
||||
Environment, EnvironmentVariable, Folder, GrpcConnection, GrpcEvent, GrpcEventType,
|
||||
GrpcRequest, HttpRequest, HttpResponse, KeyValue, ModelType, Settings, Workspace,
|
||||
get_or_create_settings, get_workspace, get_workspace_export_resources, GrpcConnection, GrpcEvent,
|
||||
GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, KeyValue,
|
||||
list_cookie_jars, list_environments, list_folders, list_grpc_connections, list_grpc_events,
|
||||
list_grpc_requests, list_http_requests, list_responses, list_workspaces, ModelType,
|
||||
set_key_value_raw, Settings, update_response_if_id, update_settings, upsert_cookie_jar, upsert_environment,
|
||||
upsert_folder, upsert_grpc_connection, upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_workspace, Workspace,
|
||||
WorkspaceExportResources,
|
||||
};
|
||||
use crate::notifications::YaakNotifier;
|
||||
use crate::plugin::{run_plugin_export_curl, run_plugin_import, ImportResult};
|
||||
use crate::plugin::{
|
||||
find_plugins, get_plugin, ImportResult, PluginCapability,
|
||||
run_plugin_export_curl, run_plugin_filter, run_plugin_import,
|
||||
};
|
||||
use crate::render::render_request;
|
||||
use crate::updates::{UpdateMode, YaakUpdater};
|
||||
use crate::window_menu::app_menu;
|
||||
@@ -735,7 +738,11 @@ async fn cmd_filter_response(
|
||||
};
|
||||
|
||||
let body = read_to_string(response.body_path.unwrap()).unwrap();
|
||||
let filter_result = plugin::run_plugin_filter(&w.app_handle(), plugin_name, filter, &body)
|
||||
let plugin = match get_plugin(&w.app_handle(), plugin_name).map_err(|e| e.to_string())? {
|
||||
None => return Err("Failed to get plugin".into()),
|
||||
Some(p) => p,
|
||||
};
|
||||
let filter_result = run_plugin_filter(&plugin, filter, &body)
|
||||
.await
|
||||
.expect("Failed to run filter");
|
||||
Ok(filter_result.filtered)
|
||||
@@ -748,26 +755,23 @@ async fn cmd_import_data(
|
||||
_workspace_id: &str,
|
||||
) -> Result<WorkspaceExportResources, String> {
|
||||
let mut result: Option<ImportResult> = None;
|
||||
let plugins = vec![
|
||||
"importer-postman",
|
||||
"importer-insomnia",
|
||||
"importer-yaak",
|
||||
"importer-curl",
|
||||
];
|
||||
let file =
|
||||
read_to_string(file_path).unwrap_or_else(|_| panic!("Unable to read file {}", file_path));
|
||||
let file_contents = file.as_str();
|
||||
for plugin_name in plugins {
|
||||
let v = run_plugin_import(&w.app_handle(), plugin_name, file_contents)
|
||||
let plugins = find_plugins(w.app_handle(), &PluginCapability::Import)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
for plugin in plugins {
|
||||
let v = run_plugin_import(&plugin, file_contents)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(r) = v {
|
||||
info!("Imported data using {}", plugin_name);
|
||||
info!("Imported data using {}", plugin.name);
|
||||
analytics::track_event(
|
||||
&w.app_handle(),
|
||||
AnalyticsResource::App,
|
||||
AnalyticsAction::Import,
|
||||
Some(json!({ "plugin": plugin_name })),
|
||||
Some(json!({ "plugin": plugin.name })),
|
||||
)
|
||||
.await;
|
||||
result = Some(r);
|
||||
@@ -907,10 +911,17 @@ async fn cmd_request_to_curl(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_curl_to_request(app_handle: AppHandle, command: &str, workspace_id: &str) -> Result<HttpRequest, String> {
|
||||
let v = run_plugin_import(&app_handle, "importer-curl", command)
|
||||
.await
|
||||
.map_err(|e| e.to_string());
|
||||
async fn cmd_curl_to_request(
|
||||
app_handle: AppHandle,
|
||||
command: &str,
|
||||
workspace_id: &str,
|
||||
) -> Result<HttpRequest, String> {
|
||||
let plugin = match get_plugin(&app_handle, "importer-curl").map_err(|e| e.to_string())? {
|
||||
None => return Err("Failed to find plugin".into()),
|
||||
Some(p) => p,
|
||||
};
|
||||
|
||||
let v = run_plugin_import(&plugin, command).await;
|
||||
match v {
|
||||
Ok(Some(r)) => r
|
||||
.resources
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
use std::path;
|
||||
use std::{fs, io};
|
||||
|
||||
use log::error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::path::BaseDirectory;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::deno::run_plugin_deno_block;
|
||||
use crate::deno::{get_plugin_capabilities_block, run_plugin_block};
|
||||
use crate::models::{HttpRequest, WorkspaceExportResources};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PluginError {
|
||||
#[error("directory not found")]
|
||||
DirectoryNotFound(#[from] io::Error),
|
||||
#[error("anyhow error")]
|
||||
V8(#[from] anyhow::Error),
|
||||
// #[error("unknown data store error")]
|
||||
// Unknown,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Deserialize, Serialize)]
|
||||
pub struct FilterResult {
|
||||
pub filtered: String,
|
||||
@@ -18,21 +29,71 @@ pub struct ImportResult {
|
||||
pub resources: WorkspaceExportResources,
|
||||
}
|
||||
|
||||
pub async fn run_plugin_filter(
|
||||
#[derive(Eq, PartialEq, Hash, Clone)]
|
||||
pub enum PluginCapability {
|
||||
Export,
|
||||
Import,
|
||||
Filter,
|
||||
}
|
||||
|
||||
pub struct PluginDef {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub capabilities: Vec<PluginCapability>,
|
||||
}
|
||||
|
||||
pub fn scan_plugins(app_handle: &AppHandle) -> Result<Vec<PluginDef>, PluginError> {
|
||||
let plugins_dir = app_handle
|
||||
.path()
|
||||
.resolve("plugins", BaseDirectory::Resource)
|
||||
.expect("failed to resolve plugin directory resource");
|
||||
|
||||
let plugin_entries = fs::read_dir(plugins_dir)?;
|
||||
|
||||
let mut plugins = Vec::new();
|
||||
for entry in plugin_entries {
|
||||
let plugin_dir_entry = match entry {
|
||||
Err(_) => continue,
|
||||
Ok(entry) => entry,
|
||||
};
|
||||
|
||||
let plugin_index_file = plugin_dir_entry.path().join("index.mjs");
|
||||
let capabilities = get_plugin_capabilities_block(&plugin_index_file.to_str().unwrap())?;
|
||||
|
||||
plugins.push(PluginDef {
|
||||
name: plugin_dir_entry.file_name().to_string_lossy().to_string(),
|
||||
path: plugin_index_file.to_string_lossy().to_string(),
|
||||
capabilities,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(plugins)
|
||||
}
|
||||
|
||||
pub async fn find_plugins(
|
||||
app_handle: &AppHandle,
|
||||
plugin_name: &str,
|
||||
capability: &PluginCapability,
|
||||
) -> Result<Vec<PluginDef>, PluginError> {
|
||||
let plugins = scan_plugins(app_handle)?
|
||||
.into_iter()
|
||||
.filter(|p| p.capabilities.contains(capability))
|
||||
.collect();
|
||||
Ok(plugins)
|
||||
}
|
||||
|
||||
pub fn get_plugin(app_handle: &AppHandle, name: &str) -> Result<Option<PluginDef>, PluginError> {
|
||||
Ok(scan_plugins(app_handle)?
|
||||
.into_iter()
|
||||
.find(|p| p.name == name))
|
||||
}
|
||||
|
||||
pub async fn run_plugin_filter(
|
||||
plugin: &PluginDef,
|
||||
response_body: &str,
|
||||
filter: &str,
|
||||
) -> Option<FilterResult> {
|
||||
let plugin_dir = app_handle
|
||||
.path()
|
||||
.resolve("plugins", BaseDirectory::Resource)
|
||||
.expect("failed to resolve plugin directory resource")
|
||||
.join(plugin_name);
|
||||
let plugin_index_file = plugin_dir.join("index.mjs");
|
||||
|
||||
let result = run_plugin_deno_block(
|
||||
plugin_index_file.to_str().unwrap(),
|
||||
let result = run_plugin_block(
|
||||
&plugin.path,
|
||||
"pluginHookResponseFilter",
|
||||
vec![
|
||||
serde_json::to_value(response_body).unwrap(),
|
||||
@@ -43,7 +104,7 @@ pub async fn run_plugin_filter(
|
||||
.expect("Failed to run plugin");
|
||||
|
||||
if result.is_null() {
|
||||
error!("Plugin {} failed to run", plugin_name);
|
||||
error!("Plugin {} failed to run", plugin.name);
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -56,39 +117,25 @@ pub fn run_plugin_export_curl(
|
||||
app_handle: &AppHandle,
|
||||
request: &HttpRequest,
|
||||
) -> Result<String, String> {
|
||||
let plugin_dir = app_handle
|
||||
.path()
|
||||
.resolve("plugins", BaseDirectory::Resource)
|
||||
.expect("failed to resolve plugin directory resource")
|
||||
.join("exporter-curl");
|
||||
let plugin_index_file = plugin_dir.join("index.mjs");
|
||||
let plugin = match get_plugin(app_handle, "exporter-curl").map_err(|e| e.to_string())? {
|
||||
None => return Err("Failed to get plugin".into()),
|
||||
Some(p) => p,
|
||||
};
|
||||
|
||||
let request_json = serde_json::to_value(request).map_err(|e| e.to_string())?;
|
||||
let result = run_plugin_deno_block(
|
||||
plugin_index_file.to_str().unwrap(),
|
||||
"pluginHookExport",
|
||||
vec![request_json],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let result = run_plugin_block(&plugin.path, "pluginHookExport", vec![request_json])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let export_str: String = serde_json::from_value(result).map_err(|e| e.to_string())?;
|
||||
Ok(export_str)
|
||||
}
|
||||
|
||||
pub async fn run_plugin_import(
|
||||
app_handle: &AppHandle,
|
||||
plugin_name: &str,
|
||||
plugin: &PluginDef,
|
||||
file_contents: &str,
|
||||
) -> Result<Option<ImportResult>, String> {
|
||||
let plugin_dir = app_handle
|
||||
.path()
|
||||
.resolve("plugins", BaseDirectory::Resource)
|
||||
.expect("failed to resolve plugin directory resource")
|
||||
.join(plugin_name);
|
||||
let plugin_index_file = plugin_dir.join("index.mjs");
|
||||
|
||||
let result = run_plugin_deno_block(
|
||||
plugin_index_file.to_str().unwrap(),
|
||||
let result = run_plugin_block(
|
||||
&plugin.path,
|
||||
"pluginHookImport",
|
||||
vec![serde_json::to_value(file_contents).map_err(|e| e.to_string())?],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user