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..68b119de 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_util.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_util.ts @@ -11,6 +11,10 @@ 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. + */ +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..68b119de 100644 --- a/crates/yaak-models/bindings/gen_util.ts +++ b/crates/yaak-models/bindings/gen_util.ts @@ -11,6 +11,10 @@ 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. + */ +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..819dd3ba 100644 --- a/crates/yaak-models/src/util.rs +++ b/crates/yaak-models/src/util.rs @@ -119,6 +119,9 @@ 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. + 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..36b77b98 100644 --- a/crates/yaak-plugins/bindings/gen_events.ts +++ b/crates/yaak-plugins/bindings/gen_events.ts @@ -474,7 +474,18 @@ 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, +/** + * Identifies the same source element across re-parses, keyed by the IDs in `resources`. + * + * Must come from the document, never from anything the user can rename in Yaak. Only set + * for formats that carry their own identifiers; the host derives the rest. + */ +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..be7eecf0 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,13 @@ pub struct ImportResponse { /// Display name of the importer that recognized the input. pub importer: String, pub resources: ImportResources, + + /// Identifies the same source element across re-parses, keyed by the IDs in `resources`. + /// + /// Must come from the document, never from anything the user can rename in Yaak. Only set + /// for formats that carry their own identifiers; the host derives the rest. + #[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..b519935b 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)?; + 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 +253,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 +463,166 @@ 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 planned collection, 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)), + } + } +} + +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 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 key = fallback_key("workspace", &[], &v.name); + candidates.push((&v.id, plugin_key(source_ids.workspaces.get(i)), key.clone(), key)); + } + for (i, v) in resources.environments.iter().enumerate() { + let ancestry = ancestry_path(&folder_tree, v.parent_id.as_deref()); + let key = fallback_key("environment", &ancestry, &v.name); + candidates.push((&v.id, plugin_key(source_ids.environments.get(i)), key.clone(), key)); + } + for (i, v) in resources.folders.iter().enumerate() { + let ancestry = ancestry_path(&folder_tree, v.folder_id.as_deref()); + let key = fallback_key("folder", &ancestry, &v.name); + candidates.push((&v.id, plugin_key(source_ids.folders.get(i)), key.clone(), key)); + } + for (i, v) in resources.http_requests.iter().enumerate() { + let ancestry = ancestry_path(&folder_tree, v.folder_id.as_deref()); + let method = if v.method.is_empty() { "GET" } else { v.method.as_str() }; + let route = format!("{method} {}", v.url); + let (by_name, by_content) = derived_pair("http_request", &ancestry, &v.name, &route); + candidates.push((&v.id, plugin_key(source_ids.http_requests.get(i)), by_name, by_content)); + } + for (i, v) in resources.grpc_requests.iter().enumerate() { + let ancestry = ancestry_path(&folder_tree, v.folder_id.as_deref()); + let service = v.service.clone().unwrap_or_default(); + let method = v.method.clone().unwrap_or_default(); + let route = format!("{} {service}/{method}", v.url); + let (by_name, by_content) = derived_pair("grpc_request", &ancestry, &v.name, &route); + candidates.push((&v.id, plugin_key(source_ids.grpc_requests.get(i)), by_name, by_content)); + } + for (i, v) in resources.websocket_requests.iter().enumerate() { + let ancestry = ancestry_path(&folder_tree, v.folder_id.as_deref()); + let (by_name, by_content) = derived_pair("websocket_request", &ancestry, &v.name, &v.url); + candidates.push(( + &v.id, + plugin_key(source_ids.websocket_requests.get(i)), + 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)>, + 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 +} + +/// 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::*; @@ -584,6 +752,7 @@ mod tests { folder_id: Some(selected_folder.id.clone()), }, imported_resources(), + None, ) .expect("plan import"); @@ -685,6 +854,7 @@ mod tests { "Yaak".to_string(), ImportDestination::NewWorkspace, resources, + None, ) .expect("plan import"); @@ -750,6 +920,7 @@ mod tests { folder_id: None, }, resources, + None, ) .expect("plan import"); @@ -778,6 +949,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 +969,140 @@ 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"); } + + 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); + + 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() { + 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 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::>() + }; + + // 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:")); + } } diff --git a/packages/plugin-runtime-types/src/bindings/gen_events.ts b/packages/plugin-runtime-types/src/bindings/gen_events.ts index d601beb6..36b77b98 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_events.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_events.ts @@ -474,7 +474,18 @@ 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, +/** + * Identifies the same source element across re-parses, keyed by the IDs in `resources`. + * + * Must come from the document, never from anything the user can rename in Yaak. Only set + * for formats that carry their own identifiers; the host derives the rest. + */ +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..6a553f24 100644 --- a/packages/plugin-runtime-types/src/plugins/ImporterPlugin.ts +++ b/packages/plugin-runtime-types/src/plugins/ImporterPlugin.ts @@ -1,4 +1,4 @@ -import type { ImportResources } from "../bindings/gen_events"; +import type { ImportResources, ImportResponse } from "../bindings/gen_events"; import type { AtLeast, MaybePromise } from "../helpers"; import type { Context } from "./Context"; @@ -14,9 +14,12 @@ export type PartialImportResources = { websocketRequests: Array>; }; -export type ImportPluginResponse = null | { - resources: PartialImportResources; -}; +/** `importer` is omitted because the host fills it in from the plugin's own name. */ +export type ImportPluginResponse = + | null + | (Omit & { + resources: PartialImportResources; + }); export type ImporterPlugin = { name: string; 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..d147b303 100644 --- a/plugins/importer-curl/tests/index.test.ts +++ b/plugins/importer-curl/tests/index.test.ts @@ -959,6 +959,10 @@ describe("importer-curl", () => { { enabled: true, name: "q", value: "a=b" }, ]); }); + + 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..4d2d2708 100644 --- a/plugins/importer-insomnia/src/common.ts +++ b/plugins/importer-insomnia/src/common.ts @@ -15,6 +15,21 @@ export function convertId(id: string): string { return `GENERATE_ID::${id}`; } +export function createSourceKeys() { + const keys: Record = {}; + return { + /** Convert a resource's own document ID, keeping it as that resource's source key. */ + own(id: string): string { + const converted = convertId(id); + keys[converted] = id; + return converted; + }, + all: (): Record => keys, + }; +} + +export type SourceKeys = ReturnType; + export function importHttpBodyAndHeaders(obj: any) { const { headers } = importHeaders(obj); const { body, bodyType } = importHttpBody(obj.body); diff --git a/plugins/importer-insomnia/src/v4.ts b/plugins/importer-insomnia/src/v4.ts index 1e6e0397..2f698fde 100644 --- a/plugins/importer-insomnia/src/v4.ts +++ b/plugins/importer-insomnia/src/v4.ts @@ -1,10 +1,18 @@ /* oxlint-disable no-explicit-any */ import type { PartialImportResources } from "@yaakapp/api"; -import { convertId, convertTemplateSyntax, importHttpBodyAndHeaders, isJSObject } from "./common"; +import { + convertId, + convertTemplateSyntax, + createSourceKeys, + importHttpBodyAndHeaders, + isJSObject, + type SourceKeys, +} from "./common"; export function convertInsomniaV4(parsed: any) { if (!Array.isArray(parsed.resources)) return null; + const keys = createSourceKeys(); const resources: PartialImportResources = { environments: [], folders: [], @@ -20,7 +28,7 @@ export function convertInsomniaV4(parsed: any) { ); for (const w of workspacesToImport) { resources.workspaces.push({ - id: convertId(w._id), + id: keys.own(w._id), createdAt: w.created ? new Date(w.created).toISOString().replace("Z", "") : undefined, updatedAt: w.updated ? new Date(w.updated).toISOString().replace("Z", "") : undefined, model: "workspace", @@ -31,7 +39,7 @@ export function convertInsomniaV4(parsed: any) { (r: any) => isJSObject(r) && r._type === "environment", ); resources.environments.push( - ...environmentsToImport.map((r: any) => importEnvironment(r, w._id)), + ...environmentsToImport.map((r: any) => importEnvironment(r, w._id, keys)), ); const nextFolder = (parentId: string) => { @@ -40,12 +48,12 @@ export function convertInsomniaV4(parsed: any) { if (!isJSObject(child)) continue; if (child._type === "request_group") { - resources.folders.push(importFolder(child, w._id)); + resources.folders.push(importFolder(child, w._id, keys)); nextFolder(child._id); } else if (child._type === "request") { - resources.httpRequests.push(importHttpRequest(child, w._id)); + resources.httpRequests.push(importHttpRequest(child, w._id, keys)); } else if (child._type === "grpc_request") { - resources.grpcRequests.push(importGrpcRequest(child, w._id)); + resources.grpcRequests.push(importGrpcRequest(child, w._id, keys)); } } }; @@ -60,10 +68,14 @@ export function convertInsomniaV4(parsed: any) { resources.environments = resources.environments.filter(Boolean); resources.workspaces = resources.workspaces.filter(Boolean); - return { resources: convertTemplateSyntax(resources) }; + return { resources: convertTemplateSyntax(resources), sourceKeys: keys.all() }; } -function importHttpRequest(r: any, workspaceId: string): PartialImportResources["httpRequests"][0] { +function importHttpRequest( + r: any, + workspaceId: string, + keys: SourceKeys, +): PartialImportResources["httpRequests"][0] { let authenticationType: string | null = null; let authentication = {}; if (r.authentication.type === "bearer") { @@ -80,7 +92,7 @@ function importHttpRequest(r: any, workspaceId: string): PartialImportResources[ } return { - id: convertId(r.meta?.id ?? r._id), + id: keys.own(r.meta?.id ?? r._id), createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : undefined, updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined, workspaceId: convertId(workspaceId), @@ -102,13 +114,17 @@ function importHttpRequest(r: any, workspaceId: string): PartialImportResources[ }; } -function importGrpcRequest(r: any, workspaceId: string): PartialImportResources["grpcRequests"][0] { +function importGrpcRequest( + r: any, + workspaceId: string, + keys: SourceKeys, +): PartialImportResources["grpcRequests"][0] { const parts = r.protoMethodName.split("/").filter((p: any) => p !== ""); const service = parts[0] ?? null; const method = parts[1] ?? null; return { - id: convertId(r.meta?.id ?? r._id), + id: keys.own(r.meta?.id ?? r._id), createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : undefined, updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined, workspaceId: convertId(workspaceId), @@ -131,9 +147,13 @@ function importGrpcRequest(r: any, workspaceId: string): PartialImportResources[ }; } -function importFolder(f: any, workspaceId: string): PartialImportResources["folders"][0] { +function importFolder( + f: any, + workspaceId: string, + keys: SourceKeys, +): PartialImportResources["folders"][0] { return { - id: convertId(f._id), + id: keys.own(f._id), createdAt: f.created ? new Date(f.created).toISOString().replace("Z", "") : undefined, updatedAt: f.modified ? new Date(f.modified).toISOString().replace("Z", "") : undefined, folderId: f.parentId === workspaceId ? null : convertId(f.parentId), @@ -147,11 +167,12 @@ function importFolder(f: any, workspaceId: string): PartialImportResources["fold function importEnvironment( e: any, workspaceId: string, + keys: SourceKeys, isParentOg?: boolean, ): PartialImportResources["environments"][0] { const isParent = isParentOg ?? e.parentId === workspaceId; return { - id: convertId(e._id), + id: keys.own(e._id), createdAt: e.created ? new Date(e.created).toISOString().replace("Z", "") : undefined, updatedAt: e.modified ? new Date(e.modified).toISOString().replace("Z", "") : undefined, workspaceId: convertId(workspaceId), diff --git a/plugins/importer-insomnia/src/v5.ts b/plugins/importer-insomnia/src/v5.ts index 0e0647b9..e47ca8e2 100644 --- a/plugins/importer-insomnia/src/v5.ts +++ b/plugins/importer-insomnia/src/v5.ts @@ -3,9 +3,11 @@ import type { PartialImportResources } from "@yaakapp/api"; import { convertId, convertTemplateSyntax, + createSourceKeys, importHeaders, importHttpBodyAndHeaders, isJSObject, + type SourceKeys, } from "./common"; export function convertInsomniaV5(parsed: any) { @@ -18,6 +20,7 @@ export function convertInsomniaV5(parsed: any) { return null; } + const keys = createSourceKeys(); const resources: PartialImportResources = { environments: [], folders: [], @@ -30,7 +33,7 @@ export function convertInsomniaV5(parsed: any) { // Import workspaces const meta = ("meta" in parsed ? parsed.meta : {}) as Record; resources.workspaces.push({ - id: convertId(meta.id ?? "collection"), + id: keys.own(meta.id ?? "collection"), createdAt: meta.created ? new Date(meta.created).toISOString().replace("Z", "") : undefined, updatedAt: meta.modified ? new Date(meta.modified).toISOString().replace("Z", "") : undefined, model: "workspace", @@ -42,8 +45,10 @@ export function convertInsomniaV5(parsed: any) { // Import environments resources.environments.push( - importEnvironment(parsed.environments, meta.id, true), - ...(parsed.environments.subEnvironments ?? []).map((r: any) => importEnvironment(r, meta.id)), + importEnvironment(parsed.environments, meta.id, keys, true), + ...(parsed.environments.subEnvironments ?? []).map((r: any) => + importEnvironment(r, meta.id, keys), + ), ); // Import folders @@ -52,16 +57,16 @@ export function convertInsomniaV5(parsed: any) { if (!isJSObject(child)) continue; if (Array.isArray(child.children)) { - const { folder, environment } = importFolder(child, meta.id, parentId); + const { folder, environment } = importFolder(child, meta.id, parentId, keys); resources.folders.push(folder); if (environment) resources.environments.push(environment); nextFolder(child.children, child.meta.id); } else if (child.method) { - resources.httpRequests.push(importHttpRequest(child, meta.id, parentId)); + resources.httpRequests.push(importHttpRequest(child, meta.id, parentId, keys)); } else if (child.protoFileId) { - resources.grpcRequests.push(importGrpcRequest(child, meta.id, parentId)); + resources.grpcRequests.push(importGrpcRequest(child, meta.id, parentId, keys)); } else if (child.url) { - resources.websocketRequests.push(importWebsocketRequest(child, meta.id, parentId)); + resources.websocketRequests.push(importWebsocketRequest(child, meta.id, parentId, keys)); } } }; @@ -75,13 +80,14 @@ export function convertInsomniaV5(parsed: any) { resources.environments = resources.environments.filter(Boolean); resources.workspaces = resources.workspaces.filter(Boolean); - return { resources: convertTemplateSyntax(resources) }; + return { resources: convertTemplateSyntax(resources), sourceKeys: keys.all() }; } function importHttpRequest( r: any, workspaceId: string, parentId: string, + keys: SourceKeys, ): PartialImportResources["httpRequests"][0] { const id = r.meta?.id ?? r._id; const created = r.meta?.created ?? r.created; @@ -89,7 +95,7 @@ function importHttpRequest( const sortKey = r.meta?.sortKey ?? r.sortKey; return { - id: convertId(id), + id: keys.own(id), workspaceId: convertId(workspaceId), createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined, updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined, @@ -114,6 +120,7 @@ function importGrpcRequest( r: any, workspaceId: string, parentId: string, + keys: SourceKeys, ): PartialImportResources["grpcRequests"][0] { const id = r.meta?.id ?? r._id; const created = r.meta?.created ?? r.created; @@ -126,7 +133,7 @@ function importGrpcRequest( return { model: "grpc_request", - id: convertId(id), + id: keys.own(id), workspaceId: convertId(workspaceId), createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined, updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined, @@ -152,6 +159,7 @@ function importWebsocketRequest( r: any, workspaceId: string, parentId: string, + keys: SourceKeys, ): PartialImportResources["websocketRequests"][0] { const id = r.meta?.id ?? r._id; const created = r.meta?.created ?? r.created; @@ -160,7 +168,7 @@ function importWebsocketRequest( return { model: "websocket_request", - id: convertId(id), + id: keys.own(id), workspaceId: convertId(workspaceId), createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined, updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined, @@ -198,6 +206,7 @@ function importFolder( f: any, workspaceId: string, parentId: string, + keys: SourceKeys, ): { folder: PartialImportResources["folders"][0]; environment: PartialImportResources["environments"][0] | null; @@ -210,7 +219,7 @@ function importFolder( let environment: PartialImportResources["environments"][0] | null = null; if (Object.keys(f.environment ?? {}).length > 0) { environment = { - id: convertId(`${id}folder`), + id: keys.own(`${id}folder`), createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined, updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined, workspaceId: convertId(workspaceId), @@ -230,7 +239,7 @@ function importFolder( return { folder: { model: "folder", - id: convertId(id), + id: keys.own(id), createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined, updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined, folderId: parentId === workspaceId ? null : convertId(parentId), @@ -248,6 +257,7 @@ function importFolder( function importEnvironment( e: any, workspaceId: string, + keys: SourceKeys, isParent?: boolean, ): PartialImportResources["environments"][0] { const id = e.meta?.id ?? e._id; @@ -256,7 +266,7 @@ function importEnvironment( const sortKey = e.meta?.sortKey ?? e.sortKey; return { - id: convertId(id), + id: keys.own(id), createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined, updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined, workspaceId: convertId(workspaceId), 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..26fc824b 100644 --- a/plugins/importer-insomnia/tests/index.test.ts +++ b/plugins/importer-insomnia/tests/index.test.ts @@ -24,6 +24,38 @@ describe("importer-yaak", () => { expect(result).toEqual(parseJsonOrYaml(expected)); }); } + + 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..302c04b7 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,20 @@ function findOrCreateFolderId({ }; resources.folders.push(folder); folderIdsByTag.set(tag, folder.id); + sourceKeys[folder.id] = tagSourceKey(tag); return folder.id; } +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..26675f4f 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -2382,4 +2382,38 @@ describe("importer-openapi", () => { expect(imported).toMatchSnapshot(); }); } + + 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"); + }); + + 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..f9d96ed3 100644 --- a/plugins/importer-postman/tests/index.test.ts +++ b/plugins/importer-postman/tests/index.test.ts @@ -87,4 +87,55 @@ describe("importer-postman", () => { }), ]); }); + + 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"); + }); + + 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..f423b9e5 100644 --- a/plugins/importer-yaak/src/index.ts +++ b/plugins/importer-yaak/src/index.ts @@ -80,7 +80,15 @@ export function migrateImport(contents: string) { } } - return { resources: parsed.resources }; + 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..a7992b33 100644 --- a/plugins/importer-yaak/tests/index.test.ts +++ b/plugins/importer-yaak/tests/index.test.ts @@ -148,4 +148,32 @@ describe("importer-yaak", () => { }), ); }); + + 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", + }); + }); });