diff --git a/crates-cli/yaak-cli/src/commands/import_export.rs b/crates-cli/yaak-cli/src/commands/import_export.rs index 25233da9..61573e18 100644 --- a/crates-cli/yaak-cli/src/commands/import_export.rs +++ b/crates-cli/yaak-cli/src/commands/import_export.rs @@ -63,8 +63,14 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult ImportDestination::ExistingWorkspace { workspace_id, folder_id: None }, None => ImportDestination::NewWorkspace, }; - let plan = import::plan_import_resources(ctx.query_manager(), importer, destination, resources) - .map_err(|e| format!("Failed to plan import: {e}"))?; + let plan = import::plan_import_resources( + ctx.query_manager(), + importer, + destination, + resources, + import_result.source_keys, + ) + .map_err(|e| format!("Failed to plan import: {e}"))?; let imported = import::commit_import_plan(ctx.query_manager(), plan) .map_err(|e| format!("Failed to import data: {e}"))?; Ok(imported) diff --git a/crates/common/yaak-rpc-schema/bindings/gen_util.ts b/crates/common/yaak-rpc-schema/bindings/gen_util.ts index 3730e500..e0762604 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_util.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_util.ts @@ -11,6 +11,13 @@ export type BatchUpsertResult = { workspaces: Array, environments: Ar */ export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, }; -export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array, }; +export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array, +/** + * Stable source key for every model in `resources`, keyed by its freshly minted ID. + * + * Committing the plan can persist these so a later import of the same document recognizes + * which models it already created, instead of duplicating them. + */ +sourceKeys: { [key in string]?: string }, }; export type ImportPlanWarning = { title: string, detail: string, }; diff --git a/crates/yaak-models/bindings/gen_util.ts b/crates/yaak-models/bindings/gen_util.ts index 3730e500..e0762604 100644 --- a/crates/yaak-models/bindings/gen_util.ts +++ b/crates/yaak-models/bindings/gen_util.ts @@ -11,6 +11,13 @@ export type BatchUpsertResult = { workspaces: Array, environments: Ar */ export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, }; -export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array, }; +export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array, +/** + * Stable source key for every model in `resources`, keyed by its freshly minted ID. + * + * Committing the plan can persist these so a later import of the same document recognizes + * which models it already created, instead of duplicating them. + */ +sourceKeys: { [key in string]?: string }, }; export type ImportPlanWarning = { title: string, detail: string, }; diff --git a/crates/yaak-models/src/util.rs b/crates/yaak-models/src/util.rs index c632862a..7d6a8195 100644 --- a/crates/yaak-models/src/util.rs +++ b/crates/yaak-models/src/util.rs @@ -119,6 +119,12 @@ pub struct ImportPlan { pub destination: ImportDestination, pub resources: BatchUpsertResult, pub warnings: Vec, + + /// Stable source key for every model in `resources`, keyed by its freshly minted ID. + /// + /// Committing the plan can persist these so a later import of the same document recognizes + /// which models it already created, instead of duplicating them. + pub source_keys: BTreeMap, } pub fn get_workspace_export_resources( diff --git a/crates/yaak-plugins/bindings/gen_events.ts b/crates/yaak-plugins/bindings/gen_events.ts index d601beb6..26c56eb6 100644 --- a/crates/yaak-plugins/bindings/gen_events.ts +++ b/crates/yaak-plugins/bindings/gen_events.ts @@ -474,7 +474,19 @@ export type ImportRequest = { content: string, }; export type ImportResources = { workspaces: Array, environments: Array, folders: Array, httpRequests: Array, grpcRequests: Array, websocketRequests: Array, }; -export type ImportResponse = { importer: string, resources: ImportResources, }; +export type ImportResponse = { +/** + * Display name of the importer that recognized the input. + */ +importer: string, resources: ImportResources, +/** + * Stable identity for imported resources, keyed by the resource IDs in `resources`. + * + * A key identifies the same source element across re-parses of an edited document, which + * lets a later import recognize what it already imported. Importers only populate it for + * formats that carry their own identifiers; the host derives a key for anything missing. + */ +sourceKeys?: { [key in string]?: string }, }; export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, }; diff --git a/crates/yaak-plugins/src/events.rs b/crates/yaak-plugins/src/events.rs index 05d3b331..96963163 100644 --- a/crates/yaak-plugins/src/events.rs +++ b/crates/yaak-plugins/src/events.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use ts_rs::TS; use yaak_models::models::{ AnyModel, Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest, @@ -250,6 +250,14 @@ pub struct ImportResponse { /// Display name of the importer that recognized the input. pub importer: String, pub resources: ImportResources, + + /// Stable identity for imported resources, keyed by the resource IDs in `resources`. + /// + /// A key identifies the same source element across re-parses of an edited document, which + /// lets a later import recognize what it already imported. Importers only populate it for + /// formats that carry their own identifiers; the host derives a key for anything missing. + #[ts(optional)] + pub source_keys: Option>, } #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] diff --git a/crates/yaak/src/import.rs b/crates/yaak/src/import.rs index 28ed831b..93f11384 100644 --- a/crates/yaak/src/import.rs +++ b/crates/yaak/src/import.rs @@ -31,6 +31,7 @@ pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result>, ) -> Result { let mut warnings = Vec::new(); validate_destination(query_manager, &destination)?; + // Every model below gets a fresh ID, so remember which source each came from while the + // importer's IDs are still on them. + let plugin_keys = source_keys.unwrap_or_default(); + let source_ids = SourceIds::collect(&resources); + let source_folder_ids = resources.folders.iter().map(|v| v.id.clone()).collect::>(); let mut folder_ids = BTreeMap::new(); for folder in &resources.folders { @@ -248,17 +255,20 @@ pub fn plan_import_resources( }); } + let resources = BatchUpsertResult { + workspaces, + environments, + folders, + http_requests, + grpc_requests, + websocket_requests, + }; + Ok(ImportPlan { importer, destination, - resources: BatchUpsertResult { - workspaces, - environments, - folders, - http_requests, - grpc_requests, - websocket_requests, - }, + source_keys: assign_source_keys(&resources, &source_ids, &plugin_keys), + resources, warnings, }) } @@ -455,6 +465,156 @@ fn display_list(items: &BTreeSet<&str>) -> String { } } +/// Importer-assigned IDs, captured before planning replaces them with freshly minted ones. +/// +/// Positional: each vector lines up with the same-named collection of the planned resources, +/// which planning maps one-to-one and in order. +struct SourceIds { + workspaces: Vec, + environments: Vec, + folders: Vec, + http_requests: Vec, + grpc_requests: Vec, + websocket_requests: Vec, +} + +impl SourceIds { + fn collect(resources: &ImportResources) -> Self { + let ids = |ids: &mut dyn Iterator| ids.cloned().collect::>(); + SourceIds { + workspaces: ids(&mut resources.workspaces.iter().map(|v| &v.id)), + environments: ids(&mut resources.environments.iter().map(|v| &v.id)), + folders: ids(&mut resources.folders.iter().map(|v| &v.id)), + http_requests: ids(&mut resources.http_requests.iter().map(|v| &v.id)), + grpc_requests: ids(&mut resources.grpc_requests.iter().map(|v| &v.id)), + websocket_requests: ids(&mut resources.websocket_requests.iter().map(|v| &v.id)), + } + } +} + +/// Give every planned model a stable key, preferring the importer's over a derived one. +fn assign_source_keys( + resources: &BatchUpsertResult, + source_ids: &SourceIds, + plugin_keys: &BTreeMap, +) -> BTreeMap { + let folder_tree = resources + .folders + .iter() + .map(|v| (v.id.clone(), (v.name.clone(), v.folder_id.clone()))) + .collect::>(); + + let mut keys = BTreeMap::new(); + let mut used = BTreeSet::new(); + let mut assign = |model_id: &str, source_id: Option<&String>, derive: &dyn Fn() -> String| { + let key = source_id + .and_then(|source_id| plugin_keys.get(source_id)) + .cloned() + .unwrap_or_else(derive); + + // A key identifies exactly one model, so a repeat has to be broken apart. Duplicates are + // expected from derived keys (same name, same folder) and a plugin bug otherwise. + 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); + }; + + for (i, v) in resources.workspaces.iter().enumerate() { + assign(&v.id, source_ids.workspaces.get(i), &|| fallback_key("workspace", &[], &v.name)); + } + for (i, v) in resources.environments.iter().enumerate() { + assign(&v.id, source_ids.environments.get(i), &|| { + // Folder environments are unnamed in some formats, so the parent disambiguates them. + let ancestry = ancestry_path(&folder_tree, v.parent_id.as_deref()); + fallback_key("environment", &ancestry, &v.name) + }); + } + for (i, v) in resources.folders.iter().enumerate() { + assign(&v.id, source_ids.folders.get(i), &|| { + let ancestry = ancestry_path(&folder_tree, v.folder_id.as_deref()); + fallback_key("folder", &ancestry, &v.name) + }); + } + for (i, v) in resources.http_requests.iter().enumerate() { + assign(&v.id, source_ids.http_requests.get(i), &|| { + let ancestry = ancestry_path(&folder_tree, v.folder_id.as_deref()); + let method = if v.method.is_empty() { "GET" } else { v.method.as_str() }; + fallback_key( + "http_request", + &ancestry, + &identity(&v.name, || format!("{method} {}", v.url)), + ) + }); + } + for (i, v) in resources.grpc_requests.iter().enumerate() { + assign(&v.id, source_ids.grpc_requests.get(i), &|| { + let ancestry = ancestry_path(&folder_tree, v.folder_id.as_deref()); + fallback_key( + "grpc_request", + &ancestry, + &identity(&v.name, || { + let service = v.service.clone().unwrap_or_default(); + let method = v.method.clone().unwrap_or_default(); + format!("{} {service}/{method}", v.url) + }), + ) + }); + } + for (i, v) in resources.websocket_requests.iter().enumerate() { + assign(&v.id, source_ids.websocket_requests.get(i), &|| { + let ancestry = ancestry_path(&folder_tree, v.folder_id.as_deref()); + fallback_key("websocket_request", &ancestry, &identity(&v.name, || v.url.clone())) + }); + } + + keys +} + +fn identity(name: &str, or_else: impl Fn() -> String) -> String { + if name.is_empty() { or_else() } else { name.to_string() } +} + +/// Folder names from the workspace down to `folder_id`. +/// +/// Stops at the first folder outside the plan, which is the existing folder an import targets. +fn ancestry_path( + folders: &BTreeMap)>, + folder_id: Option<&str>, +) -> Vec { + 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 +} + +/// Derive a key for an importer that gave the resource no identity of its own. +/// +/// 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 { + // ASCII separators keep the parts from running together into the same hash input. + const UNIT: char = '\u{1f}'; + const RECORD: char = '\u{1e}'; + let ancestry = ancestry.join(RECORD.to_string().as_str()); + format!("fb:{:x}", md5::compute(format!("{model}{UNIT}{ancestry}{UNIT}{identity}"))) +} + #[cfg(test)] mod tests { use super::*; @@ -584,6 +744,7 @@ mod tests { folder_id: Some(selected_folder.id.clone()), }, imported_resources(), + None, ) .expect("plan import"); @@ -685,6 +846,7 @@ mod tests { "Yaak".to_string(), ImportDestination::NewWorkspace, resources, + None, ) .expect("plan import"); @@ -750,6 +912,7 @@ mod tests { folder_id: None, }, resources, + None, ) .expect("plan import"); @@ -778,6 +941,7 @@ mod tests { "OpenAPI".to_string(), ImportDestination::NewWorkspace, imported_resources(), + None, ) .expect("plan import"); let workspace_id = plan.resources.workspaces[0].id.clone(); @@ -797,4 +961,120 @@ mod tests { 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"); } + + /// Look up the key for the only planned request whose name matches. + 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>, + ) -> 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, + ) + .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::>(); + + assert_eq!(plan.source_keys.keys().cloned().collect::>(), 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); + + // A re-parse of an edited document hands planning entirely different IDs, and planning + // mints fresh ones on top, so nothing about the identity may come from either. + 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::>(); + assert_eq!(keys.len(), plan.source_keys.len(), "keys collided: {:?}", plan.source_keys); + } + + #[test] + fn duplicate_keys_are_broken_apart_so_none_are_lost() { + // Two requests with the same name in the same folder derive the same key, and a key that + // identifies two models is no use for matching either of them later. + 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::>(); + 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 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:")); + } } diff --git a/packages/plugin-runtime-types/src/bindings/gen_events.ts b/packages/plugin-runtime-types/src/bindings/gen_events.ts index d601beb6..26c56eb6 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_events.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_events.ts @@ -474,7 +474,19 @@ export type ImportRequest = { content: string, }; export type ImportResources = { workspaces: Array, environments: Array, folders: Array, httpRequests: Array, grpcRequests: Array, websocketRequests: Array, }; -export type ImportResponse = { importer: string, resources: ImportResources, }; +export type ImportResponse = { +/** + * Display name of the importer that recognized the input. + */ +importer: string, resources: ImportResources, +/** + * Stable identity for imported resources, keyed by the resource IDs in `resources`. + * + * A key identifies the same source element across re-parses of an edited document, which + * lets a later import recognize what it already imported. Importers only populate it for + * formats that carry their own identifiers; the host derives a key for anything missing. + */ +sourceKeys?: { [key in string]?: string }, }; export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, }; diff --git a/packages/plugin-runtime-types/src/plugins/ImporterPlugin.ts b/packages/plugin-runtime-types/src/plugins/ImporterPlugin.ts index 0d42c4cc..2c5cedec 100644 --- a/packages/plugin-runtime-types/src/plugins/ImporterPlugin.ts +++ b/packages/plugin-runtime-types/src/plugins/ImporterPlugin.ts @@ -16,6 +16,16 @@ export type PartialImportResources = { export type ImportPluginResponse = null | { resources: PartialImportResources; + + /** + * Stable identity for imported resources, keyed by the resource IDs in `resources`. + * + * A key identifies the same source element across re-parses of an edited document, so it must + * come from the document itself and must not be derived from anything the user can change in + * Yaak after importing. Omit resources the format gives no identifier — the host derives a key + * for those. + */ + sourceKeys?: Record; }; export type ImporterPlugin = { diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index 6f18bba0..f93b2607 100644 --- a/packages/plugin-runtime/src/PluginInstance.ts +++ b/packages/plugin-runtime/src/PluginInstance.ts @@ -169,6 +169,7 @@ export class PluginInstance { type: "import_response", importer: this.#mod.importer.name, resources: reply.resources as ImportResources, + sourceKeys: reply.sourceKeys ?? null, }; this.#sendPayload(context, replyPayload, replyId); return; diff --git a/plugins/importer-curl/tests/index.test.ts b/plugins/importer-curl/tests/index.test.ts index a2e70c0b..de148a03 100644 --- a/plugins/importer-curl/tests/index.test.ts +++ b/plugins/importer-curl/tests/index.test.ts @@ -959,6 +959,11 @@ describe("importer-curl", () => { { enabled: true, name: "q", value: "a=b" }, ]); }); + + // curl commands carry no identity of their own, so the host derives every key instead. + test("Emits no source keys", () => { + expect(convertCurl("curl https://yaak.app")).not.toHaveProperty("sourceKeys"); + }); }); const idCount: Partial> = {}; diff --git a/plugins/importer-insomnia/src/common.ts b/plugins/importer-insomnia/src/common.ts index 1fe0d082..9d91c540 100644 --- a/plugins/importer-insomnia/src/common.ts +++ b/plugins/importer-insomnia/src/common.ts @@ -15,6 +15,24 @@ export function convertId(id: string): string { return `GENERATE_ID::${id}`; } +/** + * Recover the Insomnia `_id` behind each emitted resource. + * + * Every resource ID here is `convertId` of the ID the document gave it, which Insomnia keeps + * across edits, so undoing that prefix recovers a stable source key. + */ +export function sourceKeysFromResources(resources: any): Record { + const sourceKeys: Record = {}; + for (const models of Object.values(resources ?? {})) { + if (!Array.isArray(models)) continue; + for (const model of models) { + if (typeof model?.id !== "string") continue; + sourceKeys[model.id] = model.id.replace(/^GENERATE_ID::/, ""); + } + } + return sourceKeys; +} + export function importHttpBodyAndHeaders(obj: any) { const { headers } = importHeaders(obj); const { body, bodyType } = importHttpBody(obj.body); diff --git a/plugins/importer-insomnia/src/index.ts b/plugins/importer-insomnia/src/index.ts index cd54fc77..da5bb7d1 100644 --- a/plugins/importer-insomnia/src/index.ts +++ b/plugins/importer-insomnia/src/index.ts @@ -1,6 +1,6 @@ import type { Context, PluginDefinition } from "@yaakapp/api"; import YAML from "yaml"; -import { deleteUndefinedAttrs, isJSObject } from "./common"; +import { deleteUndefinedAttrs, isJSObject, sourceKeysFromResources } from "./common"; import { convertInsomniaV4 } from "./v4"; import { convertInsomniaV5 } from "./v5"; @@ -32,6 +32,10 @@ export function convertInsomnia(contents: string) { if (!isJSObject(parsed)) return null; const result = convertInsomniaV5(parsed) ?? convertInsomniaV4(parsed); + if (result == null) return null; - return deleteUndefinedAttrs(result); + return deleteUndefinedAttrs({ + ...result, + sourceKeys: sourceKeysFromResources(result.resources), + }); } diff --git a/plugins/importer-insomnia/tests/fixtures/basic.output.json b/plugins/importer-insomnia/tests/fixtures/basic.output.json index 589c4695..b8705d6f 100644 --- a/plugins/importer-insomnia/tests/fixtures/basic.output.json +++ b/plugins/importer-insomnia/tests/fixtures/basic.output.json @@ -132,5 +132,13 @@ "name": "Dummy" } ] + }, + "sourceKeys": { + "GENERATE_ID::env_16c0dec5b77c414ae0e419b8f10c3701300c5900": "env_16c0dec5b77c414ae0e419b8f10c3701300c5900", + "GENERATE_ID::env_799ae3d723ef44af91b4817e5d057e6d": "env_799ae3d723ef44af91b4817e5d057e6d", + "GENERATE_ID::env_030fbfdbb274426ebd78e2e6518f8553": "env_030fbfdbb274426ebd78e2e6518f8553", + "GENERATE_ID::fld_859d1df78261463480b6a3a1419517e3": "fld_859d1df78261463480b6a3a1419517e3", + "GENERATE_ID::req_84cd9ae4bd034dd8bb730e856a665cbb": "req_84cd9ae4bd034dd8bb730e856a665cbb", + "GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019": "wrk_d4d92f7c0ee947b89159243506687019" } } diff --git a/plugins/importer-insomnia/tests/fixtures/version-5-minimal.output.json b/plugins/importer-insomnia/tests/fixtures/version-5-minimal.output.json index 81db0e91..949d6611 100644 --- a/plugins/importer-insomnia/tests/fixtures/version-5-minimal.output.json +++ b/plugins/importer-insomnia/tests/fixtures/version-5-minimal.output.json @@ -116,5 +116,13 @@ "headers": [] } ] + }, + "sourceKeys": { + "GENERATE_ID::env_e46dc73e8ccda30ca132153e8f11183bd08119ce": "env_e46dc73e8ccda30ca132153e8f11183bd08119ce", + "GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7folder": "fld_296933ea4ea84783a775d199997e9be7folder", + "GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7": "fld_296933ea4ea84783a775d199997e9be7", + "GENERATE_ID::req_9a80320365ac4509ade406359dbc6a71": "req_9a80320365ac4509ade406359dbc6a71", + "GENERATE_ID::req_e3f8cdbd58784a539dd4c1e127d73451": "req_e3f8cdbd58784a539dd4c1e127d73451", + "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c": "wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c" } } diff --git a/plugins/importer-insomnia/tests/fixtures/version-5.output.json b/plugins/importer-insomnia/tests/fixtures/version-5.output.json index e61f3424..1cff2cee 100644 --- a/plugins/importer-insomnia/tests/fixtures/version-5.output.json +++ b/plugins/importer-insomnia/tests/fixtures/version-5.output.json @@ -189,5 +189,15 @@ "headers": [] } ] + }, + "sourceKeys": { + "GENERATE_ID::env_20945044d3c8497ca8b717bef750987e": "env_20945044d3c8497ca8b717bef750987e", + "GENERATE_ID::env_6f7728bb7fc04d558d668e954d756ea2": "env_6f7728bb7fc04d558d668e954d756ea2", + "GENERATE_ID::env_976a8b6eb5d44fb6a20150f65c32d243": "env_976a8b6eb5d44fb6a20150f65c32d243", + "GENERATE_ID::fld_42eb2e2bb22b4cedacbd3d057634e80c": "fld_42eb2e2bb22b4cedacbd3d057634e80c", + "GENERATE_ID::greq_06d659324df94504a4d64632be7106b3": "greq_06d659324df94504a4d64632be7106b3", + "GENERATE_ID::req_d72fff2a6b104b91a2ebe9de9edd2785": "req_d72fff2a6b104b91a2ebe9de9edd2785", + "GENERATE_ID::ws-req_5d1a4c7c79494743962e5176f6add270": "ws-req_5d1a4c7c79494743962e5176f6add270", + "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53": "wrk_c1eacfa750a04f3ea9985ef28043fa53" } } diff --git a/plugins/importer-insomnia/tests/index.test.ts b/plugins/importer-insomnia/tests/index.test.ts index d77f2589..bc6a4468 100644 --- a/plugins/importer-insomnia/tests/index.test.ts +++ b/plugins/importer-insomnia/tests/index.test.ts @@ -24,6 +24,40 @@ describe("importer-yaak", () => { expect(result).toEqual(parseJsonOrYaml(expected)); }); } + + // A source key has to survive edits to the document and stay put when the user renames the + // request in Yaak, otherwise a re-import cannot tell an edit apart from a new request. + test("Keys resources by their Insomnia _id, unchanged by a rename", () => { + const collection = (requestName: string) => + YAML.stringify({ + type: "collection.insomnia.rest/5.0", + name: "Keys", + meta: { id: "wrk_1" }, + environments: { meta: { id: "env_1" }, name: "Base", data: {} }, + collection: [ + { + meta: { id: "fld_1" }, + name: "Folder", + children: [ + { + meta: { id: "req_1" }, + name: requestName, + method: "GET", + url: "https://yaak.app", + }, + ], + }, + ], + }); + + const before = convertInsomnia(collection("Original")); + const after = convertInsomnia(collection("Renamed")); + + expect(before?.sourceKeys?.[before.resources.httpRequests[0]!.id]).toBe("req_1"); + expect(after?.sourceKeys?.[after.resources.httpRequests[0]!.id]).toBe("req_1"); + expect(before?.sourceKeys?.[before.resources.folders[0]!.id]).toBe("fld_1"); + expect(before?.sourceKeys?.[before.resources.workspaces[0]!.id]).toBe("wrk_1"); + }); }); function parseJsonOrYaml(text: string): unknown { diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index 4a0e5d32..0170c5fd 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -110,6 +110,7 @@ export async function convertOpenApi(contents: string): Promise(); const routeLabels = new Map(); + const sourceKeys: Record = {}; for (const tag of toArray(spec.tags)) { const tagRecord = toRecord(tag); const name = stringAt(tagRecord, "name"); @@ -126,6 +127,7 @@ export async function convertOpenApi(contents: string): Promise; importState: ImportState; operation: UnknownRecord; resources: ImportResources; + sourceKeys: Record; workspaceId: string; }): string | null { const tag = toArray(operation.tags).find((t): t is string => typeof t === "string"); @@ -705,9 +716,26 @@ function findOrCreateFolderId({ }; resources.folders.push(folder); folderIdsByTag.set(tag, folder.id); + sourceKeys[folder.id] = tagSourceKey(tag); return folder.id; } +/** + * Identify an operation by the parts of the spec that name the endpoint rather than describe it. + * + * `operationId` is the spec's own identifier for an operation and survives a summary being + * reworded; without one, the route is the only thing left that still points at the same endpoint. + */ +function operationSourceKey(operationId: string | undefined, method: string, path: string): string { + return operationId != null && operationId !== "" + ? `op:${operationId}` + : `route:${method.toUpperCase()} ${path}`; +} + +function tagSourceKey(tag: string): string { + return `tag:${tag}`; +} + /** * Yaak's `:name` placeholders only substitute when they span a whole path * segment and hold a single plain value. Templates elsewhere in a segment diff --git a/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap b/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap index f9aac685..73b04a3d 100644 --- a/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap +++ b/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap @@ -308,6 +308,16 @@ License: CC0 1.0 (https://github.com/APIs-guru/openapi-directory#licenses)", }, ], }, + "sourceKeys": { + "GENERATE_ID::FOLDER_0": "tag:APIs", + "GENERATE_ID::HTTP_REQUEST_0": "op:listAPIs", + "GENERATE_ID::HTTP_REQUEST_1": "op:getMetrics", + "GENERATE_ID::HTTP_REQUEST_2": "op:getProviders", + "GENERATE_ID::HTTP_REQUEST_3": "op:getAPI", + "GENERATE_ID::HTTP_REQUEST_4": "op:getServiceAPI", + "GENERATE_ID::HTTP_REQUEST_5": "op:getProvider", + "GENERATE_ID::HTTP_REQUEST_6": "op:getServices", + }, } `; @@ -2600,6 +2610,97 @@ Contact: me@kennethreitz.org", }, ], }, + "sourceKeys": { + "GENERATE_ID::FOLDER_0": "tag:HTTP Methods", + "GENERATE_ID::FOLDER_1": "tag:Auth", + "GENERATE_ID::FOLDER_10": "tag:Anything", + "GENERATE_ID::FOLDER_2": "tag:Status codes", + "GENERATE_ID::FOLDER_3": "tag:Request inspection", + "GENERATE_ID::FOLDER_4": "tag:Response inspection", + "GENERATE_ID::FOLDER_5": "tag:Response formats", + "GENERATE_ID::FOLDER_6": "tag:Dynamic data", + "GENERATE_ID::FOLDER_7": "tag:Cookies", + "GENERATE_ID::FOLDER_8": "tag:Images", + "GENERATE_ID::FOLDER_9": "tag:Redirects", + "GENERATE_ID::HTTP_REQUEST_0": "route:GET /absolute-redirect/{n}", + "GENERATE_ID::HTTP_REQUEST_1": "route:DELETE /anything", + "GENERATE_ID::HTTP_REQUEST_10": "route:POST /anything/{anything}", + "GENERATE_ID::HTTP_REQUEST_11": "route:PUT /anything/{anything}", + "GENERATE_ID::HTTP_REQUEST_12": "route:TRACE /anything/{anything}", + "GENERATE_ID::HTTP_REQUEST_13": "route:GET /base64/{value}", + "GENERATE_ID::HTTP_REQUEST_14": "route:GET /basic-auth/{user}/{passwd}", + "GENERATE_ID::HTTP_REQUEST_15": "route:GET /bearer", + "GENERATE_ID::HTTP_REQUEST_16": "route:GET /brotli", + "GENERATE_ID::HTTP_REQUEST_17": "route:GET /bytes/{n}", + "GENERATE_ID::HTTP_REQUEST_18": "route:GET /cache", + "GENERATE_ID::HTTP_REQUEST_19": "route:GET /cache/{value}", + "GENERATE_ID::HTTP_REQUEST_2": "route:GET /anything", + "GENERATE_ID::HTTP_REQUEST_20": "route:GET /cookies", + "GENERATE_ID::HTTP_REQUEST_21": "route:GET /cookies/delete", + "GENERATE_ID::HTTP_REQUEST_22": "route:GET /cookies/set", + "GENERATE_ID::HTTP_REQUEST_23": "route:GET /cookies/set/{name}/{value}", + "GENERATE_ID::HTTP_REQUEST_24": "route:GET /deflate", + "GENERATE_ID::HTTP_REQUEST_25": "route:DELETE /delay/{delay}", + "GENERATE_ID::HTTP_REQUEST_26": "route:GET /delay/{delay}", + "GENERATE_ID::HTTP_REQUEST_27": "route:PATCH /delay/{delay}", + "GENERATE_ID::HTTP_REQUEST_28": "route:POST /delay/{delay}", + "GENERATE_ID::HTTP_REQUEST_29": "route:PUT /delay/{delay}", + "GENERATE_ID::HTTP_REQUEST_3": "route:PATCH /anything", + "GENERATE_ID::HTTP_REQUEST_30": "route:TRACE /delay/{delay}", + "GENERATE_ID::HTTP_REQUEST_31": "route:DELETE /delete", + "GENERATE_ID::HTTP_REQUEST_32": "route:GET /deny", + "GENERATE_ID::HTTP_REQUEST_33": "route:GET /digest-auth/{qop}/{user}/{passwd}", + "GENERATE_ID::HTTP_REQUEST_34": "route:GET /digest-auth/{qop}/{user}/{passwd}/{algorithm}", + "GENERATE_ID::HTTP_REQUEST_35": "route:GET /digest-auth/{qop}/{user}/{passwd}/{algorithm}/{stale_after}", + "GENERATE_ID::HTTP_REQUEST_36": "route:GET /drip", + "GENERATE_ID::HTTP_REQUEST_37": "route:GET /encoding/utf8", + "GENERATE_ID::HTTP_REQUEST_38": "route:GET /etag/{etag}", + "GENERATE_ID::HTTP_REQUEST_39": "route:GET /get", + "GENERATE_ID::HTTP_REQUEST_4": "route:POST /anything", + "GENERATE_ID::HTTP_REQUEST_40": "route:GET /gzip", + "GENERATE_ID::HTTP_REQUEST_41": "route:GET /headers", + "GENERATE_ID::HTTP_REQUEST_42": "route:GET /hidden-basic-auth/{user}/{passwd}", + "GENERATE_ID::HTTP_REQUEST_43": "route:GET /html", + "GENERATE_ID::HTTP_REQUEST_44": "route:GET /image", + "GENERATE_ID::HTTP_REQUEST_45": "route:GET /image/jpeg", + "GENERATE_ID::HTTP_REQUEST_46": "route:GET /image/png", + "GENERATE_ID::HTTP_REQUEST_47": "route:GET /image/svg", + "GENERATE_ID::HTTP_REQUEST_48": "route:GET /image/webp", + "GENERATE_ID::HTTP_REQUEST_49": "route:GET /ip", + "GENERATE_ID::HTTP_REQUEST_5": "route:PUT /anything", + "GENERATE_ID::HTTP_REQUEST_50": "route:GET /json", + "GENERATE_ID::HTTP_REQUEST_51": "route:GET /links/{n}/{offset}", + "GENERATE_ID::HTTP_REQUEST_52": "route:PATCH /patch", + "GENERATE_ID::HTTP_REQUEST_53": "route:POST /post", + "GENERATE_ID::HTTP_REQUEST_54": "route:PUT /put", + "GENERATE_ID::HTTP_REQUEST_55": "route:GET /range/{numbytes}", + "GENERATE_ID::HTTP_REQUEST_56": "route:DELETE /redirect-to", + "GENERATE_ID::HTTP_REQUEST_57": "route:GET /redirect-to", + "GENERATE_ID::HTTP_REQUEST_58": "route:PATCH /redirect-to", + "GENERATE_ID::HTTP_REQUEST_59": "route:POST /redirect-to", + "GENERATE_ID::HTTP_REQUEST_6": "route:TRACE /anything", + "GENERATE_ID::HTTP_REQUEST_60": "route:PUT /redirect-to", + "GENERATE_ID::HTTP_REQUEST_61": "route:TRACE /redirect-to", + "GENERATE_ID::HTTP_REQUEST_62": "route:GET /redirect/{n}", + "GENERATE_ID::HTTP_REQUEST_63": "route:GET /relative-redirect/{n}", + "GENERATE_ID::HTTP_REQUEST_64": "route:GET /response-headers", + "GENERATE_ID::HTTP_REQUEST_65": "route:POST /response-headers", + "GENERATE_ID::HTTP_REQUEST_66": "route:GET /robots.txt", + "GENERATE_ID::HTTP_REQUEST_67": "route:DELETE /status/{codes}", + "GENERATE_ID::HTTP_REQUEST_68": "route:GET /status/{codes}", + "GENERATE_ID::HTTP_REQUEST_69": "route:PATCH /status/{codes}", + "GENERATE_ID::HTTP_REQUEST_7": "route:DELETE /anything/{anything}", + "GENERATE_ID::HTTP_REQUEST_70": "route:POST /status/{codes}", + "GENERATE_ID::HTTP_REQUEST_71": "route:PUT /status/{codes}", + "GENERATE_ID::HTTP_REQUEST_72": "route:TRACE /status/{codes}", + "GENERATE_ID::HTTP_REQUEST_73": "route:GET /stream-bytes/{n}", + "GENERATE_ID::HTTP_REQUEST_74": "route:GET /stream/{n}", + "GENERATE_ID::HTTP_REQUEST_75": "route:GET /user-agent", + "GENERATE_ID::HTTP_REQUEST_76": "route:GET /uuid", + "GENERATE_ID::HTTP_REQUEST_77": "route:GET /xml", + "GENERATE_ID::HTTP_REQUEST_8": "route:GET /anything/{anything}", + "GENERATE_ID::HTTP_REQUEST_9": "route:PATCH /anything/{anything}", + }, } `; @@ -2734,6 +2835,10 @@ License: Apache 2.0 (http://www.apache.org/licenses/LICENSE-2.0.html)", }, ], }, + "sourceKeys": { + "GENERATE_ID::FOLDER_0": "tag:request tag", + "GENERATE_ID::HTTP_REQUEST_0": "route:GET /apod", + }, } `; @@ -2834,5 +2939,9 @@ Responses: }, ], }, + "sourceKeys": { + "GENERATE_ID::HTTP_REQUEST_0": "route:GET /info.0.json", + "GENERATE_ID::HTTP_REQUEST_1": "route:GET /{comicId}/info.0.json", + }, } `; diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index 9416372e..483771d4 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -2382,4 +2382,42 @@ describe("importer-openapi", () => { expect(imported).toMatchSnapshot(); }); } + + // A source key has to survive edits to the document and stay put when the user renames the + // request in Yaak, otherwise a re-import cannot tell an edit apart from a new request. + test("Keys operations by operationId, unchanged by a rename", async () => { + const spec = (summary: string) => + JSON.stringify({ + openapi: "3.0.0", + info: { title: "Keys", version: "1" }, + paths: { + "/pets": { + get: { operationId: "listPets", summary, tags: ["pets"], responses: {} }, + }, + }, + }); + + const before = await convertOpenApi(spec("List pets")); + const after = await convertOpenApi(spec("Fetch every pet")); + + expect(before?.sourceKeys?.[before.resources.httpRequests[0]!.id]).toBe("op:listPets"); + expect(after?.sourceKeys?.[after.resources.httpRequests[0]!.id]).toBe("op:listPets"); + expect(before?.sourceKeys?.[before.resources.folders[0]!.id]).toBe("tag:pets"); + }); + + // operationId is optional in OpenAPI, and the route is the only other part of the document + // that still points at the same endpoint. + test("Falls back to the route when an operation has no operationId", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.0", + info: { title: "Keys", version: "1" }, + paths: { "/pets/{id}": { delete: { summary: "Remove", responses: {} } } }, + }), + ); + + expect(imported?.sourceKeys?.[imported.resources.httpRequests[0]!.id]).toBe( + "route:DELETE /pets/{id}", + ); + }); }); diff --git a/plugins/importer-postman/src/index.ts b/plugins/importer-postman/src/index.ts index c0cc5bb7..7e617e43 100644 --- a/plugins/importer-postman/src/index.ts +++ b/plugins/importer-postman/src/index.ts @@ -49,6 +49,12 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin const globalAuth = importAuth(root.auth); + const sourceKeys: Record = {}; + const trackSourceKey = (modelId: string, v: Record, prefix: string) => { + const id = v.id ?? v._postman_id; + if (typeof id === "string" && id !== "") sourceKeys[modelId] = `${prefix}:${id}`; + }; + const exportResources: ExportResources = { workspaces: [], environments: [], @@ -63,6 +69,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin description: importDescription(info.description), ...globalAuth, }; + trackSourceKey(workspace.id, info, "collection"); exportResources.workspaces.push(workspace); // Create the base environment @@ -92,6 +99,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin name: v.name, folderId, }; + trackSourceKey(folder.id, v, "item"); exportResources.folders.push(folder); for (const child of v.item) { importItem(child, folder.id); @@ -142,6 +150,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin headers, ...requestAuth, }; + trackSourceKey(request.id, v, "item"); exportResources.httpRequests.push(request); } else { console.log("Unknown item", v, folderId); @@ -156,7 +165,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin convertTemplateSyntax(exportResources), ) as PartialImportResources; - return { resources }; + return { resources, sourceKeys }; } function convertUrl(rawUrl: unknown): Pick { diff --git a/plugins/importer-postman/tests/fixtures/auth.output.json b/plugins/importer-postman/tests/fixtures/auth.output.json index cfb7bcfa..e486060b 100644 --- a/plugins/importer-postman/tests/fixtures/auth.output.json +++ b/plugins/importer-postman/tests/fixtures/auth.output.json @@ -300,5 +300,8 @@ } ], "folders": [] + }, + "sourceKeys": { + "GENERATE_ID::WORKSPACE_0": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d" } } diff --git a/plugins/importer-postman/tests/fixtures/nested.output.json b/plugins/importer-postman/tests/fixtures/nested.output.json index 4c444711..c13974a5 100644 --- a/plugins/importer-postman/tests/fixtures/nested.output.json +++ b/plugins/importer-postman/tests/fixtures/nested.output.json @@ -88,5 +88,8 @@ "folderId": "GENERATE_ID::FOLDER_0" } ] + }, + "sourceKeys": { + "GENERATE_ID::WORKSPACE_1": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d" } } diff --git a/plugins/importer-postman/tests/fixtures/params.output.json b/plugins/importer-postman/tests/fixtures/params.output.json index 417d20a3..04243fce 100644 --- a/plugins/importer-postman/tests/fixtures/params.output.json +++ b/plugins/importer-postman/tests/fixtures/params.output.json @@ -100,5 +100,8 @@ } ], "folders": [] + }, + "sourceKeys": { + "GENERATE_ID::WORKSPACE_2": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d" } } diff --git a/plugins/importer-postman/tests/index.test.ts b/plugins/importer-postman/tests/index.test.ts index 2fea86c2..8f47ab60 100644 --- a/plugins/importer-postman/tests/index.test.ts +++ b/plugins/importer-postman/tests/index.test.ts @@ -87,4 +87,59 @@ describe("importer-postman", () => { }), ]); }); + + // A source key has to survive edits to the document and stay put when the user renames the + // request in Yaak, otherwise a re-import cannot tell an edit apart from a new request. + test("Keys items by their Postman ID, unchanged by a rename", () => { + const collection = (requestName: string) => + JSON.stringify({ + info: { + _postman_id: "collection-id", + name: "Keys", + schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + }, + item: [ + { + id: "folder-id", + name: "Folder", + item: [ + { + id: "request-id", + name: requestName, + request: { method: "GET", url: "https://yaak.app" }, + }, + ], + }, + ], + }); + + const before = convertPostman(collection("Original")); + const after = convertPostman(collection("Renamed")); + + const keyOf = (result: ReturnType, id: string | undefined) => + id == null ? undefined : result?.sourceKeys?.[id]; + + expect(keyOf(before, before?.resources.httpRequests[0]?.id)).toBe("item:request-id"); + expect(keyOf(after, after?.resources.httpRequests[0]?.id)).toBe("item:request-id"); + expect(keyOf(before, before?.resources.folders[0]?.id)).toBe("item:folder-id"); + expect(keyOf(before, before?.resources.workspaces[0]?.id)).toBe("collection:collection-id"); + }); + + // Plenty of collections in the wild predate Postman writing item IDs, so the map just omits + // them and the host derives a key instead. + test("Omits keys for items the collection never identified", () => { + const result = convertPostman( + JSON.stringify({ + info: { + name: "No IDs", + schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + }, + item: [{ name: "Request", request: { method: "GET", url: "https://yaak.app" } }], + }), + ); + + const requestId = result?.resources.httpRequests[0]?.id; + expect(requestId).toBeDefined(); + expect(result?.sourceKeys).not.toHaveProperty(requestId as string); + }); }); diff --git a/plugins/importer-yaak/src/index.ts b/plugins/importer-yaak/src/index.ts index 48ca1ad4..361fc4f7 100644 --- a/plugins/importer-yaak/src/index.ts +++ b/plugins/importer-yaak/src/index.ts @@ -80,7 +80,16 @@ export function migrateImport(contents: string) { } } - return { resources: parsed.resources }; + // Yaak's own model IDs are already stable identities for the exported document. + const sourceKeys: Record = {}; + for (const models of Object.values(parsed.resources)) { + if (!Array.isArray(models)) continue; + for (const model of models) { + if (typeof model?.id === "string") sourceKeys[model.id] = model.id; + } + } + + return { resources: parsed.resources, sourceKeys }; } function isJSObject(obj: unknown) { diff --git a/plugins/importer-yaak/tests/index.test.ts b/plugins/importer-yaak/tests/index.test.ts index 9b928f64..32662f9b 100644 --- a/plugins/importer-yaak/tests/index.test.ts +++ b/plugins/importer-yaak/tests/index.test.ts @@ -148,4 +148,34 @@ describe("importer-yaak", () => { }), ); }); + + // A source key has to survive edits to the document and stay put when the user renames the + // request in Yaak, otherwise a re-import cannot tell an edit apart from a new request. + test("Keys models by their Yaak ID, unchanged by a rename", () => { + const exported = (requestName: string) => + JSON.stringify({ + yaakSchema: 5, + resources: { + workspaces: [{ id: "wk_1", model: "workspace", name: "Keys" }], + httpRequests: [ + { + id: "rq_1", + model: "http_request", + workspaceId: "wk_1", + name: requestName, + url: "https://yaak.app", + }, + ], + }, + }); + + expect(migrateImport(exported("Original"))?.sourceKeys).toEqual({ + wk_1: "wk_1", + rq_1: "rq_1", + }); + expect(migrateImport(exported("Renamed"))?.sourceKeys).toEqual({ + wk_1: "wk_1", + rq_1: "rq_1", + }); + }); });