mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-09 19:31:57 +02:00
feat(import): add stable per-resource source keys to the importer contract (#614)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e63a87718c
commit
81a2a5d955
@@ -63,7 +63,13 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
|
|||||||
Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
|
Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
|
||||||
None => ImportDestination::NewWorkspace,
|
None => ImportDestination::NewWorkspace,
|
||||||
};
|
};
|
||||||
let plan = import::plan_import_resources(ctx.query_manager(), importer, destination, resources)
|
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}"))?;
|
.map_err(|e| format!("Failed to plan import: {e}"))?;
|
||||||
let imported = import::commit_import_plan(ctx.query_manager(), plan)
|
let imported = import::commit_import_plan(ctx.query_manager(), plan)
|
||||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||||
|
|||||||
+5
-1
@@ -11,6 +11,10 @@ export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Ar
|
|||||||
*/
|
*/
|
||||||
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
|
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.
|
||||||
|
*/
|
||||||
|
sourceKeys: { [key in string]?: string }, };
|
||||||
|
|
||||||
export type ImportPlanWarning = { title: string, detail: string, };
|
export type ImportPlanWarning = { title: string, detail: string, };
|
||||||
|
|||||||
Generated
+5
-1
@@ -11,6 +11,10 @@ export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Ar
|
|||||||
*/
|
*/
|
||||||
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
|
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.
|
||||||
|
*/
|
||||||
|
sourceKeys: { [key in string]?: string }, };
|
||||||
|
|
||||||
export type ImportPlanWarning = { title: string, detail: string, };
|
export type ImportPlanWarning = { title: string, detail: string, };
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ pub struct ImportPlan {
|
|||||||
pub destination: ImportDestination,
|
pub destination: ImportDestination,
|
||||||
pub resources: BatchUpsertResult,
|
pub resources: BatchUpsertResult,
|
||||||
pub warnings: Vec<ImportPlanWarning>,
|
pub warnings: Vec<ImportPlanWarning>,
|
||||||
|
|
||||||
|
/// Stable source key for every model in `resources`, keyed by its freshly minted ID.
|
||||||
|
pub source_keys: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_workspace_export_resources(
|
pub fn get_workspace_export_resources(
|
||||||
|
|||||||
+12
-1
@@ -474,7 +474,18 @@ 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 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,
|
||||||
|
/**
|
||||||
|
* 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, };
|
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{
|
||||||
AnyModel, Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest,
|
AnyModel, Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest,
|
||||||
@@ -250,6 +250,13 @@ pub struct ImportResponse {
|
|||||||
/// Display name of the importer that recognized the input.
|
/// Display name of the importer that recognized the input.
|
||||||
pub importer: String,
|
pub importer: String,
|
||||||
pub resources: ImportResources,
|
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<BTreeMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||||
|
|||||||
+313
-5
@@ -31,6 +31,7 @@ pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result<Import
|
|||||||
import_result.importer,
|
import_result.importer,
|
||||||
params.destination,
|
params.destination,
|
||||||
import_result.resources,
|
import_result.resources,
|
||||||
|
import_result.source_keys,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +44,14 @@ pub fn plan_import_resources(
|
|||||||
importer: String,
|
importer: String,
|
||||||
destination: ImportDestination,
|
destination: ImportDestination,
|
||||||
resources: ImportResources,
|
resources: ImportResources,
|
||||||
|
source_keys: Option<BTreeMap<String, String>>,
|
||||||
) -> Result<ImportPlan> {
|
) -> Result<ImportPlan> {
|
||||||
let mut warnings = Vec::new();
|
let mut warnings = Vec::new();
|
||||||
validate_destination(query_manager, &destination)?;
|
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::<BTreeSet<_>>();
|
let source_folder_ids = resources.folders.iter().map(|v| v.id.clone()).collect::<BTreeSet<_>>();
|
||||||
let mut folder_ids = BTreeMap::new();
|
let mut folder_ids = BTreeMap::new();
|
||||||
for folder in &resources.folders {
|
for folder in &resources.folders {
|
||||||
@@ -248,17 +253,20 @@ pub fn plan_import_resources(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ImportPlan {
|
let resources = BatchUpsertResult {
|
||||||
importer,
|
|
||||||
destination,
|
|
||||||
resources: BatchUpsertResult {
|
|
||||||
workspaces,
|
workspaces,
|
||||||
environments,
|
environments,
|
||||||
folders,
|
folders,
|
||||||
http_requests,
|
http_requests,
|
||||||
grpc_requests,
|
grpc_requests,
|
||||||
websocket_requests,
|
websocket_requests,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
Ok(ImportPlan {
|
||||||
|
importer,
|
||||||
|
destination,
|
||||||
|
source_keys: assign_source_keys(&resources, &source_ids, &plugin_keys),
|
||||||
|
resources,
|
||||||
warnings,
|
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<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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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<String, (String, Option<String>)>,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut path = Vec::new();
|
||||||
|
let mut seen = BTreeSet::new();
|
||||||
|
let mut next = folder_id.map(str::to_string);
|
||||||
|
while let Some(id) = next {
|
||||||
|
if !seen.insert(id.clone()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Some((name, parent_id)) = folders.get(&id) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
path.push(name.clone());
|
||||||
|
next = parent_id.clone();
|
||||||
|
}
|
||||||
|
path.reverse();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Known limitation: this changes when the source document renames or moves the resource, so a
|
||||||
|
/// re-import sees a rename as a delete plus an add.
|
||||||
|
fn fallback_key(model: &str, ancestry: &[String], identity: &str) -> String {
|
||||||
|
const RECORD: char = '\u{1e}';
|
||||||
|
let ancestry = ancestry.join(RECORD.to_string().as_str());
|
||||||
|
format!(
|
||||||
|
"fb:{:x}",
|
||||||
|
md5::compute(format!("{model}{IDENTITY_SEP}{ancestry}{IDENTITY_SEP}{identity}"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -584,6 +752,7 @@ mod tests {
|
|||||||
folder_id: Some(selected_folder.id.clone()),
|
folder_id: Some(selected_folder.id.clone()),
|
||||||
},
|
},
|
||||||
imported_resources(),
|
imported_resources(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("plan import");
|
.expect("plan import");
|
||||||
|
|
||||||
@@ -685,6 +854,7 @@ mod tests {
|
|||||||
"Yaak".to_string(),
|
"Yaak".to_string(),
|
||||||
ImportDestination::NewWorkspace,
|
ImportDestination::NewWorkspace,
|
||||||
resources,
|
resources,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("plan import");
|
.expect("plan import");
|
||||||
|
|
||||||
@@ -750,6 +920,7 @@ mod tests {
|
|||||||
folder_id: None,
|
folder_id: None,
|
||||||
},
|
},
|
||||||
resources,
|
resources,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("plan import");
|
.expect("plan import");
|
||||||
|
|
||||||
@@ -778,6 +949,7 @@ mod tests {
|
|||||||
"OpenAPI".to_string(),
|
"OpenAPI".to_string(),
|
||||||
ImportDestination::NewWorkspace,
|
ImportDestination::NewWorkspace,
|
||||||
imported_resources(),
|
imported_resources(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("plan import");
|
.expect("plan import");
|
||||||
let workspace_id = plan.resources.workspaces[0].id.clone();
|
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_workspace(&workspace_id).is_err(), "workspace insert must roll back");
|
||||||
assert!(db.get_environment(&environment_id).is_err(), "environment must not exist");
|
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<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);
|
||||||
|
|
||||||
|
let mut edited = imported_resources();
|
||||||
|
for (i, request) in edited.http_requests.iter_mut().enumerate() {
|
||||||
|
request.id = format!("reparsed_{i}");
|
||||||
|
request.url = format!("{}?added=1", request.url);
|
||||||
|
}
|
||||||
|
edited.folders[0].id = "reparsed_folder".to_string();
|
||||||
|
edited.http_requests[1].folder_id = Some("reparsed_folder".to_string());
|
||||||
|
let second = plan_with_keys(edited, None);
|
||||||
|
|
||||||
|
assert_eq!(request_key(&first, "Root Request"), request_key(&second, "Root Request"));
|
||||||
|
assert_eq!(request_key(&first, "Nested Request"), request_key(&second, "Nested Request"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derived_keys_distinguish_same_named_requests_by_folder() {
|
||||||
|
let mut resources = imported_resources();
|
||||||
|
resources.http_requests[1].name = resources.http_requests[0].name.clone();
|
||||||
|
let plan = plan_with_keys(resources, None);
|
||||||
|
|
||||||
|
let keys = plan.source_keys.values().collect::<BTreeSet<_>>();
|
||||||
|
assert_eq!(keys.len(), plan.source_keys.len(), "keys collided: {:?}", plan.source_keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_keys_are_broken_apart_so_none_are_lost() {
|
||||||
|
let mut resources = imported_resources();
|
||||||
|
resources.http_requests[1].folder_id = None;
|
||||||
|
resources.http_requests[1].name = resources.http_requests[0].name.clone();
|
||||||
|
resources.http_requests[1].url = resources.http_requests[0].url.clone();
|
||||||
|
let plan = plan_with_keys(resources, None);
|
||||||
|
|
||||||
|
assert_eq!(plan.source_keys.len(), 5);
|
||||||
|
let keys = plan.source_keys.values().collect::<BTreeSet<_>>();
|
||||||
|
assert_eq!(keys.len(), 5, "keys collided: {:?}", plan.source_keys);
|
||||||
|
assert!(
|
||||||
|
plan.source_keys.values().any(|key| key.ends_with("~2")),
|
||||||
|
"the repeat should be suffixed: {:?}",
|
||||||
|
plan.source_keys,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_named_siblings_keep_their_keys_when_the_document_reorders() {
|
||||||
|
let build = |swap: bool| {
|
||||||
|
let mut resources = imported_resources();
|
||||||
|
resources.http_requests[0].name = "Get".to_string();
|
||||||
|
resources.http_requests[1].name = "Get".to_string();
|
||||||
|
resources.http_requests[0].folder_id = Some("fl_source".to_string());
|
||||||
|
resources.http_requests[0].url = "https://example.com/a".to_string();
|
||||||
|
resources.http_requests[1].url = "https://example.com/b".to_string();
|
||||||
|
if swap {
|
||||||
|
resources.http_requests.swap(0, 1);
|
||||||
|
}
|
||||||
|
let plan = plan_with_keys(resources, None);
|
||||||
|
plan.resources
|
||||||
|
.http_requests
|
||||||
|
.iter()
|
||||||
|
.map(|r| (r.url.clone(), plan.source_keys[&r.id].clone()))
|
||||||
|
.collect::<BTreeMap<_, _>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listing the pair the other way round must not hand each other's key over, or a later
|
||||||
|
// re-import would credit one request's edits to the other.
|
||||||
|
assert_eq!(build(false), build(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derived_keys_are_prefixed_so_importer_keys_stay_distinguishable() {
|
||||||
|
let source_keys = BTreeMap::from([("rq_root".to_string(), "op:root".to_string())]);
|
||||||
|
let plan = plan_with_keys(imported_resources(), Some(source_keys));
|
||||||
|
|
||||||
|
assert!(!request_key(&plan, "Root Request").starts_with("fb:"));
|
||||||
|
assert!(request_key(&plan, "Nested Request").starts_with("fb:"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -474,7 +474,18 @@ 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 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,
|
||||||
|
/**
|
||||||
|
* 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, };
|
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
|
||||||
|
|
||||||
|
|||||||
@@ -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 { AtLeast, MaybePromise } from "../helpers";
|
||||||
import type { Context } from "./Context";
|
import type { Context } from "./Context";
|
||||||
|
|
||||||
@@ -14,9 +14,12 @@ export type PartialImportResources = {
|
|||||||
websocketRequests: Array<AtLeast<ImportResources["websocketRequests"][0], CommonFields>>;
|
websocketRequests: Array<AtLeast<ImportResources["websocketRequests"][0], CommonFields>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ImportPluginResponse = null | {
|
/** `importer` is omitted because the host fills it in from the plugin's own name. */
|
||||||
|
export type ImportPluginResponse =
|
||||||
|
| null
|
||||||
|
| (Omit<ImportResponse, "importer" | "resources"> & {
|
||||||
resources: PartialImportResources;
|
resources: PartialImportResources;
|
||||||
};
|
});
|
||||||
|
|
||||||
export type ImporterPlugin = {
|
export type ImporterPlugin = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ export class PluginInstance {
|
|||||||
type: "import_response",
|
type: "import_response",
|
||||||
importer: this.#mod.importer.name,
|
importer: this.#mod.importer.name,
|
||||||
resources: reply.resources as ImportResources,
|
resources: reply.resources as ImportResources,
|
||||||
|
sourceKeys: reply.sourceKeys ?? null,
|
||||||
};
|
};
|
||||||
this.#sendPayload(context, replyPayload, replyId);
|
this.#sendPayload(context, replyPayload, replyId);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -959,6 +959,10 @@ describe("importer-curl", () => {
|
|||||||
{ enabled: true, name: "q", value: "a=b" },
|
{ enabled: true, name: "q", value: "a=b" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Emits no source keys", () => {
|
||||||
|
expect(convertCurl("curl https://yaak.app")).not.toHaveProperty("sourceKeys");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const idCount: Partial<Record<string, number>> = {};
|
const idCount: Partial<Record<string, number>> = {};
|
||||||
|
|||||||
@@ -15,6 +15,21 @@ export function convertId(id: string): string {
|
|||||||
return `GENERATE_ID::${id}`;
|
return `GENERATE_ID::${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createSourceKeys() {
|
||||||
|
const keys: Record<string, string> = {};
|
||||||
|
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<string, string> => keys,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SourceKeys = ReturnType<typeof createSourceKeys>;
|
||||||
|
|
||||||
export function importHttpBodyAndHeaders(obj: any) {
|
export function importHttpBodyAndHeaders(obj: any) {
|
||||||
const { headers } = importHeaders(obj);
|
const { headers } = importHeaders(obj);
|
||||||
const { body, bodyType } = importHttpBody(obj.body);
|
const { body, bodyType } = importHttpBody(obj.body);
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
/* oxlint-disable no-explicit-any */
|
/* oxlint-disable no-explicit-any */
|
||||||
import type { PartialImportResources } from "@yaakapp/api";
|
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) {
|
export function convertInsomniaV4(parsed: any) {
|
||||||
if (!Array.isArray(parsed.resources)) return null;
|
if (!Array.isArray(parsed.resources)) return null;
|
||||||
|
|
||||||
|
const keys = createSourceKeys();
|
||||||
const resources: PartialImportResources = {
|
const resources: PartialImportResources = {
|
||||||
environments: [],
|
environments: [],
|
||||||
folders: [],
|
folders: [],
|
||||||
@@ -20,7 +28,7 @@ export function convertInsomniaV4(parsed: any) {
|
|||||||
);
|
);
|
||||||
for (const w of workspacesToImport) {
|
for (const w of workspacesToImport) {
|
||||||
resources.workspaces.push({
|
resources.workspaces.push({
|
||||||
id: convertId(w._id),
|
id: keys.own(w._id),
|
||||||
createdAt: w.created ? new Date(w.created).toISOString().replace("Z", "") : undefined,
|
createdAt: w.created ? new Date(w.created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: w.updated ? new Date(w.updated).toISOString().replace("Z", "") : undefined,
|
updatedAt: w.updated ? new Date(w.updated).toISOString().replace("Z", "") : undefined,
|
||||||
model: "workspace",
|
model: "workspace",
|
||||||
@@ -31,7 +39,7 @@ export function convertInsomniaV4(parsed: any) {
|
|||||||
(r: any) => isJSObject(r) && r._type === "environment",
|
(r: any) => isJSObject(r) && r._type === "environment",
|
||||||
);
|
);
|
||||||
resources.environments.push(
|
resources.environments.push(
|
||||||
...environmentsToImport.map((r: any) => importEnvironment(r, w._id)),
|
...environmentsToImport.map((r: any) => importEnvironment(r, w._id, keys)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const nextFolder = (parentId: string) => {
|
const nextFolder = (parentId: string) => {
|
||||||
@@ -40,12 +48,12 @@ export function convertInsomniaV4(parsed: any) {
|
|||||||
if (!isJSObject(child)) continue;
|
if (!isJSObject(child)) continue;
|
||||||
|
|
||||||
if (child._type === "request_group") {
|
if (child._type === "request_group") {
|
||||||
resources.folders.push(importFolder(child, w._id));
|
resources.folders.push(importFolder(child, w._id, keys));
|
||||||
nextFolder(child._id);
|
nextFolder(child._id);
|
||||||
} else if (child._type === "request") {
|
} 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") {
|
} 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.environments = resources.environments.filter(Boolean);
|
||||||
resources.workspaces = resources.workspaces.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 authenticationType: string | null = null;
|
||||||
let authentication = {};
|
let authentication = {};
|
||||||
if (r.authentication.type === "bearer") {
|
if (r.authentication.type === "bearer") {
|
||||||
@@ -80,7 +92,7 @@ function importHttpRequest(r: any, workspaceId: string): PartialImportResources[
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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,
|
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined,
|
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined,
|
||||||
workspaceId: convertId(workspaceId),
|
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 parts = r.protoMethodName.split("/").filter((p: any) => p !== "");
|
||||||
const service = parts[0] ?? null;
|
const service = parts[0] ?? null;
|
||||||
const method = parts[1] ?? null;
|
const method = parts[1] ?? null;
|
||||||
|
|
||||||
return {
|
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,
|
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined,
|
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined,
|
||||||
workspaceId: convertId(workspaceId),
|
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 {
|
return {
|
||||||
id: convertId(f._id),
|
id: keys.own(f._id),
|
||||||
createdAt: f.created ? new Date(f.created).toISOString().replace("Z", "") : undefined,
|
createdAt: f.created ? new Date(f.created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: f.modified ? new Date(f.modified).toISOString().replace("Z", "") : undefined,
|
updatedAt: f.modified ? new Date(f.modified).toISOString().replace("Z", "") : undefined,
|
||||||
folderId: f.parentId === workspaceId ? null : convertId(f.parentId),
|
folderId: f.parentId === workspaceId ? null : convertId(f.parentId),
|
||||||
@@ -147,11 +167,12 @@ function importFolder(f: any, workspaceId: string): PartialImportResources["fold
|
|||||||
function importEnvironment(
|
function importEnvironment(
|
||||||
e: any,
|
e: any,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
keys: SourceKeys,
|
||||||
isParentOg?: boolean,
|
isParentOg?: boolean,
|
||||||
): PartialImportResources["environments"][0] {
|
): PartialImportResources["environments"][0] {
|
||||||
const isParent = isParentOg ?? e.parentId === workspaceId;
|
const isParent = isParentOg ?? e.parentId === workspaceId;
|
||||||
return {
|
return {
|
||||||
id: convertId(e._id),
|
id: keys.own(e._id),
|
||||||
createdAt: e.created ? new Date(e.created).toISOString().replace("Z", "") : undefined,
|
createdAt: e.created ? new Date(e.created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: e.modified ? new Date(e.modified).toISOString().replace("Z", "") : undefined,
|
updatedAt: e.modified ? new Date(e.modified).toISOString().replace("Z", "") : undefined,
|
||||||
workspaceId: convertId(workspaceId),
|
workspaceId: convertId(workspaceId),
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import type { PartialImportResources } from "@yaakapp/api";
|
|||||||
import {
|
import {
|
||||||
convertId,
|
convertId,
|
||||||
convertTemplateSyntax,
|
convertTemplateSyntax,
|
||||||
|
createSourceKeys,
|
||||||
importHeaders,
|
importHeaders,
|
||||||
importHttpBodyAndHeaders,
|
importHttpBodyAndHeaders,
|
||||||
isJSObject,
|
isJSObject,
|
||||||
|
type SourceKeys,
|
||||||
} from "./common";
|
} from "./common";
|
||||||
|
|
||||||
export function convertInsomniaV5(parsed: any) {
|
export function convertInsomniaV5(parsed: any) {
|
||||||
@@ -18,6 +20,7 @@ export function convertInsomniaV5(parsed: any) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const keys = createSourceKeys();
|
||||||
const resources: PartialImportResources = {
|
const resources: PartialImportResources = {
|
||||||
environments: [],
|
environments: [],
|
||||||
folders: [],
|
folders: [],
|
||||||
@@ -30,7 +33,7 @@ export function convertInsomniaV5(parsed: any) {
|
|||||||
// Import workspaces
|
// Import workspaces
|
||||||
const meta = ("meta" in parsed ? parsed.meta : {}) as Record<string, any>;
|
const meta = ("meta" in parsed ? parsed.meta : {}) as Record<string, any>;
|
||||||
resources.workspaces.push({
|
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,
|
createdAt: meta.created ? new Date(meta.created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: meta.modified ? new Date(meta.modified).toISOString().replace("Z", "") : undefined,
|
updatedAt: meta.modified ? new Date(meta.modified).toISOString().replace("Z", "") : undefined,
|
||||||
model: "workspace",
|
model: "workspace",
|
||||||
@@ -42,8 +45,10 @@ export function convertInsomniaV5(parsed: any) {
|
|||||||
|
|
||||||
// Import environments
|
// Import environments
|
||||||
resources.environments.push(
|
resources.environments.push(
|
||||||
importEnvironment(parsed.environments, meta.id, true),
|
importEnvironment(parsed.environments, meta.id, keys, true),
|
||||||
...(parsed.environments.subEnvironments ?? []).map((r: any) => importEnvironment(r, meta.id)),
|
...(parsed.environments.subEnvironments ?? []).map((r: any) =>
|
||||||
|
importEnvironment(r, meta.id, keys),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Import folders
|
// Import folders
|
||||||
@@ -52,16 +57,16 @@ export function convertInsomniaV5(parsed: any) {
|
|||||||
if (!isJSObject(child)) continue;
|
if (!isJSObject(child)) continue;
|
||||||
|
|
||||||
if (Array.isArray(child.children)) {
|
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);
|
resources.folders.push(folder);
|
||||||
if (environment) resources.environments.push(environment);
|
if (environment) resources.environments.push(environment);
|
||||||
nextFolder(child.children, child.meta.id);
|
nextFolder(child.children, child.meta.id);
|
||||||
} else if (child.method) {
|
} 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) {
|
} 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) {
|
} 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.environments = resources.environments.filter(Boolean);
|
||||||
resources.workspaces = resources.workspaces.filter(Boolean);
|
resources.workspaces = resources.workspaces.filter(Boolean);
|
||||||
|
|
||||||
return { resources: convertTemplateSyntax(resources) };
|
return { resources: convertTemplateSyntax(resources), sourceKeys: keys.all() };
|
||||||
}
|
}
|
||||||
|
|
||||||
function importHttpRequest(
|
function importHttpRequest(
|
||||||
r: any,
|
r: any,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
parentId: string,
|
parentId: string,
|
||||||
|
keys: SourceKeys,
|
||||||
): PartialImportResources["httpRequests"][0] {
|
): PartialImportResources["httpRequests"][0] {
|
||||||
const id = r.meta?.id ?? r._id;
|
const id = r.meta?.id ?? r._id;
|
||||||
const created = r.meta?.created ?? r.created;
|
const created = r.meta?.created ?? r.created;
|
||||||
@@ -89,7 +95,7 @@ function importHttpRequest(
|
|||||||
const sortKey = r.meta?.sortKey ?? r.sortKey;
|
const sortKey = r.meta?.sortKey ?? r.sortKey;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: convertId(id),
|
id: keys.own(id),
|
||||||
workspaceId: convertId(workspaceId),
|
workspaceId: convertId(workspaceId),
|
||||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||||
@@ -114,6 +120,7 @@ function importGrpcRequest(
|
|||||||
r: any,
|
r: any,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
parentId: string,
|
parentId: string,
|
||||||
|
keys: SourceKeys,
|
||||||
): PartialImportResources["grpcRequests"][0] {
|
): PartialImportResources["grpcRequests"][0] {
|
||||||
const id = r.meta?.id ?? r._id;
|
const id = r.meta?.id ?? r._id;
|
||||||
const created = r.meta?.created ?? r.created;
|
const created = r.meta?.created ?? r.created;
|
||||||
@@ -126,7 +133,7 @@ function importGrpcRequest(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
model: "grpc_request",
|
model: "grpc_request",
|
||||||
id: convertId(id),
|
id: keys.own(id),
|
||||||
workspaceId: convertId(workspaceId),
|
workspaceId: convertId(workspaceId),
|
||||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||||
@@ -152,6 +159,7 @@ function importWebsocketRequest(
|
|||||||
r: any,
|
r: any,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
parentId: string,
|
parentId: string,
|
||||||
|
keys: SourceKeys,
|
||||||
): PartialImportResources["websocketRequests"][0] {
|
): PartialImportResources["websocketRequests"][0] {
|
||||||
const id = r.meta?.id ?? r._id;
|
const id = r.meta?.id ?? r._id;
|
||||||
const created = r.meta?.created ?? r.created;
|
const created = r.meta?.created ?? r.created;
|
||||||
@@ -160,7 +168,7 @@ function importWebsocketRequest(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
model: "websocket_request",
|
model: "websocket_request",
|
||||||
id: convertId(id),
|
id: keys.own(id),
|
||||||
workspaceId: convertId(workspaceId),
|
workspaceId: convertId(workspaceId),
|
||||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||||
@@ -198,6 +206,7 @@ function importFolder(
|
|||||||
f: any,
|
f: any,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
parentId: string,
|
parentId: string,
|
||||||
|
keys: SourceKeys,
|
||||||
): {
|
): {
|
||||||
folder: PartialImportResources["folders"][0];
|
folder: PartialImportResources["folders"][0];
|
||||||
environment: PartialImportResources["environments"][0] | null;
|
environment: PartialImportResources["environments"][0] | null;
|
||||||
@@ -210,7 +219,7 @@ function importFolder(
|
|||||||
let environment: PartialImportResources["environments"][0] | null = null;
|
let environment: PartialImportResources["environments"][0] | null = null;
|
||||||
if (Object.keys(f.environment ?? {}).length > 0) {
|
if (Object.keys(f.environment ?? {}).length > 0) {
|
||||||
environment = {
|
environment = {
|
||||||
id: convertId(`${id}folder`),
|
id: keys.own(`${id}folder`),
|
||||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||||
workspaceId: convertId(workspaceId),
|
workspaceId: convertId(workspaceId),
|
||||||
@@ -230,7 +239,7 @@ function importFolder(
|
|||||||
return {
|
return {
|
||||||
folder: {
|
folder: {
|
||||||
model: "folder",
|
model: "folder",
|
||||||
id: convertId(id),
|
id: keys.own(id),
|
||||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||||
folderId: parentId === workspaceId ? null : convertId(parentId),
|
folderId: parentId === workspaceId ? null : convertId(parentId),
|
||||||
@@ -248,6 +257,7 @@ function importFolder(
|
|||||||
function importEnvironment(
|
function importEnvironment(
|
||||||
e: any,
|
e: any,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
keys: SourceKeys,
|
||||||
isParent?: boolean,
|
isParent?: boolean,
|
||||||
): PartialImportResources["environments"][0] {
|
): PartialImportResources["environments"][0] {
|
||||||
const id = e.meta?.id ?? e._id;
|
const id = e.meta?.id ?? e._id;
|
||||||
@@ -256,7 +266,7 @@ function importEnvironment(
|
|||||||
const sortKey = e.meta?.sortKey ?? e.sortKey;
|
const sortKey = e.meta?.sortKey ?? e.sortKey;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: convertId(id),
|
id: keys.own(id),
|
||||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||||
workspaceId: convertId(workspaceId),
|
workspaceId: convertId(workspaceId),
|
||||||
|
|||||||
@@ -132,5 +132,13 @@
|
|||||||
"name": "Dummy"
|
"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": []
|
"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": []
|
"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,38 @@ describe("importer-yaak", () => {
|
|||||||
expect(result).toEqual(parseJsonOrYaml(expected));
|
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 {
|
function parseJsonOrYaml(text: string): unknown {
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
|||||||
|
|
||||||
const folderIdsByTag = new Map<string, string>();
|
const folderIdsByTag = new Map<string, string>();
|
||||||
const routeLabels = new Map<string, string>();
|
const routeLabels = new Map<string, string>();
|
||||||
|
const sourceKeys: Record<string, string> = {};
|
||||||
for (const tag of toArray(spec.tags)) {
|
for (const tag of toArray(spec.tags)) {
|
||||||
const tagRecord = toRecord(tag);
|
const tagRecord = toRecord(tag);
|
||||||
const name = stringAt(tagRecord, "name");
|
const name = stringAt(tagRecord, "name");
|
||||||
@@ -126,6 +127,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
|||||||
};
|
};
|
||||||
resources.folders.push(folder);
|
resources.folders.push(folder);
|
||||||
folderIdsByTag.set(name, folder.id);
|
folderIdsByTag.set(name, folder.id);
|
||||||
|
sourceKeys[folder.id] = tagSourceKey(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [rawPath, rawPathItem] of Object.entries(toRecord(spec.paths))) {
|
for (const [rawPath, rawPathItem] of Object.entries(toRecord(spec.paths))) {
|
||||||
@@ -139,6 +141,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
|||||||
importState,
|
importState,
|
||||||
operation,
|
operation,
|
||||||
resources,
|
resources,
|
||||||
|
sourceKeys,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,6 +163,11 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
|||||||
authenticationVariables,
|
authenticationVariables,
|
||||||
});
|
});
|
||||||
routeLabels.set(request.id, `${method.toUpperCase()} ${rawPath}`);
|
routeLabels.set(request.id, `${method.toUpperCase()} ${rawPath}`);
|
||||||
|
sourceKeys[request.id] = operationSourceKey(
|
||||||
|
stringAt(operation, "operationId"),
|
||||||
|
method,
|
||||||
|
rawPath,
|
||||||
|
);
|
||||||
resources.httpRequests.push(request);
|
resources.httpRequests.push(request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,6 +249,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
|||||||
websocketRequests: [],
|
websocketRequests: [],
|
||||||
workspaces: resources.workspaces,
|
workspaces: resources.workspaces,
|
||||||
}) as PartialImportResources,
|
}) as PartialImportResources,
|
||||||
|
sourceKeys,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,12 +690,14 @@ function findOrCreateFolderId({
|
|||||||
importState,
|
importState,
|
||||||
operation,
|
operation,
|
||||||
resources,
|
resources,
|
||||||
|
sourceKeys,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
}: {
|
}: {
|
||||||
folderIdsByTag: Map<string, string>;
|
folderIdsByTag: Map<string, string>;
|
||||||
importState: ImportState;
|
importState: ImportState;
|
||||||
operation: UnknownRecord;
|
operation: UnknownRecord;
|
||||||
resources: ImportResources;
|
resources: ImportResources;
|
||||||
|
sourceKeys: Record<string, string>;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const tag = toArray(operation.tags).find((t): t is string => typeof t === "string");
|
const tag = toArray(operation.tags).find((t): t is string => typeof t === "string");
|
||||||
@@ -705,9 +716,20 @@ function findOrCreateFolderId({
|
|||||||
};
|
};
|
||||||
resources.folders.push(folder);
|
resources.folders.push(folder);
|
||||||
folderIdsByTag.set(tag, folder.id);
|
folderIdsByTag.set(tag, folder.id);
|
||||||
|
sourceKeys[folder.id] = tagSourceKey(tag);
|
||||||
return folder.id;
|
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
|
* Yaak's `:name` placeholders only substitute when they span a whole path
|
||||||
* segment and hold a single plain value. Templates elsewhere in a segment
|
* 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,38 @@ describe("importer-openapi", () => {
|
|||||||
expect(imported).toMatchSnapshot();
|
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}",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
|||||||
|
|
||||||
const globalAuth = importAuth(root.auth);
|
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 = {
|
const exportResources: ExportResources = {
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
environments: [],
|
environments: [],
|
||||||
@@ -63,6 +69,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
|||||||
description: importDescription(info.description),
|
description: importDescription(info.description),
|
||||||
...globalAuth,
|
...globalAuth,
|
||||||
};
|
};
|
||||||
|
trackSourceKey(workspace.id, info, "collection");
|
||||||
exportResources.workspaces.push(workspace);
|
exportResources.workspaces.push(workspace);
|
||||||
|
|
||||||
// Create the base environment
|
// Create the base environment
|
||||||
@@ -92,6 +99,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
|||||||
name: v.name,
|
name: v.name,
|
||||||
folderId,
|
folderId,
|
||||||
};
|
};
|
||||||
|
trackSourceKey(folder.id, v, "item");
|
||||||
exportResources.folders.push(folder);
|
exportResources.folders.push(folder);
|
||||||
for (const child of v.item) {
|
for (const child of v.item) {
|
||||||
importItem(child, folder.id);
|
importItem(child, folder.id);
|
||||||
@@ -142,6 +150,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
|||||||
headers,
|
headers,
|
||||||
...requestAuth,
|
...requestAuth,
|
||||||
};
|
};
|
||||||
|
trackSourceKey(request.id, v, "item");
|
||||||
exportResources.httpRequests.push(request);
|
exportResources.httpRequests.push(request);
|
||||||
} else {
|
} else {
|
||||||
console.log("Unknown item", v, folderId);
|
console.log("Unknown item", v, folderId);
|
||||||
@@ -156,7 +165,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
|||||||
convertTemplateSyntax(exportResources),
|
convertTemplateSyntax(exportResources),
|
||||||
) as PartialImportResources;
|
) as PartialImportResources;
|
||||||
|
|
||||||
return { resources };
|
return { resources, sourceKeys };
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertUrl(rawUrl: unknown): Pick<HttpRequest, "url" | "urlParameters"> {
|
function convertUrl(rawUrl: unknown): Pick<HttpRequest, "url" | "urlParameters"> {
|
||||||
|
|||||||
@@ -300,5 +300,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"folders": []
|
"folders": []
|
||||||
|
},
|
||||||
|
"sourceKeys": {
|
||||||
|
"GENERATE_ID::WORKSPACE_0": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,5 +88,8 @@
|
|||||||
"folderId": "GENERATE_ID::FOLDER_0"
|
"folderId": "GENERATE_ID::FOLDER_0"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"sourceKeys": {
|
||||||
|
"GENERATE_ID::WORKSPACE_1": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,5 +100,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"folders": []
|
"folders": []
|
||||||
|
},
|
||||||
|
"sourceKeys": {
|
||||||
|
"GENERATE_ID::WORKSPACE_2": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<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");
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,7 +80,15 @@ export function migrateImport(contents: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { resources: parsed.resources };
|
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) {
|
function isJSObject(obj: unknown) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user