Compare commits

...
Author SHA1 Message Date
Gregory SchierandClaude Opus 5 333e3c1653 feat(import): add stable per-resource source keys to the importer contract
Re-importing an edited document currently has no way to tell an updated
request from a new one, because every import mints fresh model IDs. Give
each imported resource a key that identifies its source element instead,
so a later import can match against what it already created.

`ImportResponse` gains an optional `sourceKeys` map, keyed by the resource
IDs plugins already emit. A parallel map keeps `ImportResources` and the
partial TS types unchanged.

Bundled importers emit keys where the format carries them: Postman item
IDs, Insomnia `_id`s, OpenAPI `operationId` (falling back to the route),
and Yaak model IDs. curl has no identity of its own, so it emits none.

Planning derives a key for anything left over, hashed from the model type,
folder ancestry, and name or method+URL, prefixed `fb:` so later code can
tell derived keys from importer-provided ones. These break when the source
document renames or moves a resource, which is accepted.

Keys land in `ImportPlan.source_keys`, keyed by minted model ID so the
mapping stays lossless, ready for the commit step to persist. Nothing
consumes them yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:14:52 -07:00
27 changed files with 742 additions and 19 deletions
@@ -63,8 +63,14 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
Some(workspace_id) => 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)
+8 -1
View File
@@ -11,6 +11,13 @@ export type BatchUpsertResult = { workspaces: Array<Workspace>, 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<ImportPlanWarning>, };
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>,
/**
* 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, };
+8 -1
View File
@@ -11,6 +11,13 @@ export type BatchUpsertResult = { workspaces: Array<Workspace>, 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<ImportPlanWarning>, };
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>,
/**
* 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, };
+6
View File
@@ -119,6 +119,12 @@ pub struct ImportPlan {
pub destination: ImportDestination,
pub resources: BatchUpsertResult,
pub warnings: Vec<ImportPlanWarning>,
/// 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<String, String>,
}
pub fn get_workspace_export_resources(
+13 -1
View File
@@ -474,7 +474,19 @@ export type ImportRequest = { content: string, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
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, };
+9 -1
View File
@@ -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<BTreeMap<String, String>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
+288 -8
View File
@@ -31,6 +31,7 @@ pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result<Import
import_result.importer,
params.destination,
import_result.resources,
import_result.source_keys,
)
}
@@ -43,10 +44,16 @@ pub fn plan_import_resources(
importer: String,
destination: ImportDestination,
resources: ImportResources,
source_keys: Option<BTreeMap<String, String>>,
) -> Result<ImportPlan> {
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::<BTreeSet<_>>();
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<String>,
environments: Vec<String>,
folders: Vec<String>,
http_requests: Vec<String>,
grpc_requests: Vec<String>,
websocket_requests: Vec<String>,
}
impl SourceIds {
fn collect(resources: &ImportResources) -> Self {
let ids = |ids: &mut dyn Iterator<Item = &String>| ids.cloned().collect::<Vec<_>>();
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<String, String>,
) -> BTreeMap<String, String> {
let folder_tree = resources
.folders
.iter()
.map(|v| (v.id.clone(), (v.name.clone(), v.folder_id.clone())))
.collect::<BTreeMap<_, _>>();
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<String, (String, Option<String>)>,
folder_id: Option<&str>,
) -> Vec<String> {
let mut path = Vec::new();
let mut seen = BTreeSet::new();
let mut next = folder_id.map(str::to_string);
while let Some(id) = next {
if !seen.insert(id.clone()) {
break;
}
let Some((name, parent_id)) = folders.get(&id) else {
break;
};
path.push(name.clone());
next = parent_id.clone();
}
path.reverse();
path
}
/// 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<BTreeMap<String, String>>,
) -> ImportPlan {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
plan_import_resources(
&query_manager,
"Yaak".to_string(),
ImportDestination::NewWorkspace,
resources,
source_keys,
)
.expect("plan import")
}
#[test]
fn every_planned_model_gets_a_source_key() {
let plan = plan_with_keys(imported_resources(), None);
let planned_ids = plan
.resources
.workspaces
.iter()
.map(|v| v.id.clone())
.chain(plan.resources.environments.iter().map(|v| v.id.clone()))
.chain(plan.resources.folders.iter().map(|v| v.id.clone()))
.chain(plan.resources.http_requests.iter().map(|v| v.id.clone()))
.collect::<BTreeSet<_>>();
assert_eq!(plan.source_keys.keys().cloned().collect::<BTreeSet<_>>(), planned_ids);
assert!(
plan.source_keys.values().all(|key| key.starts_with("fb:")),
"an importer that supplied no keys should leave every key derived: {:?}",
plan.source_keys,
);
}
#[test]
fn importer_keys_win_over_derived_ones() {
let source_keys = BTreeMap::from([("rq_nested".to_string(), "op:listPets".to_string())]);
let plan = plan_with_keys(imported_resources(), Some(source_keys));
assert_eq!(request_key(&plan, "Nested Request"), "op:listPets");
assert!(request_key(&plan, "Root Request").starts_with("fb:"));
}
#[test]
fn derived_keys_survive_a_re_parse_that_mints_new_ids() {
let first = plan_with_keys(imported_resources(), None);
// 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::<BTreeSet<_>>();
assert_eq!(keys.len(), plan.source_keys.len(), "keys collided: {:?}", plan.source_keys);
}
#[test]
fn duplicate_keys_are_broken_apart_so_none_are_lost() {
// 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::<BTreeSet<_>>();
assert_eq!(keys.len(), 5, "keys collided: {:?}", plan.source_keys);
assert!(
plan.source_keys.values().any(|key| key.ends_with("~2")),
"the repeat should be suffixed: {:?}",
plan.source_keys,
);
}
#[test]
fn 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:"));
}
}
+13 -1
View File
@@ -474,7 +474,19 @@ export type ImportRequest = { content: string, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
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, };
@@ -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<string, string>;
};
export type ImporterPlugin = {
@@ -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;
@@ -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<Record<string, number>> = {};
+18
View File
@@ -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<string, string> {
const sourceKeys: Record<string, string> = {};
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);
+6 -2
View File
@@ -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),
});
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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 {
+28
View File
@@ -110,6 +110,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
const folderIdsByTag = new Map<string, string>();
const routeLabels = new Map<string, string>();
const sourceKeys: Record<string, string> = {};
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<ImportPluginResp
};
resources.folders.push(folder);
folderIdsByTag.set(name, folder.id);
sourceKeys[folder.id] = tagSourceKey(name);
}
for (const [rawPath, rawPathItem] of Object.entries(toRecord(spec.paths))) {
@@ -139,6 +141,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
importState,
operation,
resources,
sourceKeys,
workspaceId: workspace.id,
});
@@ -160,6 +163,11 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
authenticationVariables,
});
routeLabels.set(request.id, `${method.toUpperCase()} ${rawPath}`);
sourceKeys[request.id] = operationSourceKey(
stringAt(operation, "operationId"),
method,
rawPath,
);
resources.httpRequests.push(request);
}
}
@@ -241,6 +249,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
websocketRequests: [],
workspaces: resources.workspaces,
}) as PartialImportResources,
sourceKeys,
};
}
@@ -681,12 +690,14 @@ function findOrCreateFolderId({
importState,
operation,
resources,
sourceKeys,
workspaceId,
}: {
folderIdsByTag: Map<string, string>;
importState: ImportState;
operation: UnknownRecord;
resources: ImportResources;
sourceKeys: Record<string, string>;
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
@@ -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",
},
}
`;
@@ -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}",
);
});
});
+10 -1
View File
@@ -49,6 +49,12 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
const globalAuth = importAuth(root.auth);
const sourceKeys: Record<string, string> = {};
const trackSourceKey = (modelId: string, v: Record<string, unknown>, 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<HttpRequest, "url" | "urlParameters"> {
@@ -300,5 +300,8 @@
}
],
"folders": []
},
"sourceKeys": {
"GENERATE_ID::WORKSPACE_0": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
}
}
@@ -88,5 +88,8 @@
"folderId": "GENERATE_ID::FOLDER_0"
}
]
},
"sourceKeys": {
"GENERATE_ID::WORKSPACE_1": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
}
}
@@ -100,5 +100,8 @@
}
],
"folders": []
},
"sourceKeys": {
"GENERATE_ID::WORKSPACE_2": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
}
}
@@ -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<typeof convertPostman>, 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);
});
});
+10 -1
View File
@@ -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<string, string> = {};
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) {
+30
View File
@@ -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",
});
});
});