mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-09 19:31:57 +02:00
Yaak now remembers, per item of a linked import source, whether the user wants it. A mapping row that still points at a model means wanted, so re-imports merge into it; a row with no model behind it means not wanted, so re-imports leave it alone. Wanted-ness is structural rather than a flag: deleting the model locally, or turning the item down in the preview, is what makes it not wanted. - import_source_resources drops `snapshot` for a nullable `content_hash` and lets `model_id` be NULL. The migration keeps every beta row's key to model mapping and starts hashes empty; until a hash is recorded, a difference can't be attributed to either side, so the item is offered once as a conflict that keeps local changes. - comparable() ignores sortPriority. Importers number it from source order, so inserting one operation used to plan a fake update for everything after it. The trade is that pure reorders don't propagate. - Keys the user turned down come back as unchecked "not imported" rows instead of being re-offered as new resources or resurrected. Checking one imports it under the same source key and pulls in the folders it needs. - An imported resource the source moves into a folder that isn't imported is offered as a deletion, with a tooltip pointing at the folder. - Plan-time source resolution binds by source-key overlap instead of by path, so a renamed or moved file merges into what it created and heals the stored origin. Several sources sharing keys never guess-merge: the plan says so and imports everything as new. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2672 lines
106 KiB
Rust
2672 lines
106 KiB
Rust
use crate::Result;
|
|
use chrono::Utc;
|
|
use log::info;
|
|
use serde_json::Value;
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use yaak_models::client_db::ClientDb;
|
|
use yaak_models::models::{
|
|
AnyModel, DEFAULT_REQUEST_MESSAGE_SIZE, Environment, Folder, GrpcRequest, HttpRequest,
|
|
ImportSource, ImportSourceResource, UpsertModelInfo, WebsocketRequest, Workspace,
|
|
};
|
|
use yaak_models::query_manager::QueryManager;
|
|
use yaak_models::util::{
|
|
BatchUpsertResult, ImportConflictResolution, ImportDestination, ImportOrigin, ImportPlan,
|
|
ImportPlanAction, ImportPlanItem, ImportPlanReason, ImportPlanWarning, ImportResourceType,
|
|
UpdateSource,
|
|
};
|
|
use yaak_plugins::events::{ImportResources, PluginContext};
|
|
use yaak_plugins::manager::PluginManager;
|
|
|
|
pub struct PlanImportDataParams<'a> {
|
|
pub query_manager: &'a QueryManager,
|
|
pub plugin_manager: &'a PluginManager,
|
|
pub plugin_context: &'a PluginContext,
|
|
pub destination: ImportDestination,
|
|
pub contents: &'a str,
|
|
pub origin: Option<ImportOrigin>,
|
|
}
|
|
|
|
/// Parse importer output and turn it into a commit-ready plan without mutating the database.
|
|
pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result<ImportPlan> {
|
|
let import_result =
|
|
params.plugin_manager.import_data(params.plugin_context, params.contents).await?;
|
|
|
|
plan_import_resources(
|
|
params.query_manager,
|
|
import_result.importer,
|
|
params.destination,
|
|
import_result.resources,
|
|
import_result.source_keys,
|
|
params.origin,
|
|
)
|
|
}
|
|
|
|
/// Remap parsed importer resources into their selected destination.
|
|
///
|
|
/// Every imported model gets a fresh ID. This prevents an import from accidentally updating an
|
|
/// existing model and also makes the plan safe to inspect before it is committed.
|
|
pub fn plan_import_resources(
|
|
query_manager: &QueryManager,
|
|
importer: String,
|
|
destination: ImportDestination,
|
|
resources: ImportResources,
|
|
source_keys: Option<BTreeMap<String, String>>,
|
|
origin: Option<ImportOrigin>,
|
|
) -> Result<ImportPlan> {
|
|
let mut warnings = Vec::new();
|
|
validate_destination(query_manager, &destination)?;
|
|
|
|
let plugin_keys = source_keys.unwrap_or_default();
|
|
// Source keys and merge decisions describe the document as the importer produced it, not as
|
|
// this destination reshapes it, so keep the original around.
|
|
let original = resources.clone();
|
|
|
|
let source_folder_ids = resources.folders.iter().map(|v| v.id.clone()).collect::<BTreeSet<_>>();
|
|
let mut folder_ids = BTreeMap::new();
|
|
for folder in &resources.folders {
|
|
folder_ids.insert(folder.id.clone(), Folder::generate_id());
|
|
}
|
|
|
|
let mut workspace_ids = BTreeMap::new();
|
|
let mut workspaces = Vec::new();
|
|
let (default_workspace_id, target_folder_id) = match &destination {
|
|
ImportDestination::NewWorkspace => {
|
|
for source in &resources.workspaces {
|
|
let mut workspace = source.clone();
|
|
workspace.id = Workspace::generate_id();
|
|
workspace_ids.insert(source.id.clone(), workspace.id.clone());
|
|
workspaces.push(workspace);
|
|
}
|
|
|
|
if workspaces.is_empty() {
|
|
let workspace = Workspace {
|
|
id: Workspace::generate_id(),
|
|
model: "workspace".to_string(),
|
|
name: format!("{} Import", display_importer_name(&importer)),
|
|
setting_follow_redirects: true,
|
|
setting_request_message_size: DEFAULT_REQUEST_MESSAGE_SIZE,
|
|
setting_validate_certificates: true,
|
|
setting_send_cookies: true,
|
|
setting_store_cookies: true,
|
|
..Default::default()
|
|
};
|
|
workspaces.push(workspace);
|
|
}
|
|
|
|
(workspaces[0].id.clone(), None)
|
|
}
|
|
ImportDestination::ExistingWorkspace { workspace_id, folder_id } => {
|
|
for source in &resources.workspaces {
|
|
workspace_ids.insert(source.id.clone(), workspace_id.clone());
|
|
}
|
|
if !resources.workspaces.is_empty() {
|
|
let destination_workspace = query_manager.connect().get_workspace(workspace_id)?;
|
|
let skipped_fields = resources
|
|
.workspaces
|
|
.iter()
|
|
.flat_map(|source| {
|
|
workspace_fields_not_imported(source, &destination_workspace)
|
|
})
|
|
.collect::<BTreeSet<_>>();
|
|
if !skipped_fields.is_empty() {
|
|
let source = if resources.workspaces.len() == 1 {
|
|
resources.workspaces[0].name.clone()
|
|
} else {
|
|
format!("{} imported workspaces", resources.workspaces.len())
|
|
};
|
|
warnings.push(ImportPlanWarning {
|
|
title: "Workspace settings skipped".to_string(),
|
|
detail: format!("{source} · {}", display_list(&skipped_fields)),
|
|
});
|
|
}
|
|
}
|
|
(workspace_id.clone(), folder_id.clone())
|
|
}
|
|
};
|
|
|
|
let resolve_workspace_id = |source_id: &str| {
|
|
workspace_ids.get(source_id).cloned().unwrap_or_else(|| default_workspace_id.clone())
|
|
};
|
|
|
|
let resolve_folder_id = |source_id: Option<String>| match source_id {
|
|
Some(source_id) if source_folder_ids.contains(&source_id) => {
|
|
folder_ids.get(&source_id).cloned()
|
|
}
|
|
_ => target_folder_id.clone(),
|
|
};
|
|
|
|
let folders = resources
|
|
.folders
|
|
.into_iter()
|
|
.map(|mut folder| {
|
|
folder.id = folder_ids.get(&folder.id).cloned().unwrap_or_else(Folder::generate_id);
|
|
folder.workspace_id = resolve_workspace_id(&folder.workspace_id);
|
|
folder.folder_id = resolve_folder_id(folder.folder_id);
|
|
folder
|
|
})
|
|
.collect();
|
|
|
|
let http_requests = resources
|
|
.http_requests
|
|
.into_iter()
|
|
.map(|mut request| {
|
|
request.id = HttpRequest::generate_id();
|
|
request.workspace_id = resolve_workspace_id(&request.workspace_id);
|
|
request.folder_id = resolve_folder_id(request.folder_id);
|
|
request
|
|
})
|
|
.collect();
|
|
|
|
let grpc_requests = resources
|
|
.grpc_requests
|
|
.into_iter()
|
|
.map(|mut request| {
|
|
request.id = GrpcRequest::generate_id();
|
|
request.workspace_id = resolve_workspace_id(&request.workspace_id);
|
|
request.folder_id = resolve_folder_id(request.folder_id);
|
|
request
|
|
})
|
|
.collect();
|
|
|
|
let websocket_requests = resources
|
|
.websocket_requests
|
|
.into_iter()
|
|
.map(|mut request| {
|
|
request.id = WebsocketRequest::generate_id();
|
|
request.workspace_id = resolve_workspace_id(&request.workspace_id);
|
|
request.folder_id = resolve_folder_id(request.folder_id);
|
|
request
|
|
})
|
|
.collect();
|
|
|
|
let importing_into_existing =
|
|
matches!(destination, ImportDestination::ExistingWorkspace { .. });
|
|
let mut separated_base_environments = Vec::new();
|
|
let mut converted_duplicate_base_environment = false;
|
|
let mut converted_duplicate_folder_environment = false;
|
|
let mut base_environment_workspaces = BTreeSet::new();
|
|
let mut folder_environment_ids = BTreeSet::new();
|
|
let environments = resources
|
|
.environments
|
|
.into_iter()
|
|
.map(|mut environment| {
|
|
environment.id = Environment::generate_id();
|
|
environment.workspace_id = resolve_workspace_id(&environment.workspace_id);
|
|
|
|
match (environment.parent_model.as_str(), environment.parent_id.clone()) {
|
|
("workspace", _) if importing_into_existing => {
|
|
environment.parent_model = "environment".to_string();
|
|
environment.parent_id = None;
|
|
let source_name = environment.name.clone();
|
|
environment.name = format!("{} (Imported)", environment.name);
|
|
separated_base_environments.push((
|
|
source_name,
|
|
environment.name.clone(),
|
|
environment.variables.len(),
|
|
));
|
|
}
|
|
("workspace", _) => {
|
|
environment.parent_id = None;
|
|
if !base_environment_workspaces.insert(environment.workspace_id.clone()) {
|
|
environment.parent_model = "environment".to_string();
|
|
environment.name = format!("{} (Imported)", environment.name);
|
|
converted_duplicate_base_environment = true;
|
|
}
|
|
}
|
|
("folder", Some(parent_id)) if source_folder_ids.contains(&parent_id) => {
|
|
environment.parent_id = folder_ids.get(&parent_id).cloned();
|
|
if let Some(parent_id) = &environment.parent_id
|
|
&& !folder_environment_ids.insert(parent_id.clone())
|
|
{
|
|
environment.parent_model = "environment".to_string();
|
|
environment.parent_id = None;
|
|
converted_duplicate_folder_environment = true;
|
|
}
|
|
}
|
|
("folder", _) => {
|
|
// Never attach an imported folder environment to an existing folder: the model
|
|
// layer permits only one and would otherwise delete the destination's value.
|
|
environment.parent_model = "environment".to_string();
|
|
environment.parent_id = None;
|
|
}
|
|
("environment", _) => {
|
|
environment.parent_id = None;
|
|
}
|
|
_ => {
|
|
environment.parent_model = "environment".to_string();
|
|
environment.parent_id = None;
|
|
}
|
|
}
|
|
|
|
environment
|
|
})
|
|
.collect();
|
|
|
|
for (source_name, imported_name, variable_count) in separated_base_environments {
|
|
let variables = if variable_count == 1 { "variable" } else { "variables" };
|
|
warnings.push(ImportPlanWarning {
|
|
title: "Base environment kept separate".to_string(),
|
|
detail: format!("{source_name} → {imported_name} · {variable_count} {variables}"),
|
|
});
|
|
}
|
|
if converted_duplicate_base_environment {
|
|
warnings.push(ImportPlanWarning {
|
|
title: "Base environments separated".to_string(),
|
|
detail: "Only the first remains the base environment".to_string(),
|
|
});
|
|
}
|
|
if converted_duplicate_folder_environment {
|
|
warnings.push(ImportPlanWarning {
|
|
title: "Folder environments separated".to_string(),
|
|
detail: "Only the first remains attached to each folder".to_string(),
|
|
});
|
|
}
|
|
|
|
let resources = BatchUpsertResult {
|
|
workspaces,
|
|
environments,
|
|
folders,
|
|
http_requests,
|
|
grpc_requests,
|
|
websocket_requests,
|
|
};
|
|
|
|
let mut plan = ImportPlan {
|
|
importer,
|
|
destination,
|
|
source_keys: assign_source_keys(&resources, &original, &plugin_keys),
|
|
resources,
|
|
warnings,
|
|
items: Vec::new(),
|
|
origin,
|
|
};
|
|
merge_with_linked_source(query_manager, &mut plan, &original)?;
|
|
Ok(plan)
|
|
}
|
|
|
|
/// Commit a previously prepared plan in one transaction, applying only its selected items.
|
|
pub fn commit_import_plan(
|
|
query_manager: &QueryManager,
|
|
plan: ImportPlan,
|
|
) -> Result<BatchUpsertResult> {
|
|
validate_plan(&plan)?;
|
|
|
|
info!("Committing staged import from {}", plan.importer);
|
|
query_manager.with_tx(|tx| {
|
|
validate_destination_db(tx, &plan.destination)?;
|
|
commit_plan_in_tx(tx, plan)
|
|
})
|
|
}
|
|
|
|
fn commit_plan_in_tx(db: &ClientDb, plan: ImportPlan) -> Result<BatchUpsertResult> {
|
|
let items: BTreeMap<String, ImportPlanItem> =
|
|
plan.items.iter().map(|item| (item.model_id.clone(), item.clone())).collect();
|
|
|
|
// A resource without an item (workspaces, plans from older callers) always applies.
|
|
// A selected keep-local item is an explicit request to revert the local edits.
|
|
let applies = |id: &str| match items.get(id) {
|
|
None => true,
|
|
Some(item) => match item.action {
|
|
ImportPlanAction::Create
|
|
| ImportPlanAction::Update
|
|
| ImportPlanAction::KeepLocal
|
|
| ImportPlanAction::NotImported => item.selected,
|
|
ImportPlanAction::Conflict => {
|
|
item.resolution == Some(ImportConflictResolution::TakeSource)
|
|
}
|
|
ImportPlanAction::Delete | ImportPlanAction::Unchanged => false,
|
|
},
|
|
};
|
|
|
|
// A folder that will not exist takes its planned descendants with it: nothing can be
|
|
// created inside it.
|
|
let is_new_folder = |id: &str| {
|
|
items.get(id).is_some_and(|i| {
|
|
matches!(i.action, ImportPlanAction::Create | ImportPlanAction::NotImported)
|
|
})
|
|
};
|
|
let mut missing_folders: BTreeSet<String> = plan
|
|
.resources
|
|
.folders
|
|
.iter()
|
|
.filter(|f| is_new_folder(&f.id) && !applies(&f.id))
|
|
.map(|f| f.id.clone())
|
|
.collect();
|
|
loop {
|
|
let before = missing_folders.len();
|
|
for folder in &plan.resources.folders {
|
|
if let Some(parent_id) = &folder.folder_id
|
|
&& missing_folders.contains(parent_id)
|
|
{
|
|
missing_folders.insert(folder.id.clone());
|
|
}
|
|
}
|
|
if missing_folders.len() == before {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let folder_available = |folder_id: &Option<String>| match folder_id {
|
|
Some(id) => !missing_folders.contains(id),
|
|
None => true,
|
|
};
|
|
|
|
let resources = &plan.resources;
|
|
let upserted = db.batch_upsert(
|
|
resources.workspaces.clone(),
|
|
resources
|
|
.environments
|
|
.iter()
|
|
.filter(|v| applies(&v.id))
|
|
.filter(|v| v.parent_model != "folder" || folder_available(&v.parent_id))
|
|
.cloned()
|
|
.collect(),
|
|
resources
|
|
.folders
|
|
.iter()
|
|
.filter(|v| applies(&v.id) && !missing_folders.contains(&v.id))
|
|
.cloned()
|
|
.collect(),
|
|
resources
|
|
.http_requests
|
|
.iter()
|
|
.filter(|v| applies(&v.id) && folder_available(&v.folder_id))
|
|
.cloned()
|
|
.collect(),
|
|
resources
|
|
.grpc_requests
|
|
.iter()
|
|
.filter(|v| applies(&v.id) && folder_available(&v.folder_id))
|
|
.cloned()
|
|
.collect(),
|
|
resources
|
|
.websocket_requests
|
|
.iter()
|
|
.filter(|v| applies(&v.id) && folder_available(&v.folder_id))
|
|
.cloned()
|
|
.collect(),
|
|
&UpdateSource::Import,
|
|
)?;
|
|
|
|
let selected_deletes = plan
|
|
.items
|
|
.iter()
|
|
.filter(|i| i.action == ImportPlanAction::Delete && i.selected)
|
|
.collect::<Vec<_>>();
|
|
// Folders last so their cascade only has to cover what was not deleted explicitly.
|
|
for item in selected_deletes.iter().filter(|i| i.model != ImportResourceType::Folder) {
|
|
delete_existing_model(db, item.model, &item.model_id)?;
|
|
}
|
|
for item in selected_deletes.iter().filter(|i| i.model == ImportResourceType::Folder) {
|
|
delete_existing_model(db, item.model, &item.model_id)?;
|
|
}
|
|
|
|
record_import_source(db, &plan, &items, &upserted)?;
|
|
|
|
Ok(upserted)
|
|
}
|
|
|
|
/// A folder deletion may have already cascaded over the model, so absent models are skipped.
|
|
fn delete_existing_model(db: &ClientDb, resource: ImportResourceType, id: &str) -> Result<()> {
|
|
use ImportResourceType::*;
|
|
let source = &UpdateSource::Import;
|
|
match resource {
|
|
Environment => {
|
|
if db.get_environment(id).is_ok() {
|
|
db.delete_environment_by_id(id, source)?;
|
|
}
|
|
}
|
|
Folder => {
|
|
if db.get_folder(id).is_ok() {
|
|
db.delete_folder_by_id(id, source)?;
|
|
}
|
|
}
|
|
HttpRequest => {
|
|
if db.get_http_request(id).is_ok() {
|
|
db.delete_http_request_by_id(id, source)?;
|
|
}
|
|
}
|
|
GrpcRequest => {
|
|
if db.get_grpc_request(id).is_ok() {
|
|
db.delete_grpc_request_by_id(id, source)?;
|
|
}
|
|
}
|
|
WebsocketRequest => {
|
|
if db.get_websocket_request(id).is_ok() {
|
|
db.delete_websocket_request_by_id(id, source)?;
|
|
}
|
|
}
|
|
// The destination workspace is never a plan item, so there is nothing to delete
|
|
Workspace => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Link the committed workspace to the import's source and record, per source key, whether the
|
|
/// user wants that resource and which version of it they last decided on.
|
|
///
|
|
/// Hashes advance for everything the user decided on this round — applied items and keep-mine
|
|
/// conflicts alike — while deselected updates and deletions keep their old hash so they are
|
|
/// offered again next time. A resource the user turned down is remembered as a row without a
|
|
/// model, so it is neither re-offered nor resurrected.
|
|
fn record_import_source(
|
|
db: &ClientDb,
|
|
plan: &ImportPlan,
|
|
items: &BTreeMap<String, ImportPlanItem>,
|
|
upserted: &BatchUpsertResult,
|
|
) -> Result<()> {
|
|
let Some(origin) = &plan.origin else {
|
|
return Ok(());
|
|
};
|
|
|
|
let workspace_id = match &plan.destination {
|
|
ImportDestination::ExistingWorkspace { workspace_id, .. } => workspace_id.clone(),
|
|
ImportDestination::NewWorkspace => match upserted.workspaces.first() {
|
|
Some(workspace) => workspace.id.clone(),
|
|
None => return Ok(()),
|
|
},
|
|
};
|
|
|
|
let incoming_keys: BTreeSet<String> = plan.source_keys.values().cloned().collect();
|
|
let existing = match resolve_linked_source(
|
|
db,
|
|
&workspace_id,
|
|
&plan.importer,
|
|
origin,
|
|
&incoming_keys,
|
|
)? {
|
|
LinkedSource::Linked(source) => Some(source),
|
|
LinkedSource::Ambiguous(_) | LinkedSource::Unlinked => None,
|
|
};
|
|
let import_source = db.upsert_import_source(
|
|
&ImportSource {
|
|
id: existing.map(|s| s.id).unwrap_or_default(),
|
|
workspace_id,
|
|
importer: plan.importer.clone(),
|
|
origin: origin.origin.clone(),
|
|
origin_label: origin.label.clone(),
|
|
last_imported_at: Utc::now().naive_utc(),
|
|
..Default::default()
|
|
},
|
|
&UpdateSource::Import,
|
|
)?;
|
|
|
|
let mut committed: BTreeMap<&str, String> = BTreeMap::new();
|
|
for v in &upserted.environments {
|
|
committed.insert(&v.id, content_hash(serde_json::to_value(v)?));
|
|
}
|
|
for v in &upserted.folders {
|
|
committed.insert(&v.id, content_hash(serde_json::to_value(v)?));
|
|
}
|
|
for v in &upserted.http_requests {
|
|
committed.insert(&v.id, content_hash(serde_json::to_value(v)?));
|
|
}
|
|
for v in &upserted.grpc_requests {
|
|
committed.insert(&v.id, content_hash(serde_json::to_value(v)?));
|
|
}
|
|
for v in &upserted.websocket_requests {
|
|
committed.insert(&v.id, content_hash(serde_json::to_value(v)?));
|
|
}
|
|
|
|
let write_row = |model_id: &str,
|
|
resource: ImportResourceType,
|
|
incoming: &dyn Fn() -> Result<Value>|
|
|
-> Result<()> {
|
|
let Some(source_key) = plan.source_keys.get(model_id) else {
|
|
return Ok(());
|
|
};
|
|
let item = items.get(model_id);
|
|
let next_row = match committed.get(model_id) {
|
|
Some(hash) => Some((Some(model_id.to_string()), Some(hash.clone()))),
|
|
None => match item.map(|i| i.action) {
|
|
Some(
|
|
ImportPlanAction::Unchanged
|
|
| ImportPlanAction::KeepLocal
|
|
| ImportPlanAction::Conflict,
|
|
) => Some((Some(model_id.to_string()), Some(content_hash(incoming()?)))),
|
|
// Turned down, so remember it as not wanted rather than offering it again
|
|
Some(ImportPlanAction::Create | ImportPlanAction::NotImported) => Some((None, None)),
|
|
Some(ImportPlanAction::Delete) if item.is_some_and(|i| i.selected) => {
|
|
Some((None, None))
|
|
}
|
|
// A deselected update or deletion stays offered next import
|
|
Some(ImportPlanAction::Update | ImportPlanAction::Delete) | None => None,
|
|
},
|
|
};
|
|
let Some((model_id, content_hash)) = next_row else {
|
|
return Ok(());
|
|
};
|
|
db.upsert_import_source_resource(&ImportSourceResource {
|
|
import_source_id: import_source.id.clone(),
|
|
source_key: source_key.clone(),
|
|
model_type: resource.as_str().to_string(),
|
|
model_id,
|
|
content_hash,
|
|
..Default::default()
|
|
})?;
|
|
Ok(())
|
|
};
|
|
|
|
for v in &plan.resources.environments {
|
|
write_row(&v.id, ImportResourceType::Environment, &|| Ok(serde_json::to_value(v)?))?;
|
|
}
|
|
for v in &plan.resources.folders {
|
|
write_row(&v.id, ImportResourceType::Folder, &|| Ok(serde_json::to_value(v)?))?;
|
|
}
|
|
for v in &plan.resources.http_requests {
|
|
write_row(&v.id, ImportResourceType::HttpRequest, &|| Ok(serde_json::to_value(v)?))?;
|
|
}
|
|
for v in &plan.resources.grpc_requests {
|
|
write_row(&v.id, ImportResourceType::GrpcRequest, &|| Ok(serde_json::to_value(v)?))?;
|
|
}
|
|
for v in &plan.resources.websocket_requests {
|
|
write_row(&v.id, ImportResourceType::WebsocketRequest, &|| Ok(serde_json::to_value(v)?))?;
|
|
}
|
|
|
|
for row in db.list_import_source_resources(&import_source.id)? {
|
|
if incoming_keys.contains(&row.source_key) {
|
|
continue;
|
|
}
|
|
// Keep only rows that back a deletion the user deselected; it will be offered again.
|
|
let keep = match (ImportResourceType::from_str(&row.model_type), &row.model_id) {
|
|
(Some(resource), Some(model_id)) => {
|
|
items
|
|
.get(model_id)
|
|
.is_some_and(|i| i.action == ImportPlanAction::Delete && !i.selected)
|
|
&& existing_model_json(db, resource, model_id)?.is_some()
|
|
}
|
|
_ => false,
|
|
};
|
|
if !keep {
|
|
db.delete_import_source_resource(&import_source.id, &row.source_key)?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// How an import's contents relate to what the destination workspace already has linked.
|
|
enum LinkedSource {
|
|
Linked(ImportSource),
|
|
/// Several linked sources contain these resources, so merging would have to guess
|
|
Ambiguous(Vec<ImportSource>),
|
|
Unlinked,
|
|
}
|
|
|
|
/// A re-import is recognized by the source keys it carries rather than by where it was read from,
|
|
/// so a file that moved or was renamed still merges into what it created.
|
|
fn resolve_linked_source(
|
|
db: &ClientDb,
|
|
workspace_id: &str,
|
|
importer: &str,
|
|
origin: &ImportOrigin,
|
|
incoming_keys: &BTreeSet<String>,
|
|
) -> Result<LinkedSource> {
|
|
let sources = db.list_import_sources(workspace_id)?;
|
|
let same_origin = |source: &ImportSource| {
|
|
source.importer == importer && source.origin == origin.origin
|
|
};
|
|
|
|
let mut overlapping = Vec::new();
|
|
for source in &sources {
|
|
let overlaps = db
|
|
.list_import_source_resources(&source.id)?
|
|
.iter()
|
|
.any(|row| incoming_keys.contains(&row.source_key));
|
|
if overlaps {
|
|
overlapping.push(source.clone());
|
|
}
|
|
}
|
|
|
|
Ok(match overlapping.len() {
|
|
0 => match sources.into_iter().find(same_origin) {
|
|
Some(source) => LinkedSource::Linked(source),
|
|
None => LinkedSource::Unlinked,
|
|
},
|
|
1 => LinkedSource::Linked(overlapping.remove(0)),
|
|
_ => match overlapping.iter().find(|s| same_origin(s)) {
|
|
Some(source) => LinkedSource::Linked(source.clone()),
|
|
None => LinkedSource::Ambiguous(overlapping),
|
|
},
|
|
})
|
|
}
|
|
|
|
fn ambiguous_source_warning(sources: &[ImportSource]) -> ImportPlanWarning {
|
|
let labels = sources.iter().map(|s| s.origin_label.as_str()).collect::<BTreeSet<_>>();
|
|
ImportPlanWarning {
|
|
title: "Imported as new".to_string(),
|
|
detail: format!("{} already contain these resources", display_list(&labels)),
|
|
}
|
|
}
|
|
|
|
/// Rewrite a plan against the destination's linked import source, if it has one: resources whose
|
|
/// source key still has a model adopt that model's ID, and every resource gets a plan item
|
|
/// describing the decision to preview. Keys the user has turned down come back as offers to
|
|
/// import them after all, rather than as new resources.
|
|
fn merge_with_linked_source(
|
|
query_manager: &QueryManager,
|
|
plan: &mut ImportPlan,
|
|
original: &ImportResources,
|
|
) -> Result<()> {
|
|
let db = query_manager.connect();
|
|
|
|
let incoming_keys: BTreeSet<String> = plan.source_keys.values().cloned().collect();
|
|
let linked = match (&plan.origin, &plan.destination) {
|
|
(Some(origin), ImportDestination::ExistingWorkspace { workspace_id, .. }) => {
|
|
resolve_linked_source(&db, workspace_id, &plan.importer, origin, &incoming_keys)?
|
|
}
|
|
(None, _) | (Some(_), ImportDestination::NewWorkspace) => LinkedSource::Unlinked,
|
|
};
|
|
|
|
let source = match linked {
|
|
LinkedSource::Linked(source) => source,
|
|
LinkedSource::Ambiguous(sources) => {
|
|
plan.warnings.push(ambiguous_source_warning(&sources));
|
|
plan.items = create_only_items(plan);
|
|
return Ok(());
|
|
}
|
|
LinkedSource::Unlinked => {
|
|
plan.items = create_only_items(plan);
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
let workspace_id = source.workspace_id.clone();
|
|
let rows: BTreeMap<String, ImportSourceResource> = db
|
|
.list_import_source_resources(&source.id)?
|
|
.into_iter()
|
|
.map(|row| (row.source_key.clone(), row))
|
|
.collect();
|
|
|
|
// Resources whose key maps to a model that still exists adopt that model's ID.
|
|
let mut remap: BTreeMap<String, String> = BTreeMap::new();
|
|
let mut current_models: BTreeMap<String, Value> = BTreeMap::new();
|
|
{
|
|
let mut consider = |planned_id: &str, resource: ImportResourceType| -> Result<()> {
|
|
let Some(key) = plan.source_keys.get(planned_id) else { return Ok(()) };
|
|
let Some(row) = rows.get(key) else { return Ok(()) };
|
|
if ImportResourceType::from_str(&row.model_type) != Some(resource) {
|
|
return Ok(());
|
|
}
|
|
let Some(model_id) = row.model_id.as_deref() else { return Ok(()) };
|
|
let Some(current) = existing_model_json(&db, resource, model_id)? else {
|
|
return Ok(());
|
|
};
|
|
if current.get("workspaceId").and_then(|v| v.as_str()) != Some(workspace_id.as_str()) {
|
|
return Ok(());
|
|
}
|
|
remap.insert(planned_id.to_string(), model_id.to_string());
|
|
current_models.insert(model_id.to_string(), current);
|
|
Ok(())
|
|
};
|
|
for v in &plan.resources.folders {
|
|
consider(&v.id, ImportResourceType::Folder)?;
|
|
}
|
|
for v in &plan.resources.environments {
|
|
consider(&v.id, ImportResourceType::Environment)?;
|
|
}
|
|
for v in &plan.resources.http_requests {
|
|
consider(&v.id, ImportResourceType::HttpRequest)?;
|
|
}
|
|
for v in &plan.resources.grpc_requests {
|
|
consider(&v.id, ImportResourceType::GrpcRequest)?;
|
|
}
|
|
for v in &plan.resources.websocket_requests {
|
|
consider(&v.id, ImportResourceType::WebsocketRequest)?;
|
|
}
|
|
}
|
|
|
|
let remap_ref = |id: Option<String>| id.map(|v| remap.get(&v).cloned().unwrap_or(v));
|
|
for v in &mut plan.resources.folders {
|
|
if let Some(existing_id) = remap.get(&v.id) {
|
|
v.id = existing_id.clone();
|
|
}
|
|
v.folder_id = remap_ref(v.folder_id.take());
|
|
}
|
|
for v in &mut plan.resources.environments {
|
|
if let Some(existing_id) = remap.get(&v.id) {
|
|
v.id = existing_id.clone();
|
|
}
|
|
if v.parent_model == "folder" {
|
|
v.parent_id = remap_ref(v.parent_id.take());
|
|
}
|
|
}
|
|
for v in &mut plan.resources.http_requests {
|
|
if let Some(existing_id) = remap.get(&v.id) {
|
|
v.id = existing_id.clone();
|
|
}
|
|
v.folder_id = remap_ref(v.folder_id.take());
|
|
}
|
|
for v in &mut plan.resources.grpc_requests {
|
|
if let Some(existing_id) = remap.get(&v.id) {
|
|
v.id = existing_id.clone();
|
|
}
|
|
v.folder_id = remap_ref(v.folder_id.take());
|
|
}
|
|
for v in &mut plan.resources.websocket_requests {
|
|
if let Some(existing_id) = remap.get(&v.id) {
|
|
v.id = existing_id.clone();
|
|
}
|
|
v.folder_id = remap_ref(v.folder_id.take());
|
|
}
|
|
plan.source_keys = plan
|
|
.source_keys
|
|
.iter()
|
|
.map(|(id, key)| (remap.get(id).cloned().unwrap_or_else(|| id.clone()), key.clone()))
|
|
.collect();
|
|
|
|
// A mapped environment that is currently the destination's base environment stays the base
|
|
// environment: it came from this source, so the imported-copy separation does not apply.
|
|
let mut restored_base_names = BTreeSet::new();
|
|
for (i, v) in plan.resources.environments.iter_mut().enumerate() {
|
|
let is_current_base = current_models
|
|
.get(&v.id)
|
|
.and_then(|m| m.get("parentModel"))
|
|
.and_then(|p| p.as_str())
|
|
== Some("workspace");
|
|
if !is_current_base {
|
|
continue;
|
|
}
|
|
if let Some(source) = original.environments.get(i) {
|
|
v.name = source.name.clone();
|
|
}
|
|
v.parent_model = "workspace".to_string();
|
|
v.parent_id = None;
|
|
restored_base_names.insert(v.name.clone());
|
|
}
|
|
if !restored_base_names.is_empty() {
|
|
plan.warnings.retain(|w| {
|
|
!(w.title == "Base environment kept separate"
|
|
&& restored_base_names.iter().any(|n| w.detail.starts_with(&format!("{n} →"))))
|
|
});
|
|
}
|
|
|
|
// A key whose row still points at a model in this workspace is one the user wants; a key
|
|
// whose row has lost its model is one they turned down.
|
|
let status = |planned_id: &str, resource: ImportResourceType| -> KeyStatus {
|
|
let Some(row) = plan.source_keys.get(planned_id).and_then(|key| rows.get(key)) else {
|
|
return KeyStatus::New;
|
|
};
|
|
if ImportResourceType::from_str(&row.model_type) != Some(resource) {
|
|
return KeyStatus::New;
|
|
}
|
|
if row.model_id.as_deref() == Some(planned_id) && current_models.contains_key(planned_id) {
|
|
KeyStatus::Wanted(row)
|
|
} else {
|
|
KeyStatus::NotWanted
|
|
}
|
|
};
|
|
|
|
let not_imported_folders = plan
|
|
.resources
|
|
.folders
|
|
.iter()
|
|
.filter(|v| matches!(status(&v.id, ImportResourceType::Folder), KeyStatus::NotWanted))
|
|
.map(|v| v.id.as_str())
|
|
.collect::<BTreeSet<_>>();
|
|
let folder_parents = plan
|
|
.resources
|
|
.folders
|
|
.iter()
|
|
.map(|v| (v.id.as_str(), v.folder_id.as_deref()))
|
|
.collect::<BTreeMap<_, _>>();
|
|
let inside_a_not_imported_folder = |parent_id: Option<&str>| {
|
|
let mut seen = BTreeSet::new();
|
|
let mut next = parent_id;
|
|
while let Some(id) = next {
|
|
if !seen.insert(id) {
|
|
break;
|
|
}
|
|
if not_imported_folders.contains(id) {
|
|
return true;
|
|
}
|
|
next = folder_parents.get(id).copied().flatten();
|
|
}
|
|
false
|
|
};
|
|
|
|
let mut items = Vec::new();
|
|
{
|
|
let mut classify = |any: AnyModel,
|
|
resource: ImportResourceType,
|
|
parent_id: Option<String>|
|
|
-> Result<()> {
|
|
let planned_id = any.id().to_string();
|
|
let item = |action, selected, resolution, reason| ImportPlanItem {
|
|
action,
|
|
model: resource,
|
|
model_id: planned_id.clone(),
|
|
name: any.resolved_name(),
|
|
parent_id: parent_id.clone(),
|
|
selected,
|
|
resolution,
|
|
reason,
|
|
};
|
|
|
|
// Nothing can be created inside a folder that isn't imported, so it starts unchecked
|
|
// and comes along only if the folder does.
|
|
let reachable = !inside_a_not_imported_folder(parent_id.as_deref());
|
|
let row = match status(&planned_id, resource) {
|
|
KeyStatus::New => {
|
|
items.push(item(ImportPlanAction::Create, reachable, None, None));
|
|
return Ok(());
|
|
}
|
|
KeyStatus::NotWanted => {
|
|
items.push(item(ImportPlanAction::NotImported, false, None, None));
|
|
return Ok(());
|
|
}
|
|
KeyStatus::Wanted(row) => row,
|
|
};
|
|
|
|
// The source put it somewhere the workspace has no folder for, so it can't stay
|
|
if !reachable {
|
|
items.push(item(
|
|
ImportPlanAction::Delete,
|
|
false,
|
|
None,
|
|
Some(ImportPlanReason::MovedIntoNotImportedFolder),
|
|
));
|
|
return Ok(());
|
|
}
|
|
|
|
let incoming = serde_json::to_value(&any)?;
|
|
let current = current_models.get(&planned_id).cloned().unwrap_or_default();
|
|
let (source_changed, local_changed) = match recorded_hash(row) {
|
|
Some(hash) => (content_hash(incoming) != hash, content_hash(current) != hash),
|
|
// Without a recorded version, all that can be told is whether the two sides differ
|
|
None => {
|
|
let differs = comparable(incoming) != comparable(current);
|
|
(differs, differs)
|
|
}
|
|
};
|
|
|
|
let (action, selected, resolution) = match (source_changed, local_changed) {
|
|
(false, false) => (ImportPlanAction::Unchanged, false, None),
|
|
(true, false) => (ImportPlanAction::Update, true, None),
|
|
(false, true) => (ImportPlanAction::KeepLocal, false, None),
|
|
(true, true) => (
|
|
ImportPlanAction::Conflict,
|
|
true,
|
|
Some(ImportConflictResolution::KeepMine),
|
|
),
|
|
};
|
|
items.push(item(action, selected, resolution, None));
|
|
Ok(())
|
|
};
|
|
|
|
for v in &plan.resources.folders {
|
|
classify(AnyModel::Folder(v.clone()), ImportResourceType::Folder, v.folder_id.clone())?;
|
|
}
|
|
for v in &plan.resources.http_requests {
|
|
classify(AnyModel::HttpRequest(v.clone()), ImportResourceType::HttpRequest, v.folder_id.clone())?;
|
|
}
|
|
for v in &plan.resources.grpc_requests {
|
|
classify(AnyModel::GrpcRequest(v.clone()), ImportResourceType::GrpcRequest, v.folder_id.clone())?;
|
|
}
|
|
for v in &plan.resources.websocket_requests {
|
|
classify(AnyModel::WebsocketRequest(v.clone()), ImportResourceType::WebsocketRequest, v.folder_id.clone())?;
|
|
}
|
|
for v in &plan.resources.environments {
|
|
classify(AnyModel::Environment(v.clone()), ImportResourceType::Environment, v.parent_id.clone())?;
|
|
}
|
|
}
|
|
|
|
// Mapped models the source no longer has become deletion offers, deselected by default.
|
|
for (key, row) in &rows {
|
|
if incoming_keys.contains(key) {
|
|
continue;
|
|
}
|
|
let Some(resource) = ImportResourceType::from_str(&row.model_type) else {
|
|
continue;
|
|
};
|
|
let Some(model_id) = row.model_id.as_deref() else {
|
|
continue;
|
|
};
|
|
let Some(current) = existing_model_json(&db, resource, model_id)? else {
|
|
continue;
|
|
};
|
|
if current.get("workspaceId").and_then(|v| v.as_str()) != Some(workspace_id.as_str()) {
|
|
continue;
|
|
}
|
|
let name = serde_json::from_value::<AnyModel>(current.clone())
|
|
.map(|m| m.resolved_name())
|
|
.unwrap_or_else(|_| "Unknown".to_string());
|
|
let parent_id = current
|
|
.get("folderId")
|
|
.or_else(|| current.get("parentId"))
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string);
|
|
items.push(ImportPlanItem {
|
|
action: ImportPlanAction::Delete,
|
|
model: resource,
|
|
model_id: model_id.to_string(),
|
|
name,
|
|
parent_id,
|
|
selected: false,
|
|
resolution: None,
|
|
reason: None,
|
|
});
|
|
}
|
|
|
|
plan.items = items;
|
|
Ok(())
|
|
}
|
|
|
|
fn create_only_items(plan: &ImportPlan) -> Vec<ImportPlanItem> {
|
|
let mut items = Vec::new();
|
|
let mut push = |any: AnyModel, resource: ImportResourceType, parent_id: Option<String>| {
|
|
items.push(ImportPlanItem {
|
|
action: ImportPlanAction::Create,
|
|
model: resource,
|
|
model_id: any.id().to_string(),
|
|
name: any.resolved_name(),
|
|
parent_id,
|
|
selected: true,
|
|
resolution: None,
|
|
reason: None,
|
|
});
|
|
};
|
|
for v in &plan.resources.folders {
|
|
push(AnyModel::Folder(v.clone()), ImportResourceType::Folder, v.folder_id.clone());
|
|
}
|
|
for v in &plan.resources.http_requests {
|
|
push(AnyModel::HttpRequest(v.clone()), ImportResourceType::HttpRequest, v.folder_id.clone());
|
|
}
|
|
for v in &plan.resources.grpc_requests {
|
|
push(AnyModel::GrpcRequest(v.clone()), ImportResourceType::GrpcRequest, v.folder_id.clone());
|
|
}
|
|
for v in &plan.resources.websocket_requests {
|
|
push(AnyModel::WebsocketRequest(v.clone()), ImportResourceType::WebsocketRequest, v.folder_id.clone());
|
|
}
|
|
for v in &plan.resources.environments {
|
|
push(AnyModel::Environment(v.clone()), ImportResourceType::Environment, v.parent_id.clone());
|
|
}
|
|
items
|
|
}
|
|
|
|
/// What a source key means for the resource the plan wants to put behind it.
|
|
enum KeyStatus<'a> {
|
|
/// The source has never been imported under this key
|
|
New,
|
|
Wanted(&'a ImportSourceResource),
|
|
/// Imported under this key before, and since turned down or deleted
|
|
NotWanted,
|
|
}
|
|
|
|
/// Strip identity and bookkeeping fields so equality means "same content in the same place".
|
|
/// The deprecated environment `base` flag mirrors `parentModel`, which is compared already.
|
|
/// Importers number `sortPriority` from source order, so comparing it would turn one insertion
|
|
/// into an update of everything after it.
|
|
fn comparable(mut value: Value) -> Value {
|
|
if let Some(object) = value.as_object_mut() {
|
|
for field in
|
|
["id", "model", "workspaceId", "createdAt", "updatedAt", "base", "sortPriority"]
|
|
{
|
|
object.remove(field);
|
|
}
|
|
}
|
|
value
|
|
}
|
|
|
|
const CONTENT_HASH_VERSION: &str = "v1:";
|
|
|
|
/// Identifies a resource's content well enough to tell "changed since the last import" from
|
|
/// "unchanged", without keeping a copy of every imported resource around.
|
|
fn content_hash(value: Value) -> String {
|
|
let canonical = serde_json::to_string(&sorted_keys(comparable(value))).unwrap_or_default();
|
|
format!("{CONTENT_HASH_VERSION}{:x}", Sha256::digest(canonical.as_bytes()))
|
|
}
|
|
|
|
/// A hash written by a version this build doesn't understand says nothing about the resource.
|
|
fn recorded_hash(row: &ImportSourceResource) -> Option<&str> {
|
|
let hash = row.content_hash.as_deref()?;
|
|
hash.starts_with(CONTENT_HASH_VERSION).then_some(hash)
|
|
}
|
|
|
|
fn sorted_keys(value: Value) -> Value {
|
|
match value {
|
|
Value::Object(object) => {
|
|
let mut entries = object.into_iter().collect::<Vec<_>>();
|
|
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
Value::Object(entries.into_iter().map(|(k, v)| (k, sorted_keys(v))).collect())
|
|
}
|
|
Value::Array(items) => Value::Array(items.into_iter().map(sorted_keys).collect()),
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
fn existing_model_json(
|
|
db: &ClientDb,
|
|
resource: ImportResourceType,
|
|
id: &str,
|
|
) -> Result<Option<Value>> {
|
|
use ImportResourceType::*;
|
|
let value = match resource {
|
|
Environment => db.get_environment(id).ok().map(|m| serde_json::to_value(&m)),
|
|
Folder => db.get_folder(id).ok().map(|m| serde_json::to_value(&m)),
|
|
HttpRequest => db.get_http_request(id).ok().map(|m| serde_json::to_value(&m)),
|
|
GrpcRequest => db.get_grpc_request(id).ok().map(|m| serde_json::to_value(&m)),
|
|
WebsocketRequest => db.get_websocket_request(id).ok().map(|m| serde_json::to_value(&m)),
|
|
Workspace => None,
|
|
};
|
|
Ok(value.transpose()?)
|
|
}
|
|
|
|
fn validate_destination(
|
|
query_manager: &QueryManager,
|
|
destination: &ImportDestination,
|
|
) -> Result<()> {
|
|
let db = query_manager.connect();
|
|
validate_destination_db(&db, destination)
|
|
}
|
|
|
|
fn validate_destination_db(db: &ClientDb<'_>, destination: &ImportDestination) -> Result<()> {
|
|
let ImportDestination::ExistingWorkspace { workspace_id, folder_id } = destination else {
|
|
return Ok(());
|
|
};
|
|
|
|
db.get_workspace(workspace_id)?;
|
|
if let Some(folder_id) = folder_id {
|
|
let folder = db.get_folder(folder_id)?;
|
|
if folder.workspace_id != *workspace_id {
|
|
return Err(yaak_models::error::Error::GenericError(format!(
|
|
"Folder {folder_id} does not belong to workspace {workspace_id}"
|
|
))
|
|
.into());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_plan(plan: &ImportPlan) -> Result<()> {
|
|
let invalid = |message: String| -> Result<()> {
|
|
Err(yaak_models::error::Error::GenericError(message).into())
|
|
};
|
|
|
|
match &plan.destination {
|
|
ImportDestination::ExistingWorkspace { workspace_id, .. } => {
|
|
if !plan.resources.workspaces.is_empty() {
|
|
return invalid(
|
|
"An existing-workspace import plan must not contain workspace updates"
|
|
.to_string(),
|
|
);
|
|
}
|
|
|
|
let all_workspace_ids = plan
|
|
.resources
|
|
.environments
|
|
.iter()
|
|
.map(|v| &v.workspace_id)
|
|
.chain(plan.resources.folders.iter().map(|v| &v.workspace_id))
|
|
.chain(plan.resources.http_requests.iter().map(|v| &v.workspace_id))
|
|
.chain(plan.resources.grpc_requests.iter().map(|v| &v.workspace_id))
|
|
.chain(plan.resources.websocket_requests.iter().map(|v| &v.workspace_id));
|
|
if all_workspace_ids.into_iter().any(|id| id != workspace_id) {
|
|
return invalid(
|
|
"An existing-workspace import plan contains resources for another workspace"
|
|
.to_string(),
|
|
);
|
|
}
|
|
|
|
// A merging plan may update the base environment it created earlier, which its plan
|
|
// item records; anything else must not replace the destination's base environment.
|
|
let updates_own_base = |id: &str| {
|
|
plan.items.iter().any(|i| {
|
|
i.model_id == id
|
|
&& !matches!(
|
|
i.action,
|
|
ImportPlanAction::Create | ImportPlanAction::NotImported
|
|
)
|
|
})
|
|
};
|
|
if plan
|
|
.resources
|
|
.environments
|
|
.iter()
|
|
.any(|v| v.parent_model == "workspace" && !updates_own_base(&v.id))
|
|
{
|
|
return invalid(
|
|
"An existing-workspace import plan must not replace the base environment"
|
|
.to_string(),
|
|
);
|
|
}
|
|
}
|
|
ImportDestination::NewWorkspace => {
|
|
let workspace_ids =
|
|
plan.resources.workspaces.iter().map(|v| v.id.as_str()).collect::<BTreeSet<_>>();
|
|
if workspace_ids.is_empty() {
|
|
return invalid("A new-workspace import plan has no workspace".to_string());
|
|
}
|
|
let all_workspace_ids = plan
|
|
.resources
|
|
.environments
|
|
.iter()
|
|
.map(|v| v.workspace_id.as_str())
|
|
.chain(plan.resources.folders.iter().map(|v| v.workspace_id.as_str()))
|
|
.chain(plan.resources.http_requests.iter().map(|v| v.workspace_id.as_str()))
|
|
.chain(plan.resources.grpc_requests.iter().map(|v| v.workspace_id.as_str()))
|
|
.chain(plan.resources.websocket_requests.iter().map(|v| v.workspace_id.as_str()));
|
|
if all_workspace_ids.into_iter().any(|id| !workspace_ids.contains(id)) {
|
|
return invalid(
|
|
"A new-workspace import plan contains resources outside its workspaces"
|
|
.to_string(),
|
|
);
|
|
}
|
|
|
|
let mut base_environment_workspaces = BTreeSet::new();
|
|
if plan.resources.environments.iter().any(|v| {
|
|
v.parent_model == "workspace"
|
|
&& !base_environment_workspaces.insert(v.workspace_id.as_str())
|
|
}) {
|
|
return invalid(
|
|
"A new-workspace import plan contains multiple base environments for one workspace"
|
|
.to_string(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let planned_folder_ids =
|
|
plan.resources.folders.iter().map(|v| v.id.as_str()).collect::<BTreeSet<_>>();
|
|
if plan.resources.environments.iter().any(|v| {
|
|
v.parent_model == "folder"
|
|
&& v.parent_id.as_deref().is_none_or(|id| !planned_folder_ids.contains(id))
|
|
}) {
|
|
return invalid(
|
|
"An import plan must not replace an existing folder environment".to_string(),
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn display_importer_name(importer: &str) -> &str {
|
|
importer.strip_prefix("@yaak/importer-").unwrap_or(importer)
|
|
}
|
|
|
|
fn workspace_fields_not_imported(source: &Workspace, destination: &Workspace) -> Vec<&'static str> {
|
|
let mut fields = Vec::new();
|
|
if source.name != destination.name {
|
|
fields.push("workspace name");
|
|
}
|
|
if source.description != destination.description {
|
|
fields.push("description");
|
|
}
|
|
if source.authentication != destination.authentication
|
|
|| source.authentication_type != destination.authentication_type
|
|
{
|
|
fields.push("authentication");
|
|
}
|
|
if source.headers != destination.headers {
|
|
fields.push("default headers");
|
|
}
|
|
if source.encryption_key_challenge != destination.encryption_key_challenge {
|
|
fields.push("encryption configuration");
|
|
}
|
|
if source.setting_validate_certificates != destination.setting_validate_certificates {
|
|
fields.push("certificate validation");
|
|
}
|
|
if source.setting_follow_redirects != destination.setting_follow_redirects {
|
|
fields.push("redirect behavior");
|
|
}
|
|
if source.setting_request_timeout != destination.setting_request_timeout {
|
|
fields.push("request timeout");
|
|
}
|
|
if source.setting_request_message_size != destination.setting_request_message_size {
|
|
fields.push("request message size");
|
|
}
|
|
if source.setting_dns_overrides != destination.setting_dns_overrides {
|
|
fields.push("DNS overrides");
|
|
}
|
|
if source.setting_send_cookies != destination.setting_send_cookies
|
|
|| source.setting_store_cookies != destination.setting_store_cookies
|
|
{
|
|
fields.push("cookie behavior");
|
|
}
|
|
fields
|
|
}
|
|
|
|
fn display_list(items: &BTreeSet<&str>) -> String {
|
|
let items = items.iter().copied().collect::<Vec<_>>();
|
|
match items.as_slice() {
|
|
[] => String::new(),
|
|
[item] => (*item).to_string(),
|
|
[first, second] => format!("{first} and {second}"),
|
|
_ => format!("{}, and {}", items[..items.len() - 1].join(", "), items[items.len() - 1]),
|
|
}
|
|
}
|
|
|
|
/// Keys are derived from the document as the importer produced it — names, routes, and folder
|
|
/// ancestry before any destination re-rooting or renaming — so the same document maps onto the
|
|
/// same keys no matter which workspace it is imported into. Collections are positional: planned
|
|
/// entry `i` came from original entry `i`.
|
|
fn assign_source_keys(
|
|
resources: &BatchUpsertResult,
|
|
original: &ImportResources,
|
|
plugin_keys: &BTreeMap<String, String>,
|
|
) -> BTreeMap<String, String> {
|
|
let folder_tree = original
|
|
.folders
|
|
.iter()
|
|
.map(|v| (v.id.clone(), (v.name.clone(), v.folder_id.clone())))
|
|
.collect::<BTreeMap<_, _>>();
|
|
|
|
let plugin_key = |source_id: Option<&String>| source_id.and_then(|id| plugin_keys.get(id));
|
|
|
|
// (model ID, importer key, key by name, key by name and content)
|
|
let mut candidates: Vec<(&str, Option<&String>, String, String)> = Vec::new();
|
|
|
|
for (i, v) in resources.workspaces.iter().enumerate() {
|
|
let source = original.workspaces.get(i);
|
|
let name = source.map(|s| s.name.as_str()).unwrap_or(v.name.as_str());
|
|
let key = fallback_key("workspace", &[], name);
|
|
candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), key.clone(), key));
|
|
}
|
|
for (i, v) in resources.environments.iter().enumerate() {
|
|
let source = original.environments.get(i);
|
|
let name = source.map(|s| s.name.as_str()).unwrap_or(v.name.as_str());
|
|
let parent_id = source.and_then(|s| {
|
|
if s.parent_model == "folder" { s.parent_id.as_deref() } else { None }
|
|
});
|
|
let ancestry = ancestry_path(&folder_tree, parent_id);
|
|
let key = fallback_key("environment", &ancestry, name);
|
|
candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), key.clone(), key));
|
|
}
|
|
for (i, v) in resources.folders.iter().enumerate() {
|
|
let source = original.folders.get(i);
|
|
let name = source.map(|s| s.name.as_str()).unwrap_or(v.name.as_str());
|
|
let ancestry =
|
|
ancestry_path(&folder_tree, source.and_then(|s| s.folder_id.as_deref()));
|
|
let key = fallback_key("folder", &ancestry, name);
|
|
candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), key.clone(), key));
|
|
}
|
|
for (i, v) in resources.http_requests.iter().enumerate() {
|
|
let source = original.http_requests.get(i);
|
|
let s = source.unwrap_or(v);
|
|
let ancestry = ancestry_path(&folder_tree, s.folder_id.as_deref());
|
|
let method = if s.method.is_empty() { "GET" } else { s.method.as_str() };
|
|
let route = format!("{method} {}", s.url);
|
|
let (by_name, by_content) = derived_pair("http_request", &ancestry, &s.name, &route);
|
|
candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), by_name, by_content));
|
|
}
|
|
for (i, v) in resources.grpc_requests.iter().enumerate() {
|
|
let source = original.grpc_requests.get(i);
|
|
let s = source.unwrap_or(v);
|
|
let ancestry = ancestry_path(&folder_tree, s.folder_id.as_deref());
|
|
let service = s.service.clone().unwrap_or_default();
|
|
let method = s.method.clone().unwrap_or_default();
|
|
let route = format!("{} {service}/{method}", s.url);
|
|
let (by_name, by_content) = derived_pair("grpc_request", &ancestry, &s.name, &route);
|
|
candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), by_name, by_content));
|
|
}
|
|
for (i, v) in resources.websocket_requests.iter().enumerate() {
|
|
let source = original.websocket_requests.get(i);
|
|
let s = source.unwrap_or(v);
|
|
let ancestry = ancestry_path(&folder_tree, s.folder_id.as_deref());
|
|
let (by_name, by_content) = derived_pair("websocket_request", &ancestry, &s.name, &s.url);
|
|
candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), by_name, by_content));
|
|
}
|
|
|
|
// A name shared by several resources identifies none of them, so every member of the group
|
|
// falls back to its own content. Counting the whole document first keeps that decision
|
|
// independent of the order the resources happen to be listed in.
|
|
let mut shared_names: BTreeMap<&str, usize> = BTreeMap::new();
|
|
for (_, plugin_key, by_name, _) in &candidates {
|
|
if plugin_key.is_none() {
|
|
*shared_names.entry(by_name.as_str()).or_default() += 1;
|
|
}
|
|
}
|
|
|
|
let mut keys = BTreeMap::new();
|
|
let mut used = BTreeSet::new();
|
|
for (model_id, plugin_key, by_name, by_content) in &candidates {
|
|
let key = match plugin_key {
|
|
Some(plugin_key) => (*plugin_key).clone(),
|
|
None if shared_names.get(by_name.as_str()).is_some_and(|n| *n > 1) => {
|
|
by_content.clone()
|
|
}
|
|
None => by_name.clone(),
|
|
};
|
|
|
|
// A key identifies one model, so a repeat has to be broken apart rather than overwrite.
|
|
// Reaching here means the resources are indistinguishable by name and content alike.
|
|
let mut unique = key.clone();
|
|
let mut attempt = 1;
|
|
while !used.insert(unique.clone()) {
|
|
attempt += 1;
|
|
unique = format!("{key}~{attempt}");
|
|
}
|
|
|
|
keys.insert((*model_id).to_string(), unique);
|
|
}
|
|
|
|
keys
|
|
}
|
|
|
|
const IDENTITY_SEP: char = '\u{1f}';
|
|
|
|
/// A resource's key by name, plus the one to use when another resource already took it.
|
|
fn derived_pair(model: &str, ancestry: &[String], name: &str, route: &str) -> (String, String) {
|
|
let by_name = if name.is_empty() { route } else { name };
|
|
let by_content = format!("{by_name}{IDENTITY_SEP}{route}");
|
|
(fallback_key(model, ancestry, by_name), fallback_key(model, ancestry, &by_content))
|
|
}
|
|
|
|
/// Stops at the first folder outside the plan, which is the existing folder an import targets.
|
|
fn ancestry_path(
|
|
folders: &BTreeMap<String, (String, Option<String>)>,
|
|
folder_id: Option<&str>,
|
|
) -> Vec<String> {
|
|
let mut path = Vec::new();
|
|
let mut seen = BTreeSet::new();
|
|
let mut next = folder_id.map(str::to_string);
|
|
while let Some(id) = next {
|
|
if !seen.insert(id.clone()) {
|
|
break;
|
|
}
|
|
let Some((name, parent_id)) = folders.get(&id) else {
|
|
break;
|
|
};
|
|
path.push(name.clone());
|
|
next = parent_id.clone();
|
|
}
|
|
path.reverse();
|
|
path
|
|
}
|
|
|
|
/// Known limitation: this changes when the source document renames or moves the resource, so a
|
|
/// re-import sees a rename as a delete plus an add.
|
|
fn fallback_key(model: &str, ancestry: &[String], identity: &str) -> String {
|
|
const RECORD: char = '\u{1e}';
|
|
let ancestry = ancestry.join(RECORD.to_string().as_str());
|
|
format!(
|
|
"fb:{:x}",
|
|
md5::compute(format!("{model}{IDENTITY_SEP}{ancestry}{IDENTITY_SEP}{identity}"))
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
use yaak_models::models::{EnvironmentVariable, HttpRequestHeader};
|
|
|
|
fn destination_workspace() -> Workspace {
|
|
Workspace {
|
|
id: "wk_destination".to_string(),
|
|
model: "workspace".to_string(),
|
|
name: "Destination".to_string(),
|
|
authentication: BTreeMap::from([("token".to_string(), json!("keep-me"))]),
|
|
authentication_type: Some("bearer".to_string()),
|
|
headers: vec![HttpRequestHeader {
|
|
enabled: true,
|
|
name: "X-Destination".to_string(),
|
|
value: "preserved".to_string(),
|
|
id: None,
|
|
}],
|
|
setting_validate_certificates: false,
|
|
setting_follow_redirects: false,
|
|
setting_request_timeout: 1234,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn imported_resources() -> ImportResources {
|
|
ImportResources {
|
|
workspaces: vec![Workspace {
|
|
id: "wk_source".to_string(),
|
|
model: "workspace".to_string(),
|
|
name: "Imported".to_string(),
|
|
authentication_type: Some("basic".to_string()),
|
|
setting_validate_certificates: true,
|
|
..Default::default()
|
|
}],
|
|
environments: vec![Environment {
|
|
id: "ev_source_base".to_string(),
|
|
model: "environment".to_string(),
|
|
workspace_id: "wk_source".to_string(),
|
|
name: "Global Variables".to_string(),
|
|
parent_model: "workspace".to_string(),
|
|
variables: vec![EnvironmentVariable {
|
|
enabled: true,
|
|
name: "imported".to_string(),
|
|
value: "yes".to_string(),
|
|
id: None,
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
folders: vec![Folder {
|
|
id: "fl_source".to_string(),
|
|
model: "folder".to_string(),
|
|
workspace_id: "wk_source".to_string(),
|
|
name: "Imported Folder".to_string(),
|
|
..Default::default()
|
|
}],
|
|
http_requests: vec![
|
|
HttpRequest {
|
|
id: "rq_root".to_string(),
|
|
model: "http_request".to_string(),
|
|
workspace_id: "wk_source".to_string(),
|
|
name: "Root Request".to_string(),
|
|
method: "GET".to_string(),
|
|
url: "https://example.com/root".to_string(),
|
|
..Default::default()
|
|
},
|
|
HttpRequest {
|
|
id: "rq_nested".to_string(),
|
|
model: "http_request".to_string(),
|
|
workspace_id: "wk_source".to_string(),
|
|
folder_id: Some("fl_source".to_string()),
|
|
name: "Nested Request".to_string(),
|
|
method: "GET".to_string(),
|
|
url: "https://example.com/nested".to_string(),
|
|
..Default::default()
|
|
},
|
|
],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn existing_workspace_plan_does_not_mutate_and_preserves_workspace_settings() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let mut destination = destination_workspace();
|
|
let selected_folder = Folder {
|
|
id: "fl_selected".to_string(),
|
|
model: "folder".to_string(),
|
|
workspace_id: destination.id.clone(),
|
|
name: "Selected Folder".to_string(),
|
|
..Default::default()
|
|
};
|
|
{
|
|
let db = query_manager.connect();
|
|
destination = db
|
|
.upsert_workspace(&destination, &UpdateSource::Import)
|
|
.expect("create destination");
|
|
db.upsert_folder(&selected_folder, &UpdateSource::Import)
|
|
.expect("create selected folder");
|
|
db.upsert_environment(
|
|
&Environment {
|
|
id: "ev_destination_base".to_string(),
|
|
model: "environment".to_string(),
|
|
workspace_id: destination.id.clone(),
|
|
name: "Destination Variables".to_string(),
|
|
parent_model: "workspace".to_string(),
|
|
variables: vec![EnvironmentVariable {
|
|
enabled: true,
|
|
name: "destination".to_string(),
|
|
value: "keep".to_string(),
|
|
id: None,
|
|
}],
|
|
..Default::default()
|
|
},
|
|
&UpdateSource::Import,
|
|
)
|
|
.expect("create base environment");
|
|
}
|
|
|
|
let plan = plan_import_resources(
|
|
&query_manager,
|
|
"OpenAPI".to_string(),
|
|
ImportDestination::ExistingWorkspace {
|
|
workspace_id: destination.id.clone(),
|
|
folder_id: Some(selected_folder.id.clone()),
|
|
},
|
|
imported_resources(),
|
|
None,
|
|
None,
|
|
)
|
|
.expect("plan import");
|
|
|
|
// Planning performed only reads.
|
|
{
|
|
let db = query_manager.connect();
|
|
assert_eq!(db.list_workspaces().expect("list workspaces").len(), 1);
|
|
assert_eq!(db.list_folders(&destination.id).expect("list folders").len(), 1);
|
|
assert!(db.list_http_requests(&destination.id).expect("list requests").is_empty());
|
|
assert_eq!(
|
|
db.list_environments_ensure_base(&destination.id).expect("list environments").len(),
|
|
1
|
|
);
|
|
assert_eq!(db.get_workspace(&destination.id).expect("get destination"), destination);
|
|
}
|
|
|
|
assert!(plan.resources.workspaces.is_empty());
|
|
assert_eq!(plan.resources.folders[0].workspace_id, destination.id);
|
|
assert_eq!(
|
|
plan.resources.folders[0].folder_id.as_deref(),
|
|
Some(selected_folder.id.as_str())
|
|
);
|
|
let root_request = plan
|
|
.resources
|
|
.http_requests
|
|
.iter()
|
|
.find(|v| v.name == "Root Request")
|
|
.expect("root request");
|
|
assert_eq!(root_request.folder_id.as_deref(), Some(selected_folder.id.as_str()));
|
|
let nested_request = plan
|
|
.resources
|
|
.http_requests
|
|
.iter()
|
|
.find(|v| v.name == "Nested Request")
|
|
.expect("nested request");
|
|
assert_eq!(nested_request.folder_id, Some(plan.resources.folders[0].id.clone()));
|
|
assert_eq!(plan.resources.environments[0].parent_model, "environment");
|
|
assert!(plan.resources.environments[0].name.ends_with("(Imported)"));
|
|
assert_eq!(plan.warnings.len(), 2);
|
|
assert!(plan.warnings.iter().any(|warning| {
|
|
warning.title == "Workspace settings skipped"
|
|
&& warning.detail.starts_with("Imported ·")
|
|
&& warning.detail.contains("authentication")
|
|
&& warning.detail.contains("default headers")
|
|
}));
|
|
assert!(plan.warnings.iter().any(|warning| {
|
|
warning.title == "Base environment kept separate"
|
|
&& warning.detail == "Global Variables → Global Variables (Imported) · 1 variable"
|
|
}));
|
|
|
|
let committed = commit_import_plan(&query_manager, plan).expect("commit import");
|
|
assert!(committed.workspaces.is_empty());
|
|
assert_eq!(committed.http_requests.len(), 2);
|
|
assert_eq!(
|
|
query_manager
|
|
.connect()
|
|
.get_workspace(&destination.id)
|
|
.expect("get destination after commit"),
|
|
destination
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn environment_collisions_are_explicit_and_do_not_overwrite() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let mut resources = imported_resources();
|
|
resources.environments.extend([
|
|
Environment {
|
|
id: "ev_second_base".to_string(),
|
|
model: "environment".to_string(),
|
|
workspace_id: "wk_source".to_string(),
|
|
name: "Second Base".to_string(),
|
|
parent_model: "workspace".to_string(),
|
|
..Default::default()
|
|
},
|
|
Environment {
|
|
id: "ev_folder_one".to_string(),
|
|
model: "environment".to_string(),
|
|
workspace_id: "wk_source".to_string(),
|
|
name: "Folder One".to_string(),
|
|
parent_model: "folder".to_string(),
|
|
parent_id: Some("fl_source".to_string()),
|
|
..Default::default()
|
|
},
|
|
Environment {
|
|
id: "ev_folder_two".to_string(),
|
|
model: "environment".to_string(),
|
|
workspace_id: "wk_source".to_string(),
|
|
name: "Folder Two".to_string(),
|
|
parent_model: "folder".to_string(),
|
|
parent_id: Some("fl_source".to_string()),
|
|
..Default::default()
|
|
},
|
|
]);
|
|
|
|
let plan = plan_import_resources(
|
|
&query_manager,
|
|
"Yaak".to_string(),
|
|
ImportDestination::NewWorkspace,
|
|
resources,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("plan import");
|
|
|
|
assert_eq!(
|
|
plan.resources.environments.iter().filter(|v| v.parent_model == "workspace").count(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
plan.resources.environments.iter().filter(|v| v.parent_model == "folder").count(),
|
|
1
|
|
);
|
|
assert_eq!(plan.warnings.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn importer_id_conventions_all_flow_through_the_same_planner() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let destination = destination_workspace();
|
|
query_manager
|
|
.connect()
|
|
.upsert_workspace(&destination, &UpdateSource::Import)
|
|
.expect("create destination");
|
|
let resources = ImportResources {
|
|
workspaces: vec![
|
|
Workspace {
|
|
id: "GENERATE_ID::WORKSPACE_0".to_string(),
|
|
model: "workspace".to_string(),
|
|
name: "Generated ID Importer".to_string(),
|
|
..Default::default()
|
|
},
|
|
Workspace {
|
|
id: "wk_exported".to_string(),
|
|
model: "workspace".to_string(),
|
|
name: "Stable ID Importer".to_string(),
|
|
..Default::default()
|
|
},
|
|
],
|
|
http_requests: [
|
|
"GENERATE_ID::WORKSPACE_0",
|
|
"wk_exported",
|
|
"CURRENT_WORKSPACE",
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(index, workspace_id)| HttpRequest {
|
|
id: format!("GENERATE_ID::HTTP_REQUEST_{index}"),
|
|
model: "http_request".to_string(),
|
|
workspace_id: workspace_id.to_string(),
|
|
name: format!("Request {index}"),
|
|
method: "GET".to_string(),
|
|
..Default::default()
|
|
})
|
|
.collect(),
|
|
..Default::default()
|
|
};
|
|
|
|
let plan = plan_import_resources(
|
|
&query_manager,
|
|
"Compatibility".to_string(),
|
|
ImportDestination::ExistingWorkspace {
|
|
workspace_id: destination.id.clone(),
|
|
folder_id: None,
|
|
},
|
|
resources,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("plan import");
|
|
|
|
assert!(plan.resources.workspaces.is_empty());
|
|
assert!(plan.resources.http_requests.iter().all(|v| v.workspace_id == destination.id));
|
|
assert_eq!(
|
|
plan.resources
|
|
.http_requests
|
|
.iter()
|
|
.map(|v| v.id.as_str())
|
|
.collect::<BTreeSet<_>>()
|
|
.len(),
|
|
3
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn commit_rolls_back_every_resource_when_a_late_write_fails() {
|
|
let dir = tempfile::tempdir().expect("create temp directory");
|
|
let db_path = dir.path().join("models.sqlite");
|
|
let blob_path = dir.path().join("blobs.sqlite");
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_standalone(&db_path, &blob_path).expect("initialize database");
|
|
let plan = plan_import_resources(
|
|
&query_manager,
|
|
"OpenAPI".to_string(),
|
|
ImportDestination::NewWorkspace,
|
|
imported_resources(),
|
|
None,
|
|
Some(linked_origin()),
|
|
)
|
|
.expect("plan import");
|
|
let workspace_id = plan.resources.workspaces[0].id.clone();
|
|
let environment_id = plan.resources.environments[0].id.clone();
|
|
|
|
let connection = rusqlite::Connection::open(&db_path).expect("open test database");
|
|
connection
|
|
.execute_batch(&format!(
|
|
"CREATE TRIGGER fail_import_environment BEFORE INSERT ON environments \
|
|
WHEN NEW.id = '{environment_id}' BEGIN SELECT RAISE(FAIL, 'forced failure'); END;"
|
|
))
|
|
.expect("install failure trigger");
|
|
drop(connection);
|
|
|
|
assert!(commit_import_plan(&query_manager, plan).is_err());
|
|
let db = query_manager.connect();
|
|
assert!(db.get_workspace(&workspace_id).is_err(), "workspace insert must roll back");
|
|
assert!(db.get_environment(&environment_id).is_err(), "environment must not exist");
|
|
assert!(
|
|
db.list_import_sources(&workspace_id).expect("list import sources").is_empty(),
|
|
"import source must roll back"
|
|
);
|
|
}
|
|
|
|
fn request_key<'a>(plan: &'a ImportPlan, name: &str) -> &'a str {
|
|
let request = plan
|
|
.resources
|
|
.http_requests
|
|
.iter()
|
|
.find(|v| v.name == name)
|
|
.unwrap_or_else(|| panic!("no planned request named {name}"));
|
|
plan.source_keys.get(&request.id).expect("request has a source key")
|
|
}
|
|
|
|
fn plan_with_keys(
|
|
resources: ImportResources,
|
|
source_keys: Option<BTreeMap<String, String>>,
|
|
) -> ImportPlan {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
plan_import_resources(
|
|
&query_manager,
|
|
"Yaak".to_string(),
|
|
ImportDestination::NewWorkspace,
|
|
resources,
|
|
source_keys,
|
|
None,
|
|
)
|
|
.expect("plan import")
|
|
}
|
|
|
|
#[test]
|
|
fn every_planned_model_gets_a_source_key() {
|
|
let plan = plan_with_keys(imported_resources(), None);
|
|
|
|
let planned_ids = plan
|
|
.resources
|
|
.workspaces
|
|
.iter()
|
|
.map(|v| v.id.clone())
|
|
.chain(plan.resources.environments.iter().map(|v| v.id.clone()))
|
|
.chain(plan.resources.folders.iter().map(|v| v.id.clone()))
|
|
.chain(plan.resources.http_requests.iter().map(|v| v.id.clone()))
|
|
.collect::<BTreeSet<_>>();
|
|
|
|
assert_eq!(plan.source_keys.keys().cloned().collect::<BTreeSet<_>>(), planned_ids);
|
|
assert!(
|
|
plan.source_keys.values().all(|key| key.starts_with("fb:")),
|
|
"an importer that supplied no keys should leave every key derived: {:?}",
|
|
plan.source_keys,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn importer_keys_win_over_derived_ones() {
|
|
let source_keys = BTreeMap::from([("rq_nested".to_string(), "op:listPets".to_string())]);
|
|
let plan = plan_with_keys(imported_resources(), Some(source_keys));
|
|
|
|
assert_eq!(request_key(&plan, "Nested Request"), "op:listPets");
|
|
assert!(request_key(&plan, "Root Request").starts_with("fb:"));
|
|
}
|
|
|
|
#[test]
|
|
fn derived_keys_survive_a_re_parse_that_mints_new_ids() {
|
|
let first = plan_with_keys(imported_resources(), None);
|
|
|
|
let mut edited = imported_resources();
|
|
for (i, request) in edited.http_requests.iter_mut().enumerate() {
|
|
request.id = format!("reparsed_{i}");
|
|
request.url = format!("{}?added=1", request.url);
|
|
}
|
|
edited.folders[0].id = "reparsed_folder".to_string();
|
|
edited.http_requests[1].folder_id = Some("reparsed_folder".to_string());
|
|
let second = plan_with_keys(edited, None);
|
|
|
|
assert_eq!(request_key(&first, "Root Request"), request_key(&second, "Root Request"));
|
|
assert_eq!(request_key(&first, "Nested Request"), request_key(&second, "Nested Request"));
|
|
}
|
|
|
|
#[test]
|
|
fn derived_keys_distinguish_same_named_requests_by_folder() {
|
|
let mut resources = imported_resources();
|
|
resources.http_requests[1].name = resources.http_requests[0].name.clone();
|
|
let plan = plan_with_keys(resources, None);
|
|
|
|
let keys = plan.source_keys.values().collect::<BTreeSet<_>>();
|
|
assert_eq!(keys.len(), plan.source_keys.len(), "keys collided: {:?}", plan.source_keys);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_keys_are_broken_apart_so_none_are_lost() {
|
|
let mut resources = imported_resources();
|
|
resources.http_requests[1].folder_id = None;
|
|
resources.http_requests[1].name = resources.http_requests[0].name.clone();
|
|
resources.http_requests[1].url = resources.http_requests[0].url.clone();
|
|
let plan = plan_with_keys(resources, None);
|
|
|
|
assert_eq!(plan.source_keys.len(), 5);
|
|
let keys = plan.source_keys.values().collect::<BTreeSet<_>>();
|
|
assert_eq!(keys.len(), 5, "keys collided: {:?}", plan.source_keys);
|
|
assert!(
|
|
plan.source_keys.values().any(|key| key.ends_with("~2")),
|
|
"the repeat should be suffixed: {:?}",
|
|
plan.source_keys,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn same_named_siblings_keep_their_keys_when_the_document_reorders() {
|
|
let build = |swap: bool| {
|
|
let mut resources = imported_resources();
|
|
resources.http_requests[0].name = "Get".to_string();
|
|
resources.http_requests[1].name = "Get".to_string();
|
|
resources.http_requests[0].folder_id = Some("fl_source".to_string());
|
|
resources.http_requests[0].url = "https://example.com/a".to_string();
|
|
resources.http_requests[1].url = "https://example.com/b".to_string();
|
|
if swap {
|
|
resources.http_requests.swap(0, 1);
|
|
}
|
|
let plan = plan_with_keys(resources, None);
|
|
plan.resources
|
|
.http_requests
|
|
.iter()
|
|
.map(|r| (r.url.clone(), plan.source_keys[&r.id].clone()))
|
|
.collect::<BTreeMap<_, _>>()
|
|
};
|
|
|
|
// Listing the pair the other way round must not hand each other's key over, or a later
|
|
// re-import would credit one request's edits to the other.
|
|
assert_eq!(build(false), build(true));
|
|
}
|
|
|
|
#[test]
|
|
fn derived_keys_are_prefixed_so_importer_keys_stay_distinguishable() {
|
|
let source_keys = BTreeMap::from([("rq_root".to_string(), "op:root".to_string())]);
|
|
let plan = plan_with_keys(imported_resources(), Some(source_keys));
|
|
|
|
assert!(!request_key(&plan, "Root Request").starts_with("fb:"));
|
|
assert!(request_key(&plan, "Nested Request").starts_with("fb:"));
|
|
}
|
|
|
|
fn linked_origin() -> ImportOrigin {
|
|
ImportOrigin { origin: "/tmp/api.yaml".to_string(), label: "api.yaml".to_string() }
|
|
}
|
|
|
|
fn importer_keys() -> BTreeMap<String, String> {
|
|
BTreeMap::from([
|
|
("ev_source_base".to_string(), "env:base".to_string()),
|
|
("fl_source".to_string(), "folder:src".to_string()),
|
|
("rq_root".to_string(), "op:root".to_string()),
|
|
("rq_nested".to_string(), "op:nested".to_string()),
|
|
("rq_extra".to_string(), "op:extra".to_string()),
|
|
("fl_extra".to_string(), "folder:extra".to_string()),
|
|
])
|
|
}
|
|
|
|
fn first_import(query_manager: &QueryManager) -> BatchUpsertResult {
|
|
let plan = plan_import_resources(
|
|
query_manager,
|
|
"OpenAPI".to_string(),
|
|
ImportDestination::NewWorkspace,
|
|
imported_resources(),
|
|
Some(importer_keys()),
|
|
Some(linked_origin()),
|
|
)
|
|
.expect("plan first import");
|
|
commit_import_plan(query_manager, plan).expect("commit first import")
|
|
}
|
|
|
|
fn replan(
|
|
query_manager: &QueryManager,
|
|
workspace_id: &str,
|
|
resources: ImportResources,
|
|
) -> ImportPlan {
|
|
replan_from(query_manager, workspace_id, resources, linked_origin())
|
|
}
|
|
|
|
fn replan_from(
|
|
query_manager: &QueryManager,
|
|
workspace_id: &str,
|
|
resources: ImportResources,
|
|
origin: ImportOrigin,
|
|
) -> ImportPlan {
|
|
plan_import_resources(
|
|
query_manager,
|
|
"OpenAPI".to_string(),
|
|
ImportDestination::ExistingWorkspace {
|
|
workspace_id: workspace_id.to_string(),
|
|
folder_id: None,
|
|
},
|
|
resources,
|
|
Some(importer_keys()),
|
|
Some(origin),
|
|
)
|
|
.expect("plan re-import")
|
|
}
|
|
|
|
fn extra_request() -> HttpRequest {
|
|
HttpRequest {
|
|
id: "rq_extra".to_string(),
|
|
model: "http_request".to_string(),
|
|
workspace_id: "wk_source".to_string(),
|
|
name: "Extra Request".to_string(),
|
|
method: "GET".to_string(),
|
|
url: "https://example.com/extra".to_string(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn select(plan: &mut ImportPlan, name: &str, selected: bool) {
|
|
let item = plan
|
|
.items
|
|
.iter_mut()
|
|
.find(|i| i.name == name)
|
|
.unwrap_or_else(|| panic!("no plan item named {name}"));
|
|
item.selected = selected;
|
|
}
|
|
|
|
fn source_rows(query_manager: &QueryManager, workspace_id: &str) -> Vec<ImportSourceResource> {
|
|
let db = query_manager.connect();
|
|
let sources = db.list_import_sources(workspace_id).expect("list import sources");
|
|
assert_eq!(sources.len(), 1, "expected one linked source: {sources:?}");
|
|
db.list_import_source_resources(&sources[0].id).expect("list resource rows")
|
|
}
|
|
|
|
fn item_by_name<'a>(plan: &'a ImportPlan, name: &str) -> &'a ImportPlanItem {
|
|
plan.items
|
|
.iter()
|
|
.find(|i| i.name == name)
|
|
.unwrap_or_else(|| panic!("no plan item named {name}: {:?}", plan.items))
|
|
}
|
|
|
|
#[test]
|
|
fn commit_records_the_linked_source_and_hashes() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let committed = first_import(&query_manager);
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
|
|
let source = {
|
|
let db = query_manager.connect();
|
|
let source = db
|
|
.find_import_source(&workspace_id, "OpenAPI", "/tmp/api.yaml")
|
|
.expect("query import source")
|
|
.expect("import source recorded");
|
|
assert_eq!(source.origin_label, "api.yaml");
|
|
|
|
let rows = db.list_import_source_resources(&source.id).expect("list resource rows");
|
|
assert_eq!(rows.len(), 4, "one row per non-workspace resource: {rows:?}");
|
|
for row in &rows {
|
|
let model_id = row.model_id.as_deref().expect("row is wanted");
|
|
let resource = ImportResourceType::from_str(&row.model_type)
|
|
.expect("row has a known resource type");
|
|
let current = existing_model_json(&db, resource, model_id)
|
|
.expect("query current model")
|
|
.expect("row target exists");
|
|
assert_eq!(row.content_hash.as_deref(), Some(content_hash(current).as_str()));
|
|
}
|
|
source
|
|
};
|
|
|
|
// Re-importing the identical document is a no-op offer: everything unchanged,
|
|
// ancestry re-rooting does not count as a change, and the base environment it
|
|
// created stays the base environment.
|
|
let plan = replan(&query_manager, &workspace_id, imported_resources());
|
|
assert!(
|
|
plan.items.iter().all(|i| i.action == ImportPlanAction::Unchanged),
|
|
"expected all unchanged: {:?}",
|
|
plan.items
|
|
);
|
|
let base = plan
|
|
.resources
|
|
.environments
|
|
.iter()
|
|
.find(|e| e.name == "Global Variables")
|
|
.expect("base environment keeps its original name");
|
|
assert_eq!(base.parent_model, "workspace");
|
|
commit_import_plan(&query_manager, plan).expect("commit re-import");
|
|
|
|
let db = query_manager.connect();
|
|
assert_eq!(db.list_http_requests(&workspace_id).expect("list requests").len(), 2);
|
|
assert_eq!(db.list_folders(&workspace_id).expect("list folders").len(), 1);
|
|
assert_eq!(
|
|
db.list_environments_ensure_base(&workspace_id).expect("list environments").len(),
|
|
1
|
|
);
|
|
let rows = db.list_import_source_resources(&source.id).expect("list resource rows");
|
|
assert_eq!(rows.len(), 4, "re-commit replaces rows instead of accumulating");
|
|
}
|
|
|
|
#[test]
|
|
fn re_import_merges_source_and_local_changes() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let committed = first_import(&query_manager);
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
let root_id = committed
|
|
.http_requests
|
|
.iter()
|
|
.find(|r| r.name == "Root Request")
|
|
.expect("root request")
|
|
.id
|
|
.clone();
|
|
|
|
{
|
|
let db = query_manager.connect();
|
|
let nested = db
|
|
.list_http_requests(&workspace_id)
|
|
.expect("list requests")
|
|
.into_iter()
|
|
.find(|r| r.name == "Nested Request")
|
|
.expect("nested request");
|
|
db.upsert_http_request(
|
|
&HttpRequest {
|
|
url: "https://example.com/nested-local".to_string(),
|
|
..nested.clone()
|
|
},
|
|
&UpdateSource::Background,
|
|
)
|
|
.expect("edit nested request locally");
|
|
}
|
|
|
|
let mut resources = imported_resources();
|
|
resources.http_requests[0].url = "https://example.com/root-v2".to_string();
|
|
resources.http_requests.push(HttpRequest {
|
|
id: "rq_extra".to_string(),
|
|
model: "http_request".to_string(),
|
|
workspace_id: "wk_source".to_string(),
|
|
name: "Extra Request".to_string(),
|
|
method: "GET".to_string(),
|
|
url: "https://example.com/extra".to_string(),
|
|
..Default::default()
|
|
});
|
|
|
|
let plan = replan(&query_manager, &workspace_id, resources);
|
|
let root = item_by_name(&plan, "Root Request");
|
|
assert_eq!(root.action, ImportPlanAction::Update);
|
|
assert!(root.selected);
|
|
assert_eq!(root.model_id, root_id, "update targets the mapped model");
|
|
let nested = item_by_name(&plan, "Nested Request");
|
|
assert_eq!(nested.action, ImportPlanAction::KeepLocal);
|
|
assert!(!nested.selected);
|
|
let extra = item_by_name(&plan, "Extra Request");
|
|
assert_eq!(extra.action, ImportPlanAction::Create);
|
|
assert!(extra.selected);
|
|
assert_eq!(item_by_name(&plan, "Imported Folder").action, ImportPlanAction::Unchanged);
|
|
|
|
commit_import_plan(&query_manager, plan).expect("commit merge");
|
|
|
|
let db = query_manager.connect();
|
|
let requests = db.list_http_requests(&workspace_id).expect("list requests");
|
|
assert_eq!(requests.len(), 3);
|
|
assert_eq!(
|
|
requests.iter().find(|r| r.name == "Root Request").expect("root").url,
|
|
"https://example.com/root-v2"
|
|
);
|
|
assert_eq!(
|
|
requests.iter().find(|r| r.name == "Nested Request").expect("nested").url,
|
|
"https://example.com/nested-local"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rename_in_source_updates_the_same_model() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let committed = first_import(&query_manager);
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
let root_id = committed
|
|
.http_requests
|
|
.iter()
|
|
.find(|r| r.name == "Root Request")
|
|
.expect("root request")
|
|
.id
|
|
.clone();
|
|
|
|
let mut resources = imported_resources();
|
|
resources.http_requests[0].name = "Root Request Renamed".to_string();
|
|
let plan = replan(&query_manager, &workspace_id, resources);
|
|
let renamed = item_by_name(&plan, "Root Request Renamed");
|
|
assert_eq!(renamed.action, ImportPlanAction::Update);
|
|
assert_eq!(renamed.model_id, root_id);
|
|
|
|
commit_import_plan(&query_manager, plan).expect("commit rename");
|
|
let requests =
|
|
query_manager.connect().list_http_requests(&workspace_id).expect("list requests");
|
|
assert_eq!(requests.len(), 2, "rename must not duplicate");
|
|
assert!(requests.iter().any(|r| r.id == root_id && r.name == "Root Request Renamed"));
|
|
}
|
|
|
|
#[test]
|
|
fn keep_mine_conflicts_advance_and_are_not_offered_again() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let committed = first_import(&query_manager);
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
let root_id = committed
|
|
.http_requests
|
|
.iter()
|
|
.find(|r| r.name == "Root Request")
|
|
.expect("root request")
|
|
.id
|
|
.clone();
|
|
|
|
{
|
|
let db = query_manager.connect();
|
|
let root = db.get_http_request(&root_id).expect("get root");
|
|
db.upsert_http_request(
|
|
&HttpRequest { url: "https://example.com/root-local".to_string(), ..root },
|
|
&UpdateSource::Background,
|
|
)
|
|
.expect("edit root locally");
|
|
}
|
|
|
|
let mut resources = imported_resources();
|
|
resources.http_requests[0].url = "https://example.com/root-v2".to_string();
|
|
|
|
let plan = replan(&query_manager, &workspace_id, resources.clone());
|
|
let root = item_by_name(&plan, "Root Request");
|
|
assert_eq!(root.action, ImportPlanAction::Conflict);
|
|
assert_eq!(root.resolution, Some(ImportConflictResolution::KeepMine));
|
|
commit_import_plan(&query_manager, plan).expect("commit keep-mine");
|
|
|
|
assert_eq!(
|
|
query_manager.connect().get_http_request(&root_id).expect("get root").url,
|
|
"https://example.com/root-local",
|
|
"keep-mine must not overwrite the local edit"
|
|
);
|
|
|
|
// The decision was recorded, so the same source version stops nagging.
|
|
let plan = replan(&query_manager, &workspace_id, resources);
|
|
assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::KeepLocal);
|
|
|
|
// A newer source version conflicts again; taking it overwrites the local edit.
|
|
let mut resources = imported_resources();
|
|
resources.http_requests[0].url = "https://example.com/root-v3".to_string();
|
|
let mut plan = replan(&query_manager, &workspace_id, resources.clone());
|
|
assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::Conflict);
|
|
for item in plan.items.iter_mut() {
|
|
if item.action == ImportPlanAction::Conflict {
|
|
item.resolution = Some(ImportConflictResolution::TakeSource);
|
|
}
|
|
}
|
|
commit_import_plan(&query_manager, plan).expect("commit take-source");
|
|
assert_eq!(
|
|
query_manager.connect().get_http_request(&root_id).expect("get root").url,
|
|
"https://example.com/root-v3"
|
|
);
|
|
let plan = replan(&query_manager, &workspace_id, resources);
|
|
assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::Unchanged);
|
|
}
|
|
|
|
#[test]
|
|
fn deselected_update_is_offered_again_next_import() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let committed = first_import(&query_manager);
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
|
|
let mut resources = imported_resources();
|
|
resources.http_requests[0].url = "https://example.com/root-v2".to_string();
|
|
|
|
let mut plan = replan(&query_manager, &workspace_id, resources.clone());
|
|
for item in plan.items.iter_mut() {
|
|
if item.action == ImportPlanAction::Update {
|
|
item.selected = false;
|
|
}
|
|
}
|
|
commit_import_plan(&query_manager, plan).expect("commit with deselected update");
|
|
|
|
let db = query_manager.connect();
|
|
let root = db
|
|
.list_http_requests(&workspace_id)
|
|
.expect("list requests")
|
|
.into_iter()
|
|
.find(|r| r.name == "Root Request")
|
|
.expect("root request");
|
|
assert_eq!(root.url, "https://example.com/root", "deselected update must not apply");
|
|
drop(db);
|
|
|
|
let plan = replan(&query_manager, &workspace_id, resources);
|
|
assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::Update);
|
|
}
|
|
|
|
#[test]
|
|
fn source_removals_are_deselected_offers_until_applied() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let committed = first_import(&query_manager);
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
|
|
let mut resources = imported_resources();
|
|
resources.http_requests.remove(1);
|
|
|
|
let plan = replan(&query_manager, &workspace_id, resources.clone());
|
|
let removal = item_by_name(&plan, "Nested Request");
|
|
assert_eq!(removal.action, ImportPlanAction::Delete);
|
|
assert!(!removal.selected, "deletions default to deselected");
|
|
commit_import_plan(&query_manager, plan).expect("commit with default selection");
|
|
assert_eq!(
|
|
query_manager.connect().list_http_requests(&workspace_id).expect("list").len(),
|
|
2,
|
|
"deselected deletion must not delete"
|
|
);
|
|
|
|
let mut plan = replan(&query_manager, &workspace_id, resources.clone());
|
|
assert_eq!(item_by_name(&plan, "Nested Request").action, ImportPlanAction::Delete);
|
|
for item in plan.items.iter_mut() {
|
|
if item.action == ImportPlanAction::Delete {
|
|
item.selected = true;
|
|
}
|
|
}
|
|
commit_import_plan(&query_manager, plan).expect("commit with deletion");
|
|
assert_eq!(
|
|
query_manager.connect().list_http_requests(&workspace_id).expect("list").len(),
|
|
1
|
|
);
|
|
|
|
let plan = replan(&query_manager, &workspace_id, resources);
|
|
assert!(
|
|
plan.items.iter().all(|i| i.action != ImportPlanAction::Delete),
|
|
"applied deletion must not be offered again: {:?}",
|
|
plan.items
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn locally_deleted_mapped_model_is_not_resurrected() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let committed = first_import(&query_manager);
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
let root_id = committed
|
|
.http_requests
|
|
.iter()
|
|
.find(|r| r.name == "Root Request")
|
|
.expect("root request")
|
|
.id
|
|
.clone();
|
|
|
|
query_manager
|
|
.connect()
|
|
.delete_http_request_by_id(&root_id, &UpdateSource::Background)
|
|
.expect("delete root locally");
|
|
|
|
let plan = replan(&query_manager, &workspace_id, imported_resources());
|
|
let root = item_by_name(&plan, "Root Request");
|
|
assert_eq!(root.action, ImportPlanAction::NotImported, "deleting it means not wanting it");
|
|
assert!(!root.selected);
|
|
assert_ne!(root.model_id, root_id);
|
|
|
|
commit_import_plan(&query_manager, plan).expect("commit re-import");
|
|
assert_eq!(
|
|
query_manager.connect().list_http_requests(&workspace_id).expect("list").len(),
|
|
1,
|
|
"a locally deleted resource must not come back"
|
|
);
|
|
|
|
let plan = replan(&query_manager, &workspace_id, imported_resources());
|
|
assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::NotImported);
|
|
}
|
|
|
|
#[test]
|
|
fn deselected_create_is_not_offered_again() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let committed = first_import(&query_manager);
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
|
|
let mut resources = imported_resources();
|
|
resources.http_requests.push(extra_request());
|
|
|
|
let mut plan = replan(&query_manager, &workspace_id, resources.clone());
|
|
assert_eq!(item_by_name(&plan, "Extra Request").action, ImportPlanAction::Create);
|
|
select(&mut plan, "Extra Request", false);
|
|
commit_import_plan(&query_manager, plan).expect("commit with deselected create");
|
|
assert_eq!(
|
|
query_manager.connect().list_http_requests(&workspace_id).expect("list").len(),
|
|
2,
|
|
"a deselected create must not be created"
|
|
);
|
|
|
|
let plan = replan(&query_manager, &workspace_id, resources);
|
|
let extra = item_by_name(&plan, "Extra Request");
|
|
assert_eq!(extra.action, ImportPlanAction::NotImported, "deselecting it is remembered");
|
|
assert!(!extra.selected);
|
|
}
|
|
|
|
#[test]
|
|
fn restoring_a_not_imported_item_relinks_its_source_key() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let committed = first_import(&query_manager);
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
|
|
let mut resources = imported_resources();
|
|
resources.http_requests.push(extra_request());
|
|
|
|
let mut plan = replan(&query_manager, &workspace_id, resources.clone());
|
|
select(&mut plan, "Extra Request", false);
|
|
commit_import_plan(&query_manager, plan).expect("commit with deselected create");
|
|
|
|
let mut plan = replan(&query_manager, &workspace_id, resources.clone());
|
|
assert_eq!(item_by_name(&plan, "Extra Request").action, ImportPlanAction::NotImported);
|
|
select(&mut plan, "Extra Request", true);
|
|
commit_import_plan(&query_manager, plan).expect("commit with restored item");
|
|
|
|
let extra = query_manager
|
|
.connect()
|
|
.list_http_requests(&workspace_id)
|
|
.expect("list")
|
|
.into_iter()
|
|
.find(|r| r.name == "Extra Request")
|
|
.expect("restored request exists");
|
|
let rows = source_rows(&query_manager, &workspace_id);
|
|
let row = rows.iter().find(|r| r.source_key == "op:extra").expect("row for the same key");
|
|
assert_eq!(row.model_id.as_deref(), Some(extra.id.as_str()), "the key links to the model");
|
|
|
|
let plan = replan(&query_manager, &workspace_id, resources);
|
|
assert_eq!(item_by_name(&plan, "Extra Request").action, ImportPlanAction::Unchanged);
|
|
}
|
|
|
|
#[test]
|
|
fn moving_into_a_not_imported_folder_offers_a_delete() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let committed = first_import(&query_manager);
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
|
|
let with_extra_folder = |root_inside: bool| {
|
|
let mut resources = imported_resources();
|
|
resources.folders.push(Folder {
|
|
id: "fl_extra".to_string(),
|
|
model: "folder".to_string(),
|
|
workspace_id: "wk_source".to_string(),
|
|
name: "Extra Folder".to_string(),
|
|
..Default::default()
|
|
});
|
|
if root_inside {
|
|
resources.http_requests[0].folder_id = Some("fl_extra".to_string());
|
|
}
|
|
resources
|
|
};
|
|
|
|
let mut plan = replan(&query_manager, &workspace_id, with_extra_folder(false));
|
|
select(&mut plan, "Extra Folder", false);
|
|
commit_import_plan(&query_manager, plan).expect("commit without the new folder");
|
|
|
|
// The source moves an imported request into the folder that was turned down.
|
|
let plan = replan(&query_manager, &workspace_id, with_extra_folder(true));
|
|
assert_eq!(item_by_name(&plan, "Extra Folder").action, ImportPlanAction::NotImported);
|
|
let root = item_by_name(&plan, "Root Request");
|
|
assert_eq!(root.action, ImportPlanAction::Delete);
|
|
assert!(!root.selected, "a deletion is never applied by default");
|
|
assert_eq!(root.reason, Some(ImportPlanReason::MovedIntoNotImportedFolder));
|
|
|
|
// Anything new inside that folder can't be created either, so it waits for the folder.
|
|
let mut resources = with_extra_folder(true);
|
|
resources.http_requests.push(HttpRequest {
|
|
folder_id: Some("fl_extra".to_string()),
|
|
..extra_request()
|
|
});
|
|
let plan = replan(&query_manager, &workspace_id, resources);
|
|
let extra = item_by_name(&plan, "Extra Request");
|
|
assert_eq!(extra.action, ImportPlanAction::Create);
|
|
assert!(!extra.selected, "a create with nowhere to go starts unchecked");
|
|
|
|
let mut plan = replan(&query_manager, &workspace_id, with_extra_folder(true));
|
|
select(&mut plan, "Root Request", true);
|
|
commit_import_plan(&query_manager, plan).expect("commit the move as a deletion");
|
|
|
|
let db = query_manager.connect();
|
|
let requests = db.list_http_requests(&workspace_id).expect("list");
|
|
assert!(!requests.iter().any(|r| r.name == "Root Request"), "accepting deletes it");
|
|
drop(db);
|
|
|
|
let plan = replan(&query_manager, &workspace_id, with_extra_folder(true));
|
|
assert_eq!(
|
|
item_by_name(&plan, "Root Request").action,
|
|
ImportPlanAction::NotImported,
|
|
"accepting the deletion means the resource is no longer wanted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_source_that_moved_still_merges_and_updates_its_origin() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let committed = first_import(&query_manager);
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
|
|
let moved =
|
|
ImportOrigin { origin: "/tmp/api/v1.yaml".to_string(), label: "v1.yaml".to_string() };
|
|
let mut resources = imported_resources();
|
|
resources.http_requests[0].url = "https://example.com/root-v2".to_string();
|
|
|
|
let plan = replan_from(&query_manager, &workspace_id, resources, moved.clone());
|
|
assert!(plan.warnings.iter().all(|w| w.title != "Imported as new"), "{:?}", plan.warnings);
|
|
assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::Update);
|
|
commit_import_plan(&query_manager, plan).expect("commit merge from the new path");
|
|
|
|
let db = query_manager.connect();
|
|
assert_eq!(
|
|
db.list_http_requests(&workspace_id).expect("list").len(),
|
|
2,
|
|
"a renamed file must not duplicate what it created"
|
|
);
|
|
let sources = db.list_import_sources(&workspace_id).expect("list import sources");
|
|
assert_eq!(sources.len(), 1, "the link follows the file: {sources:?}");
|
|
assert_eq!(sources[0].origin, moved.origin);
|
|
assert_eq!(sources[0].origin_label, moved.label);
|
|
}
|
|
|
|
#[test]
|
|
fn an_ambiguous_overlap_warns_instead_of_merging() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let committed = first_import(&query_manager);
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
|
|
// A second source claiming the same keys leaves nothing to merge into safely.
|
|
{
|
|
let db = query_manager.connect();
|
|
let other = db
|
|
.upsert_import_source(
|
|
&ImportSource {
|
|
workspace_id: workspace_id.clone(),
|
|
importer: "OpenAPI".to_string(),
|
|
origin: "/tmp/copy.yaml".to_string(),
|
|
origin_label: "copy.yaml".to_string(),
|
|
..Default::default()
|
|
},
|
|
&UpdateSource::Import,
|
|
)
|
|
.expect("create second source");
|
|
db.upsert_import_source_resource(&ImportSourceResource {
|
|
import_source_id: other.id,
|
|
source_key: "op:root".to_string(),
|
|
model_type: "http_request".to_string(),
|
|
..Default::default()
|
|
})
|
|
.expect("claim the same key");
|
|
}
|
|
|
|
let third =
|
|
ImportOrigin { origin: "/tmp/third.yaml".to_string(), label: "third.yaml".to_string() };
|
|
let plan = replan_from(&query_manager, &workspace_id, imported_resources(), third);
|
|
assert!(
|
|
plan.items.iter().all(|i| i.action == ImportPlanAction::Create),
|
|
"an ambiguous link must never guess: {:?}",
|
|
plan.items
|
|
);
|
|
let warning = plan
|
|
.warnings
|
|
.iter()
|
|
.find(|w| w.title == "Imported as new")
|
|
.expect("ambiguity is surfaced");
|
|
assert!(warning.detail.contains("api.yaml"), "{warning:?}");
|
|
assert!(warning.detail.contains("copy.yaml"), "{warning:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_unrecognized_hash_degrades_to_a_conflict() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let committed = first_import(&query_manager);
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
|
|
{
|
|
let db = query_manager.connect();
|
|
let sources = db.list_import_sources(&workspace_id).expect("list import sources");
|
|
let row = db
|
|
.list_import_source_resources(&sources[0].id)
|
|
.expect("list rows")
|
|
.into_iter()
|
|
.find(|r| r.source_key == "op:root")
|
|
.expect("row for the root request");
|
|
db.upsert_import_source_resource(&ImportSourceResource {
|
|
content_hash: Some("v99:from-the-future".to_string()),
|
|
..row
|
|
})
|
|
.expect("write an unreadable hash");
|
|
}
|
|
|
|
let plan = replan(&query_manager, &workspace_id, imported_resources());
|
|
assert_eq!(
|
|
item_by_name(&plan, "Root Request").action,
|
|
ImportPlanAction::Unchanged,
|
|
"with nothing to compare against, matching sides are still unchanged"
|
|
);
|
|
|
|
let mut resources = imported_resources();
|
|
resources.http_requests[0].url = "https://example.com/root-v2".to_string();
|
|
let plan = replan(&query_manager, &workspace_id, resources);
|
|
let root = item_by_name(&plan, "Root Request");
|
|
assert_eq!(root.action, ImportPlanAction::Conflict, "a difference can't be attributed");
|
|
assert_eq!(root.resolution, Some(ImportConflictResolution::KeepMine));
|
|
}
|
|
|
|
#[test]
|
|
fn reordering_the_source_document_changes_nothing() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let committed = first_import(&query_manager);
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
|
|
// Inserting anything ahead of an existing resource renumbers everything after it.
|
|
let mut resources = imported_resources();
|
|
for (i, request) in resources.http_requests.iter_mut().enumerate() {
|
|
request.sort_priority = (i as f64 + 1.0) * 1000.0;
|
|
}
|
|
resources.folders[0].sort_priority = 500.0;
|
|
|
|
let plan = replan(&query_manager, &workspace_id, resources);
|
|
assert!(
|
|
plan.items.iter().all(|i| i.action == ImportPlanAction::Unchanged),
|
|
"a reorder is not a change: {:?}",
|
|
plan.items
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deselecting_a_new_folder_skips_its_descendants() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let mut plan = plan_import_resources(
|
|
&query_manager,
|
|
"OpenAPI".to_string(),
|
|
ImportDestination::NewWorkspace,
|
|
imported_resources(),
|
|
Some(importer_keys()),
|
|
Some(linked_origin()),
|
|
)
|
|
.expect("plan import");
|
|
|
|
assert_eq!(plan.items.len(), 4, "one create item per non-workspace resource");
|
|
assert!(plan.items.iter().all(|i| i.action == ImportPlanAction::Create && i.selected));
|
|
|
|
for item in plan.items.iter_mut() {
|
|
if item.model == ImportResourceType::Folder {
|
|
item.selected = false;
|
|
}
|
|
}
|
|
let committed = commit_import_plan(&query_manager, plan).expect("commit import");
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
|
|
let db = query_manager.connect();
|
|
assert!(db.list_folders(&workspace_id).expect("list folders").is_empty());
|
|
let requests = db.list_http_requests(&workspace_id).expect("list requests");
|
|
assert_eq!(requests.len(), 1, "requests inside the skipped folder are skipped too");
|
|
assert_eq!(requests[0].name, "Root Request");
|
|
|
|
let source = db
|
|
.find_import_source(&workspace_id, "OpenAPI", "/tmp/api.yaml")
|
|
.expect("query import source")
|
|
.expect("import source recorded");
|
|
let rows = db.list_import_source_resources(&source.id).expect("list resource rows");
|
|
assert_eq!(rows.len(), 4, "every key is remembered, wanted or not: {rows:?}");
|
|
let not_wanted = rows.iter().filter(|r| r.model_id.is_none()).count();
|
|
assert_eq!(not_wanted, 2, "the skipped folder and its request are remembered: {rows:?}");
|
|
}
|
|
|
|
|
|
#[test]
|
|
fn desktop_style_json_roundtrip_records_source() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let plan = plan_import_resources(
|
|
&query_manager,
|
|
"OpenAPI".to_string(),
|
|
ImportDestination::NewWorkspace,
|
|
imported_resources(),
|
|
None,
|
|
Some(linked_origin()),
|
|
)
|
|
.expect("plan import");
|
|
let json = serde_json::to_string(&plan).expect("serialize plan");
|
|
let plan: ImportPlan = serde_json::from_str(&json).expect("deserialize plan");
|
|
let committed = commit_import_plan(&query_manager, plan).expect("commit");
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
let source = query_manager
|
|
.connect()
|
|
.find_import_source(&workspace_id, "OpenAPI", "/tmp/api.yaml")
|
|
.expect("query")
|
|
.expect("source recorded after JSON round-trip");
|
|
assert_eq!(source.origin_label, "api.yaml");
|
|
}
|
|
|
|
#[test]
|
|
fn selected_keep_local_reverts_the_local_edit() {
|
|
let (query_manager, _blob_manager, _rx) =
|
|
yaak_models::init_in_memory().expect("initialize database");
|
|
let committed = first_import(&query_manager);
|
|
let workspace_id = committed.workspaces[0].id.clone();
|
|
let root_id = committed
|
|
.http_requests
|
|
.iter()
|
|
.find(|r| r.name == "Root Request")
|
|
.expect("root request")
|
|
.id
|
|
.clone();
|
|
|
|
{
|
|
let db = query_manager.connect();
|
|
let root = db.get_http_request(&root_id).expect("get root");
|
|
db.upsert_http_request(
|
|
&HttpRequest { url: "https://example.com/root-local".to_string(), ..root },
|
|
&UpdateSource::Background,
|
|
)
|
|
.expect("edit root locally");
|
|
}
|
|
|
|
let mut plan = replan(&query_manager, &workspace_id, imported_resources());
|
|
let root = item_by_name(&plan, "Root Request");
|
|
assert_eq!(root.action, ImportPlanAction::KeepLocal);
|
|
assert!(!root.selected, "keep-local defaults to keeping the local edit");
|
|
for item in plan.items.iter_mut() {
|
|
if item.action == ImportPlanAction::KeepLocal {
|
|
item.selected = true;
|
|
}
|
|
}
|
|
commit_import_plan(&query_manager, plan).expect("commit revert");
|
|
|
|
assert_eq!(
|
|
query_manager.connect().get_http_request(&root_id).expect("get root").url,
|
|
"https://example.com/root",
|
|
"selected keep-local must revert to the source version"
|
|
);
|
|
let plan = replan(&query_manager, &workspace_id, imported_resources());
|
|
assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::Unchanged);
|
|
}
|
|
}
|