From de5aa0a35d21420f5e53cd107f75a16540048376 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 21 Jul 2026 22:46:41 +0530 Subject: [PATCH 1/8] feat(api-client): git-friendly folder-backed collections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open a folder as a collection: requests stored as YAML files (NNN- prefix = order, dirs = folders, collection.yaml manifest), so collections can be committed, diffed and PR-reviewed. Filesystem is the store — mutations diff the serialized tree and write only changed files; window focus re-reads external edits. Secrets stay out of files ({{vault.*}} tokens pass through; environments never serialized). Desktop only: tauri fs/dialog/persisted-scope plugins plus a recursive fs-scope grant command for picked dirs. Co-Authored-By: Claude Fable 5 --- apps/desktop-ui/messages/af.json | 2 + apps/desktop-ui/messages/ar.json | 2 + apps/desktop-ui/messages/ca.json | 2 + apps/desktop-ui/messages/cs.json | 2 + apps/desktop-ui/messages/da.json | 2 + apps/desktop-ui/messages/de.json | 2 + apps/desktop-ui/messages/el.json | 2 + apps/desktop-ui/messages/en.json | 2 + apps/desktop-ui/messages/es.json | 2 + apps/desktop-ui/messages/fa.json | 2 + apps/desktop-ui/messages/fr.json | 2 + apps/desktop-ui/messages/id.json | 2 + apps/desktop-ui/messages/it.json | 2 + apps/desktop-ui/messages/ja.json | 2 + apps/desktop-ui/messages/ko.json | 2 + apps/desktop-ui/messages/ms.json | 2 + apps/desktop-ui/messages/nb.json | 2 + apps/desktop-ui/messages/nl.json | 2 + apps/desktop-ui/messages/pl.json | 2 + apps/desktop-ui/messages/pt-BR.json | 2 + apps/desktop-ui/messages/pt.json | 2 + apps/desktop-ui/messages/ru.json | 2 + apps/desktop-ui/messages/sv.json | 2 + apps/desktop-ui/messages/tr.json | 2 + apps/desktop-ui/messages/uk.json | 2 + apps/desktop-ui/messages/vi.json | 2 + apps/desktop-ui/messages/zh.json | 2 + apps/desktop-ui/package.json | 2 + .../collections/collections-sidebar.tsx | 24 +- .../api-client/collections/use-collections.ts | 276 +++++++++++- .../context/collections-context.tsx | 6 +- .../src/components/api-client/types.ts | 3 + .../api-client/__tests__/file-store.test.ts | 185 ++++++++ .../src/lib/api-client/file-store.ts | 405 ++++++++++++++++++ .../src/lib/desktop/collection-files.ts | 88 ++++ apps/desktop/src-tauri/Cargo.lock | 94 ++++ apps/desktop/src-tauri/Cargo.toml | 3 + .../src-tauri/capabilities/default.json | 17 +- apps/desktop/src-tauri/src/lib.rs | 17 + 39 files changed, 1150 insertions(+), 24 deletions(-) create mode 100644 apps/desktop-ui/src/lib/api-client/__tests__/file-store.test.ts create mode 100644 apps/desktop-ui/src/lib/api-client/file-store.ts create mode 100644 apps/desktop-ui/src/lib/desktop/collection-files.ts diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json index 4973c624..7c361478 100644 --- a/apps/desktop-ui/messages/af.json +++ b/apps/desktop-ui/messages/af.json @@ -1923,6 +1923,8 @@ "collectionsSidebar": { "title": "API-hulpbronne", "newCollection": "Nuwe versameling", + "openFolderCollection": "Maak vouer oop as versameling", + "folderCollection": "Vouerversameling", "tabCollections": "Versamelings", "tabHistory": "Geskiedenis", "noCollectionsTitle": "Geen versamelings", diff --git a/apps/desktop-ui/messages/ar.json b/apps/desktop-ui/messages/ar.json index 0d61ee09..dbd633ef 100644 --- a/apps/desktop-ui/messages/ar.json +++ b/apps/desktop-ui/messages/ar.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "موارد API", "newCollection": "مجموعة جديدة", + "openFolderCollection": "فتح مجلد كمجموعة", + "folderCollection": "مجموعة مجلد", "tabCollections": "المجموعات", "tabHistory": "السجل", "noCollectionsTitle": "لا مجموعات", diff --git a/apps/desktop-ui/messages/ca.json b/apps/desktop-ui/messages/ca.json index 8db95bdf..6b316f56 100644 --- a/apps/desktop-ui/messages/ca.json +++ b/apps/desktop-ui/messages/ca.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "Recursos API", "newCollection": "Col·lecció nova", + "openFolderCollection": "Obre una carpeta com a col·lecció", + "folderCollection": "Col·lecció de carpeta", "tabCollections": "Col·leccions", "tabHistory": "Historial", "noCollectionsTitle": "Sense col·leccions", diff --git a/apps/desktop-ui/messages/cs.json b/apps/desktop-ui/messages/cs.json index d2d63a26..ed9a80c2 100644 --- a/apps/desktop-ui/messages/cs.json +++ b/apps/desktop-ui/messages/cs.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "API zdroje", "newCollection": "Nová kolekce", + "openFolderCollection": "Otevřít složku jako kolekci", + "folderCollection": "Kolekce ze složky", "tabCollections": "Kolekce", "tabHistory": "Historie", "noCollectionsTitle": "Žádné kolekce", diff --git a/apps/desktop-ui/messages/da.json b/apps/desktop-ui/messages/da.json index d2020c77..f1b2c4d6 100644 --- a/apps/desktop-ui/messages/da.json +++ b/apps/desktop-ui/messages/da.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "API-ressourcer", "newCollection": "Ny kollektion", + "openFolderCollection": "Åbn mappe som samling", + "folderCollection": "Mappesamling", "tabCollections": "Samlinger", "tabHistory": "Historie", "noCollectionsTitle": "Ingen samlinger", diff --git a/apps/desktop-ui/messages/de.json b/apps/desktop-ui/messages/de.json index 6c29a90c..1770dd2e 100644 --- a/apps/desktop-ui/messages/de.json +++ b/apps/desktop-ui/messages/de.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "API-Ressourcen", "newCollection": "Neue Sammlung", + "openFolderCollection": "Ordner als Sammlung öffnen", + "folderCollection": "Ordner-Sammlung", "tabCollections": "Sammlungen", "tabHistory": "Verlauf", "noCollectionsTitle": "Keine Sammlungen", diff --git a/apps/desktop-ui/messages/el.json b/apps/desktop-ui/messages/el.json index 7c76d021..cad4cac6 100644 --- a/apps/desktop-ui/messages/el.json +++ b/apps/desktop-ui/messages/el.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "Πόροι API", "newCollection": "Νέα συλλογή", + "openFolderCollection": "Άνοιγμα φακέλου ως συλλογή", + "folderCollection": "Συλλογή φακέλου", "tabCollections": "Συλλογές", "tabHistory": "Ιστορία", "noCollectionsTitle": "Χωρίς συλλογές", diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index e94e917f..624c1c32 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1948,6 +1948,8 @@ "collectionsSidebar": { "title": "API resources", "newCollection": "New collection", + "openFolderCollection": "Open folder as collection", + "folderCollection": "Folder collection", "tabCollections": "Collections", "tabHistory": "History", "noCollectionsTitle": "No collections", diff --git a/apps/desktop-ui/messages/es.json b/apps/desktop-ui/messages/es.json index a93bd0c0..a07d5274 100644 --- a/apps/desktop-ui/messages/es.json +++ b/apps/desktop-ui/messages/es.json @@ -1923,6 +1923,8 @@ "collectionsSidebar": { "title": "Recursos API", "newCollection": "Nueva colección", + "openFolderCollection": "Abrir carpeta como colección", + "folderCollection": "Colección de carpeta", "tabCollections": "Colecciones", "tabHistory": "Historial", "noCollectionsTitle": "Sin colecciones", diff --git a/apps/desktop-ui/messages/fa.json b/apps/desktop-ui/messages/fa.json index 414e3619..2ec79c56 100644 --- a/apps/desktop-ui/messages/fa.json +++ b/apps/desktop-ui/messages/fa.json @@ -1923,6 +1923,8 @@ "collectionsSidebar": { "title": "منابع API", "newCollection": "مجموعه جدید", + "openFolderCollection": "باز کردن پوشه به عنوان مجموعه", + "folderCollection": "مجموعه پوشه‌ای", "tabCollections": "مجموعه ها", "tabHistory": "تاریخچه", "noCollectionsTitle": "بدون مجموعه", diff --git a/apps/desktop-ui/messages/fr.json b/apps/desktop-ui/messages/fr.json index b1035452..b0a84b80 100644 --- a/apps/desktop-ui/messages/fr.json +++ b/apps/desktop-ui/messages/fr.json @@ -1923,6 +1923,8 @@ "collectionsSidebar": { "title": "Ressources API", "newCollection": "Nouvelle collection", + "openFolderCollection": "Ouvrir un dossier comme collection", + "folderCollection": "Collection de dossier", "tabCollections": "Collections", "tabHistory": "Historique", "noCollectionsTitle": "Aucune collection", diff --git a/apps/desktop-ui/messages/id.json b/apps/desktop-ui/messages/id.json index 1f7c7675..c703e8ca 100644 --- a/apps/desktop-ui/messages/id.json +++ b/apps/desktop-ui/messages/id.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "API resources", "newCollection": "New collection", + "openFolderCollection": "Buka folder sebagai koleksi", + "folderCollection": "Koleksi folder", "tabCollections": "Collections", "tabHistory": "History", "noCollectionsTitle": "No collections", diff --git a/apps/desktop-ui/messages/it.json b/apps/desktop-ui/messages/it.json index cc6e30a0..fdbdb7ec 100644 --- a/apps/desktop-ui/messages/it.json +++ b/apps/desktop-ui/messages/it.json @@ -1923,6 +1923,8 @@ "collectionsSidebar": { "title": "Risorse API", "newCollection": "Nuova collezione", + "openFolderCollection": "Apri cartella come raccolta", + "folderCollection": "Raccolta da cartella", "tabCollections": "Collezioni", "tabHistory": "Storia", "noCollectionsTitle": "Nessuna raccolta", diff --git a/apps/desktop-ui/messages/ja.json b/apps/desktop-ui/messages/ja.json index 471da4fe..31293c68 100644 --- a/apps/desktop-ui/messages/ja.json +++ b/apps/desktop-ui/messages/ja.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "APIリソース", "newCollection": "新しいコレクション", + "openFolderCollection": "フォルダーをコレクションとして開く", + "folderCollection": "フォルダーコレクション", "tabCollections": "コレクション", "tabHistory": "履歴", "noCollectionsTitle": "コレクションなし", diff --git a/apps/desktop-ui/messages/ko.json b/apps/desktop-ui/messages/ko.json index 39581cf4..c30ff58e 100644 --- a/apps/desktop-ui/messages/ko.json +++ b/apps/desktop-ui/messages/ko.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "API 리소스", "newCollection": "새 컬렉션", + "openFolderCollection": "폴더를 컬렉션으로 열기", + "folderCollection": "폴더 컬렉션", "tabCollections": "컬렉션", "tabHistory": "기록", "noCollectionsTitle": "컬렉션 없음", diff --git a/apps/desktop-ui/messages/ms.json b/apps/desktop-ui/messages/ms.json index 15e1a393..a7b29e4a 100644 --- a/apps/desktop-ui/messages/ms.json +++ b/apps/desktop-ui/messages/ms.json @@ -1923,6 +1923,8 @@ "collectionsSidebar": { "title": "Sumber API", "newCollection": "Koleksi baharu", + "openFolderCollection": "Buka folder sebagai koleksi", + "folderCollection": "Koleksi folder", "tabCollections": "Koleksi", "tabHistory": "Sejarah", "noCollectionsTitle": "Tiada koleksi", diff --git a/apps/desktop-ui/messages/nb.json b/apps/desktop-ui/messages/nb.json index db3c3c56..24828968 100644 --- a/apps/desktop-ui/messages/nb.json +++ b/apps/desktop-ui/messages/nb.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "API-ressurser", "newCollection": "Ny samling", + "openFolderCollection": "Åpne mappe som samling", + "folderCollection": "Mappesamling", "tabCollections": "Samlinger", "tabHistory": "Historikk", "noCollectionsTitle": "Ingen samlinger", diff --git a/apps/desktop-ui/messages/nl.json b/apps/desktop-ui/messages/nl.json index 8e3b0277..d2ef1e33 100644 --- a/apps/desktop-ui/messages/nl.json +++ b/apps/desktop-ui/messages/nl.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "API-bronnen", "newCollection": "Nieuwe collectie", + "openFolderCollection": "Map openen als collectie", + "folderCollection": "Mapcollectie", "tabCollections": "Collecties", "tabHistory": "Geschiedenis", "noCollectionsTitle": "Geen collecties", diff --git a/apps/desktop-ui/messages/pl.json b/apps/desktop-ui/messages/pl.json index 4cd12d26..7d7b981c 100644 --- a/apps/desktop-ui/messages/pl.json +++ b/apps/desktop-ui/messages/pl.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "API resources", "newCollection": "New collection", + "openFolderCollection": "Otwórz folder jako kolekcję", + "folderCollection": "Kolekcja z folderu", "tabCollections": "Collections", "tabHistory": "History", "noCollectionsTitle": "No collections", diff --git a/apps/desktop-ui/messages/pt-BR.json b/apps/desktop-ui/messages/pt-BR.json index 00f8e012..8c5787cc 100644 --- a/apps/desktop-ui/messages/pt-BR.json +++ b/apps/desktop-ui/messages/pt-BR.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "Recursos API", "newCollection": "Nova coleção", + "openFolderCollection": "Abrir pasta como coleção", + "folderCollection": "Coleção de pasta", "tabCollections": "Coleções", "tabHistory": "Histórico", "noCollectionsTitle": "Sem coleções", diff --git a/apps/desktop-ui/messages/pt.json b/apps/desktop-ui/messages/pt.json index 8dc4365a..0b54e559 100644 --- a/apps/desktop-ui/messages/pt.json +++ b/apps/desktop-ui/messages/pt.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "Recursos API", "newCollection": "Nova coleção", + "openFolderCollection": "Abrir pasta como coleção", + "folderCollection": "Coleção de pasta", "tabCollections": "Coleções", "tabHistory": "Histórico", "noCollectionsTitle": "Sem coleções", diff --git a/apps/desktop-ui/messages/ru.json b/apps/desktop-ui/messages/ru.json index 82592912..b2692b3e 100644 --- a/apps/desktop-ui/messages/ru.json +++ b/apps/desktop-ui/messages/ru.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "API-ресурсы", "newCollection": "Новая коллекция", + "openFolderCollection": "Открыть папку как коллекцию", + "folderCollection": "Коллекция из папки", "tabCollections": "Коллекции", "tabHistory": "История", "noCollectionsTitle": "Нет коллекций", diff --git a/apps/desktop-ui/messages/sv.json b/apps/desktop-ui/messages/sv.json index badc30d6..79a20744 100644 --- a/apps/desktop-ui/messages/sv.json +++ b/apps/desktop-ui/messages/sv.json @@ -1923,6 +1923,8 @@ "collectionsSidebar": { "title": "API-resurser", "newCollection": "Ny samling", + "openFolderCollection": "Öppna mapp som samling", + "folderCollection": "Mappsamling", "tabCollections": "Samlingar", "tabHistory": "Historik", "noCollectionsTitle": "Inga samlingar", diff --git a/apps/desktop-ui/messages/tr.json b/apps/desktop-ui/messages/tr.json index eefca454..d5bce185 100644 --- a/apps/desktop-ui/messages/tr.json +++ b/apps/desktop-ui/messages/tr.json @@ -1923,6 +1923,8 @@ "collectionsSidebar": { "title": "API kaynakları", "newCollection": "Yeni koleksiyon", + "openFolderCollection": "Klasörü koleksiyon olarak aç", + "folderCollection": "Klasör koleksiyonu", "tabCollections": "Koleksiyonlar", "tabHistory": "Geçmiş", "noCollectionsTitle": "Koleksiyon yok", diff --git a/apps/desktop-ui/messages/uk.json b/apps/desktop-ui/messages/uk.json index 51c34f08..a992e42d 100644 --- a/apps/desktop-ui/messages/uk.json +++ b/apps/desktop-ui/messages/uk.json @@ -1923,6 +1923,8 @@ "collectionsSidebar": { "title": "API ресурси", "newCollection": "Нова колекція", + "openFolderCollection": "Відкрити папку як колекцію", + "folderCollection": "Колекція з папки", "tabCollections": "Колекції", "tabHistory": "Історія", "noCollectionsTitle": "Немає колекцій", diff --git a/apps/desktop-ui/messages/vi.json b/apps/desktop-ui/messages/vi.json index 7cc54820..32645338 100644 --- a/apps/desktop-ui/messages/vi.json +++ b/apps/desktop-ui/messages/vi.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "Tài nguyên API", "newCollection": "Bộ sưu tập mới", + "openFolderCollection": "Mở thư mục dưới dạng bộ sưu tập", + "folderCollection": "Bộ sưu tập thư mục", "tabCollections": "Bộ sưu tập", "tabHistory": "Lịch sử", "noCollectionsTitle": "Chưa có bộ sưu tập", diff --git a/apps/desktop-ui/messages/zh.json b/apps/desktop-ui/messages/zh.json index 42b1770e..efe9096e 100644 --- a/apps/desktop-ui/messages/zh.json +++ b/apps/desktop-ui/messages/zh.json @@ -2008,6 +2008,8 @@ "collectionsSidebar": { "title": "API 资源", "newCollection": "新建集合", + "openFolderCollection": "将文件夹作为集合打开", + "folderCollection": "文件夹集合", "tabCollections": "集合", "tabHistory": "历史", "noCollectionsTitle": "暂无集合", diff --git a/apps/desktop-ui/package.json b/apps/desktop-ui/package.json index 4a9b01e9..ba820e1c 100644 --- a/apps/desktop-ui/package.json +++ b/apps/desktop-ui/package.json @@ -55,6 +55,8 @@ "@tanstack/react-virtual": "^3.14.3", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-deep-link": "^2.4.9", + "@tauri-apps/plugin-dialog": "^2.4.0", + "@tauri-apps/plugin-fs": "^2.4.0", "@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", diff --git a/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx b/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx index 686de5f2..26c03d12 100644 --- a/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx +++ b/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx @@ -6,7 +6,7 @@ import { Checkbox } from "@/components/ui/checkbox" import { ScrollArea } from "@/components/ui/scroll-area" import { Collection, CollectionRequest } from "../types" import { CollectionItem } from "./collection-item" -import { FolderPlus, Trash2, Pencil, MoreHorizontal, Search, X, FileDown, Play, Server, Link2, Globe, PanelRightClose } from "lucide-react" +import { FolderPlus, FolderGit2, Trash2, Pencil, MoreHorizontal, Search, X, FileDown, Play, Server, Link2, Globe, PanelRightClose } from "lucide-react" import { buildShareUrl } from "@/lib/share-link" import { backendFetch } from "@/lib/backend-auth" import { toast } from "sonner" @@ -58,7 +58,11 @@ export function CollectionsSidebar({ onCollapse, }: CollectionsSidebarProps) { const { collections, isLoading } = useCollectionsState() - const { addFolder: onAddFolder, deleteItem: onDelete, toggleFolder: onToggle, createCollection: onCreateCollection, renameCollection: onRenameCollection, renameFolder: onRenameFolder, patchFolder, deleteMultipleCollections: onDeleteMultiple } = useCollectionsActions() + const { addFolder: onAddFolder, deleteItem: onDelete, toggleFolder: onToggle, createCollection: onCreateCollection, renameCollection: onRenameCollection, renameFolder: onRenameFolder, patchFolder, deleteMultipleCollections: onDeleteMultiple, openFolderCollection } = useCollectionsActions() + + // isDesktop() is false during SSR — gate behind a mounted flag to avoid hydration mismatch. + const [desktop, setDesktop] = React.useState(false) + React.useEffect(() => { setDesktop(isDesktop()) }, []) const { history, isLoading: isHistoryLoading } = useHistoryState() const { clearHistory: onClearHistory, deleteHistoryItem: onDeleteHistoryItem } = useHistoryActions() const t = useTranslations("ApiClient.collectionsSidebar") @@ -215,6 +219,17 @@ export function CollectionsSidebar({ > + {desktop && ( + + )} {onCollapse && ( + +
+ {item.url} +
+ {new Date(item.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} + {item.status && ( + = 200 && item.status < 300 ? "text-green-500" + : item.status >= 300 && item.status < 400 ? "text-amber-500" + : "text-destructive" + )}> + {item.status} + + )} +
+
+ + + ), [onLoadRequest, onDeleteHistoryItem, tRoot]) + return ( // Header (icon, title, collapse) is owned by ToolSidebarLayout in // components/api-client/api-client.tsx — this renders the body only. @@ -421,19 +482,19 @@ export function CollectionsSidebar({ onLoadRequest: loadRequest }: CollectionsSi Publish as public mock } - downloadCollectionAsPostman(collection)}> + void import("@/lib/export/postman").then((m) => m.downloadCollectionAsPostman(collection))}> Export (Postman v2.1) - downloadCollectionAsOpenApi(collection)}> + void import("@/lib/export/openapi").then((m) => m.downloadCollectionAsOpenApi(collection))}> Export (OpenAPI 3.0) - downloadCollectionAsHar(collection)}> + void import("@/lib/export/har").then((m) => m.downloadCollectionAsHar(collection))}> Export (HAR 1.2) - downloadCollectionAsInsomnia(collection)}> + void import("@/lib/export/insomnia").then((m) => m.downloadCollectionAsInsomnia(collection))}> Export (Insomnia v4) @@ -523,62 +584,7 @@ export function CollectionsSidebar({ onLoadRequest: loadRequest }: CollectionsSi height={historyListHeight} onSelect={onLoadRequest} onDelete={onDeleteHistoryItem} - renderRow={(item, style) => ( -
-
onLoadRequest(item)} - > -
-
- - {item.method} - - - {item.name ? getApiClientRequestDisplayName(item.name, tRoot) : item.url} - -
- -
-
- {item.url} -
- {new Date(item.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} - {item.status && ( - = 200 && item.status < 300 ? "text-green-500" - : item.status >= 300 && item.status < 400 ? "text-amber-500" - : "text-destructive" - )}> - {item.status} - - )} -
-
-
-
- )} + renderRow={renderHistoryRow} /> )} diff --git a/apps/desktop-ui/src/components/api-client/json-visualisers.tsx b/apps/desktop-ui/src/components/api-client/json-visualisers.tsx index 697270be..e25b42b2 100644 --- a/apps/desktop-ui/src/components/api-client/json-visualisers.tsx +++ b/apps/desktop-ui/src/components/api-client/json-visualisers.tsx @@ -35,13 +35,21 @@ function TreeNode({ name, value, depth }: TreeNodeProps) { {name} : {isArr ? "[" : "{"}{entries.length}{isArr ? "]" : "}"} - {open && entries.map(([k, v]) => ( + {open && entries.slice(0, MAX_TREE_CHILDREN).map(([k, v]) => ( ))} + {open && entries.length > MAX_TREE_CHILDREN && ( + + )} ) } +// Nothing is virtualised here; a 10k-element array would otherwise mount ~100k +// DOM nodes when the Tree tab opens. Caps keep the views instant on big bodies. +const MAX_TREE_CHILDREN = 200 +const MAX_TABLE_ROWS = 500 + function Leaf({ name, text, tone, depth }: { name: string | number; text: string; tone: string; depth: number }) { return (
@@ -95,7 +103,7 @@ export function JsonTableView({ rows }: { rows: unknown }) { - {rows.map((r, i) => ( + {rows.slice(0, MAX_TABLE_ROWS).map((r, i) => ( {cols.map((c) => { const v = (r && typeof r === "object" ? (r as Record)[c] : undefined) @@ -109,6 +117,11 @@ export function JsonTableView({ rows }: { rows: unknown }) { ))} + {rows.length > MAX_TABLE_ROWS && ( +
+ Showing {MAX_TABLE_ROWS} of {rows.length} rows +
+ )}
) } diff --git a/apps/desktop-ui/src/components/api-client/request-tabs.tsx b/apps/desktop-ui/src/components/api-client/request-tabs.tsx index d12e2e4f..b1ece110 100644 --- a/apps/desktop-ui/src/components/api-client/request-tabs.tsx +++ b/apps/desktop-ui/src/components/api-client/request-tabs.tsx @@ -69,7 +69,7 @@ interface RequestTabsProps { setComments?: (next: import("./types").RequestComment[]) => void } -export function RequestTabs({ +function RequestTabsImpl({ params, setParams, headers, @@ -105,9 +105,9 @@ export function RequestTabs({ ], }), [body]) - const updateBody = (updates: Partial) => { + const updateBody = React.useCallback((updates: Partial) => { setBody({ ...normalizedBody, ...updates }) - } + }, [normalizedBody, setBody]) const addFormDataItem = () => { updateBody({ @@ -157,10 +157,10 @@ export function RequestTabs({ const handleBodyContentChange = React.useCallback( (v: string) => updateBody({ content: v }), - // updateBody closes over normalizedBody via its own capture; listing - // updateBody as the dep is sufficient and avoids re-creating the - // callback on every normalizedBody identity change. - // eslint-disable-next-line react-hooks/exhaustive-deps + [updateBody] + ) + const handleGraphqlVariablesChange = React.useCallback( + (v: string) => updateBody({ graphqlVariables: v }), [updateBody] ) @@ -413,7 +413,9 @@ export function RequestTabs({ - + {/* Editor tabs stay mounted (forceMount) so switching sub-tabs doesn't + re-create a Monaco instance each time; `hidden` via data-state. */} +
void handleFile(e.target.files?.[0] ?? null)} /> diff --git a/apps/desktop-ui/src/lib/__tests__/import-export.test.ts b/apps/desktop-ui/src/lib/__tests__/import-export.test.ts index ff7d1ee6..308fcbc0 100644 --- a/apps/desktop-ui/src/lib/__tests__/import-export.test.ts +++ b/apps/desktop-ui/src/lib/__tests__/import-export.test.ts @@ -31,6 +31,15 @@ describe("detectImportFormat", () => { it("detects cURL", () => { expect(detectImportFormat("curl https://example.com")).toBe("curl") }) + it("detects Insomnia v4 JSON and v5 YAML", () => { + expect(detectImportFormat(JSON.stringify({ _type: "export", __export_format: 4, resources: [] }))) + .toBe("insomnia") + expect(detectImportFormat("type: collection.insomnia.rest/5.0\nname: X\n")).toBe("insomnia") + }) + it("detects Bruno .bru and OpenCollection YAML", () => { + expect(detectImportFormat("meta {\n name: Ping\n}\n\nget {\n url: https://x/y\n}\n")).toBe("bruno") + expect(detectImportFormat("info:\n name: X\nhttp:\n method: GET\n url: https://x/y\n")).toBe("bruno") + }) it("returns unknown on garbage", () => { expect(detectImportFormat("{not json")).toBe("unknown") expect(detectImportFormat("")).toBe("unknown") diff --git a/apps/desktop-ui/src/lib/import/__tests__/bruno.test.ts b/apps/desktop-ui/src/lib/import/__tests__/bruno.test.ts new file mode 100644 index 00000000..a96cdac5 --- /dev/null +++ b/apps/desktop-ui/src/lib/import/__tests__/bruno.test.ts @@ -0,0 +1,277 @@ +import { + looksLikeBrunoBru, + looksLikeOpenCollectionYaml, + parseBruRequest, + parseBrunoEnvironment, + parseOpenCollectionRequestYaml, + importBrunoFolder, +} from "../bruno" +import type { KeyValueItem } from "@/components/api-client/types" + +const kv = (items: KeyValueItem[] | undefined) => + (items ?? []).map((i) => [i.key, i.value, i.active] as const) + +// ── fixtures ──────────────────────────────────────────────────────────────── + +const createUser = `meta { + name: Create user + type: http + seq: 2 +} + +post { + url: {{base_url}}/users?dry=1 + body: json + auth: bearer +} + +params:query { + dry: 1 + ~verbose: true +} + +headers { + Content-Type: application/json + ~X-Debug: 1 +} + +auth:bearer { + token: {{token}} +} + +body:json { + { + "name": "Ada" + } +} + +script:pre-request { + bru.setVar("t", Date.now()) +} + +tests { + test("created", function() { + expect(res.getStatus()).to.equal(201) + }) +} +` + +const listUsers = `meta { + name: List users + type: http + seq: 1 +} + +get { + url: {{base_url}}/users +} +` + +const graphqlReq = `meta { + name: Search + type: graphql + seq: 1 +} + +post { + url: {{base_url}}/graphql + body: graphql +} + +body:graphql { + query Search($q: String) { search(q: $q) { id } } +} + +body:graphql:vars { + { "q": "ada" } +} +` + +const uploadReq = `meta { + name: Upload + seq: 2 +} + +post { + url: {{base_url}}/upload + body: multipartForm + auth: oauth2 +} + +body:multipart-form { + avatar: @file(/tmp/a.png) + caption: hello +} + +auth:oauth2 { + grant_type: client_credentials + access_token_url: https://id.example.com/token + client_id: cid + client_secret: shh + scope: read write +} +` + +const folderBru = `meta { + name: Users API + seq: 1 +} + +headers { + Accept: application/json +} +` + +const envBru = `vars { + base_url: https://api.example.com + ~staging_only: 1 +} + +vars:secret [ + token +] +` + +const ocRequest = `info: + name: OC ping + type: http + seq: 1 +http: + method: GET + url: "{{base_url}}/ping" + headers: + - name: Accept + value: application/json + - name: X-Off + value: "1" + disabled: true + auth: + type: bearer + token: "{{token}}" +` + +// ── tests ─────────────────────────────────────────────────────────────────── + +describe("bruno .bru detection", () => { + it("recognises .bru files and rejects other text", () => { + expect(looksLikeBrunoBru(createUser)).toBe(true) + expect(looksLikeBrunoBru(folderBru)).toBe(true) + expect(looksLikeBrunoBru('{"info":{"name":"x"},"item":[]}')).toBe(false) + }) +}) + +describe("parseBruRequest", () => { + it("maps method, url, params, headers, json body, bearer auth and scripts", () => { + const r = parseBruRequest(createUser) + expect(r.name).toBe("Create user") + expect(r.method).toBe("POST") + expect(r.url).toBe("{{base_url}}/users?dry=1") + expect(kv(r.params)).toEqual([["dry", "1", true], ["verbose", "true", false]]) + expect(kv(r.headers)).toEqual([ + ["Content-Type", "application/json", true], + ["X-Debug", "1", false], + ]) + expect(r.body.type).toBe("json") + expect(JSON.parse(r.body.content)).toEqual({ name: "Ada" }) + expect(r.auth).toEqual({ type: "bearer", token: "{{token}}" }) + expect(r.preRequestScript).toContain('bru.setVar("t"') + expect(r.testScript).toContain('test("created"') + }) + + it("maps a graphql body with variables", () => { + const r = parseBruRequest(graphqlReq) + expect(r.body.type).toBe("graphql") + expect(r.body.content).toContain("query Search") + expect(JSON.parse(r.body.graphqlVariables ?? "{}")).toEqual({ q: "ada" }) + }) + + it("maps multipart @file entries and oauth2 client_credentials", () => { + const r = parseBruRequest(uploadReq) + expect(r.body.type).toBe("form-data") + const [avatar, caption] = r.body.formData ?? [] + expect(avatar).toMatchObject({ key: "avatar", valueType: "file", fileName: "/tmp/a.png" }) + expect(caption).toMatchObject({ key: "caption", value: "hello", valueType: "text" }) + expect(r.auth).toMatchObject({ + type: "oauth2", + oauth2: { + grantType: "client_credentials", + tokenUrl: "https://id.example.com/token", + clientId: "cid", + clientSecret: "shh", + scope: "read write", + }, + }) + }) +}) + +describe("parseBrunoEnvironment", () => { + it("reads vars, disabled vars and secret names (values blank)", () => { + const env = parseBrunoEnvironment(envBru, "prod") + expect(env.name).toBe("prod") + expect(env.variables.map((v) => [v.key, v.value, v.enabled])).toEqual([ + ["base_url", "https://api.example.com", true], + ["staging_only", "1", false], + ["token", "", true], + ]) + }) +}) + +describe("OpenCollection YAML", () => { + it("detects and parses a request.yml", () => { + expect(looksLikeOpenCollectionYaml(ocRequest)).toBe(true) + const r = parseOpenCollectionRequestYaml(ocRequest) + expect(r.name).toBe("OC ping") + expect(r.method).toBe("GET") + expect(r.url).toBe("{{base_url}}/ping") + expect(kv(r.headers)).toEqual([ + ["Accept", "application/json", true], + ["X-Off", "1", false], + ]) + expect(r.auth).toEqual({ type: "bearer", token: "{{token}}" }) + }) +}) + +describe("importBrunoFolder", () => { + const files = [ + { path: "bruno.json", text: JSON.stringify({ version: "1", name: "Pet Store", type: "collection" }) }, + { path: "users/folder.bru", text: folderBru }, + { path: "users/create-user.bru", text: createUser }, + { path: "users/list-users.bru", text: listUsers }, + { path: "environments/prod.bru", text: envBru }, + { path: "README.md", text: "ignored" }, + ] + + it("builds the collection tree, honours seq order and reads environments", () => { + const { collection, environments } = importBrunoFolder(files) + expect(collection.name).toBe("Pet Store") + expect(collection.items).toHaveLength(1) + + const folder = collection.items[0] + expect(folder).toMatchObject({ type: "folder", name: "Users API" }) + if (!("type" in folder) || folder.type !== "folder") throw new Error("expected a folder") + expect(kv(folder.defaultHeaders)).toEqual([["Accept", "application/json", true]]) + // seq 1 before seq 2, not file order + expect(folder.items.map((i) => i.name)).toEqual(["List users", "Create user"]) + + expect(environments).toHaveLength(1) + expect(environments[0].name).toBe("prod") + expect(environments[0].variables[0]).toMatchObject({ key: "base_url" }) + }) + + it("tolerates a wrapping directory (zip-style) and windows separators", () => { + const wrapped = files.map((f) => ({ ...f, path: `Pet Store\\${f.path.replace(/\//g, "\\")}` })) + const { collection, environments } = importBrunoFolder(wrapped) + expect(collection.name).toBe("Pet Store") + expect(collection.items).toHaveLength(1) + expect(environments).toHaveLength(1) + }) + + it("mixes OpenCollection YAML requests into the same tree", () => { + const { collection } = importBrunoFolder([ + { path: "opencollection.yml", text: "info:\n name: OC Store\n" }, + { path: "ping.yml", text: ocRequest }, + ]) + expect(collection.name).toBe("OC Store") + expect(collection.items.map((i) => i.name)).toEqual(["OC ping"]) + }) +}) diff --git a/apps/desktop-ui/src/lib/import/__tests__/insomnia.test.ts b/apps/desktop-ui/src/lib/import/__tests__/insomnia.test.ts new file mode 100644 index 00000000..30c11356 --- /dev/null +++ b/apps/desktop-ui/src/lib/import/__tests__/insomnia.test.ts @@ -0,0 +1,404 @@ +import { exportCollectionAsInsomnia } from "../../export/insomnia" +import { importInsomniaWithMeta, looksLikeInsomniaExport } from "../insomnia" +import type { Collection, CollectionFolder, CollectionRequest, KeyValueItem } from "@/components/api-client/types" + +// ── v4 JSON fixture ────────────────────────────────────────────────────────── + +const v4 = { + _type: "export", + __export_format: 4, + __export_source: "insomnia.desktop.app:v2023.5.8", + resources: [ + { _id: "wrk_1", _type: "workspace", name: "Pet Store", scope: "collection", parentId: null }, + { + _id: "env_base", _type: "environment", parentId: "wrk_1", name: "Base Environment", metaSortKey: 1, + data: { base_url: "https://api.example.com", auth: { token: "abc", nested: { deep: 1 } }, flag: true, list: [1, 2], ref: "{{ _.base_url }}/v1" }, + }, + { _id: "env_prod", _type: "environment", parentId: "env_base", name: "Production", metaSortKey: 2, data: { base_url: "https://prod.example.com" } }, + { _id: "env_empty", _type: "environment", parentId: "env_base", name: "Empty", data: {} }, + { _id: "jar_1", _type: "cookie_jar", parentId: "wrk_1", name: "Default Jar", cookies: [] }, + { _id: "fld_users", _type: "request_group", parentId: "wrk_1", name: "Users", metaSortKey: -200, preRequestScript: "insomnia.environment.set('x', 1)" }, + { _id: "fld_admin", _type: "request_group", parentId: "fld_users", name: "Admin", metaSortKey: -100, environment: { ignored: true } }, + { + _id: "req_list", _type: "request", parentId: "fld_users", name: "List users", method: "get", metaSortKey: -300, + url: "{{ _.base_url }}/users", + parameters: [{ name: "page", value: "1" }, { name: "debug", value: "true", disabled: true }], + headers: [{ name: "Accept", value: "application/json" }, { name: "X-Off", value: "1", disabled: true }], + body: {}, + authentication: { type: "bearer", token: "{{ _.auth.token }}", prefix: "Bearer" }, + preRequestScript: "insomnia.request.addHeader({ name: 'a', value: 'b' })", + afterResponseScript: "insomnia.test('ok', () => {})", + }, + { + _id: "req_create", _type: "request", parentId: "fld_users", name: "Create user", method: "POST", metaSortKey: -200, + url: "{{ base_url }}/users", + body: { mimeType: "application/json", text: '{"name":"{{ _.name }}"}' }, + authentication: { type: "basic", username: "u", password: "p" }, + }, + { + _id: "req_ban", _type: "request", parentId: "fld_admin", name: "Ban", method: "DELETE", + url: "{{ _.base_url }}/users/1", + body: { mimeType: "text/plain", text: "bye" }, + authentication: { type: "apikey", key: "X-Api-Key", value: "k", addTo: "header" }, + }, + { + _id: "req_xml", _type: "request", parentId: "wrk_1", name: "XML", method: "PUT", metaSortKey: 10, + url: "https://x/xml", + body: { mimeType: "application/xml", text: "" }, + authentication: { type: "apikey", key: "api_key", value: "k", addTo: "queryParams" }, + }, + { + _id: "req_form", _type: "request", parentId: "wrk_1", name: "Form", method: "POST", metaSortKey: 20, + url: "https://x/form", + body: { mimeType: "application/x-www-form-urlencoded", params: [{ name: "a", value: "1" }, { name: "b", value: "2", disabled: true }] }, + authentication: { type: "oauth2", grantType: "client_credentials", accessTokenUrl: "https://x/token", clientId: "cid", clientSecret: "sec", scope: "read" }, + }, + { + _id: "req_multi", _type: "request", parentId: "wrk_1", name: "Upload", method: "POST", metaSortKey: 30, + url: "https://x/upload", + body: { mimeType: "multipart/form-data", params: [{ name: "file", type: "file", fileName: "/Users/me/a.png" }, { name: "note", value: "hi" }] }, + authentication: { type: "oauth2", grantType: "authorization_code", accessTokenUrl: "https://x/token", authorizationUrl: "https://x/auth", clientId: "cid", redirectUrl: "http://localhost/cb" }, + }, + { + _id: "req_gql", _type: "request", parentId: "wrk_1", name: "GQL", method: "POST", metaSortKey: 40, + url: "https://x/graphql", + body: { mimeType: "application/graphql", text: JSON.stringify({ query: "query { me { id } }", variables: '{"a":1}' }) }, + authentication: { type: "basic", disabled: true, username: "u", password: "p" }, + }, + { _id: "req_bogus", _type: "request", parentId: "wrk_1", name: "Bogus", method: "PURGE", url: "https://x/", metaSortKey: 50 }, + ], +} + +// ── v5 YAML fixtures ───────────────────────────────────────────────────────── + +const v5 = ` +type: collection.insomnia.rest/5.0 +name: Pet Store v5 +meta: + id: wrk_v5 +collection: + - name: Users + meta: + id: fld_1 + sortKey: -100 + scripts: + preRequest: insomnia.environment.set("folder", 1) + children: + - url: "{{ _.base_url }}/users" + name: List users + meta: + id: req_1 + sortKey: -50 + method: GET + parameters: + - name: page + value: "1" + - name: off + value: x + disabled: true + headers: + - name: Accept + value: application/json + authentication: + type: apikey + key: X-Key + value: "{{ _.key }}" + addTo: header + scripts: + preRequest: |- + insomnia.request.addHeader({ name: "a", value: "b" }) + afterResponse: |- + insomnia.test("ok", () => {}) + - url: "{{ _.base_url }}/users" + name: Create user + meta: + id: req_2 + sortKey: -40 + method: POST + body: + mimeType: application/json + text: '{"name":"{{ _.name }}"}' + authentication: + type: bearer + token: "{{ _.token }}" + - url: https://x/ping + name: Ping + meta: + id: req_3 + sortKey: 5 + method: HEAD +cookieJar: + name: Default Jar +environments: + name: Base Environment + data: + base_url: https://api.example.com + key: k1 + subEnvironments: + - name: Staging + data: + base_url: https://staging.example.com +` + +const v5Env = ` +type: environment.insomnia.rest/5.0 +name: Prod +data: + base_url: https://prod + db: + host: h +` + +const folders = (items: Collection["items"]) => items as CollectionFolder[] +const reqs = (items: Collection["items"]) => items as CollectionRequest[] +const bare = (kv: KeyValueItem[]) => kv.map(({ key, value, active }) => ({ key, value, active })) + +describe("looksLikeInsomniaExport", () => { + it("detects v4 JSON and v5 YAML", () => { + expect(looksLikeInsomniaExport(JSON.stringify(v4))).toBe(true) + expect(looksLikeInsomniaExport(v5)).toBe(true) + expect(looksLikeInsomniaExport(v5Env)).toBe(true) + }) + + it("rejects other formats and garbage", () => { + expect(looksLikeInsomniaExport(JSON.stringify({ info: { name: "pm" }, item: [] }))).toBe(false) + expect(looksLikeInsomniaExport("openapi: 3.0.0\ninfo:\n title: x")).toBe(false) + expect(looksLikeInsomniaExport("{ not json")).toBe(false) + expect(looksLikeInsomniaExport("")).toBe(false) + }) +}) + +describe("Insomnia v4 import", () => { + const { collection, environments } = importInsomniaWithMeta(JSON.stringify(v4)) + + it("uses the workspace name and orders by metaSortKey", () => { + expect(collection.name).toBe("Pet Store") + expect(collection.items.map((i) => i.name)).toEqual(["Users", "XML", "Form", "Upload", "GQL", "Bogus"]) + }) + + it("nests groups via parentId and keeps group scripts", () => { + const users = folders(collection.items)[0] + expect(users.type).toBe("folder") + expect(users.preRequestScript).toBe("insomnia.environment.set('x', 1)") + expect(users.items.map((i) => i.name)).toEqual(["List users", "Create user", "Admin"]) + const admin = users.items[2] as CollectionFolder + expect(admin.type).toBe("folder") + expect(admin.items.map((i) => i.name)).toEqual(["Ban"]) + }) + + it("converts params, headers, disabled rows, template tags, and scripts", () => { + const list = reqs(folders(collection.items)[0].items)[0] + expect(list.method).toBe("GET") + expect(list.url).toBe("{{base_url}}/users") + expect(bare(list.params)).toEqual([{ key: "page", value: "1", active: true }, { key: "debug", value: "true", active: false }]) + expect(bare(list.headers)).toEqual([{ key: "Accept", value: "application/json", active: true }, { key: "X-Off", value: "1", active: false }]) + expect(list.body).toEqual({ type: "none", content: "" }) + expect(list.auth).toEqual({ type: "bearer", token: "{{auth.token}}" }) + expect(list.preRequestScript).toBe("insomnia.request.addHeader({ name: 'a', value: 'b' })") + expect(list.testScript).toBe("insomnia.test('ok', () => {})") + expect(list.params.every((p) => typeof p.id === "string" && p.id.length > 0)).toBe(true) + }) + + it("converts json / text / xml bodies and basic / apikey auth", () => { + const create = reqs(folders(collection.items)[0].items)[1] + expect(create.url).toBe("{{base_url}}/users") + expect(create.body).toEqual({ type: "json", content: '{"name":"{{name}}"}' }) + expect(create.auth).toEqual({ type: "basic", username: "u", password: "p" }) + + const ban = reqs((folders(collection.items)[0].items[2] as CollectionFolder).items)[0] + expect(ban.method).toBe("DELETE") + expect(ban.body).toEqual({ type: "text", content: "bye" }) + expect(ban.auth).toEqual({ type: "api-key", apiKeyKey: "X-Api-Key", apiKeyValue: "k", apiKeyLocation: "header" }) + + const xml = reqs(collection.items)[1] + expect(xml.body).toEqual({ type: "text", content: "" }) + expect(xml.auth).toEqual({ type: "api-key", apiKeyKey: "api_key", apiKeyValue: "k", apiKeyLocation: "query" }) + }) + + it("converts urlencoded + multipart bodies and oauth2 auth", () => { + const form = reqs(collection.items)[2] + expect(form.body.type).toBe("x-www-form-urlencoded") + expect(bare(form.body.urlEncoded!)).toEqual([{ key: "a", value: "1", active: true }, { key: "b", value: "2", active: false }]) + expect(form.auth).toMatchObject({ + type: "oauth2", + oauth2: { grantType: "client_credentials", tokenUrl: "https://x/token", clientId: "cid", clientSecret: "sec", scope: "read" }, + }) + + const upload = reqs(collection.items)[3] + expect(upload.body.type).toBe("form-data") + expect(upload.body.formData!.map(({ key, value, active, valueType, fileName, fileContentBase64 }) => ({ key, value, active, valueType, fileName, fileContentBase64 }))).toEqual([ + { key: "file", value: "", active: true, valueType: "file", fileName: "a.png", fileContentBase64: undefined }, + { key: "note", value: "hi", active: true, valueType: "text", fileName: undefined, fileContentBase64: undefined }, + ]) + expect(upload.auth).toMatchObject({ + type: "oauth2", + oauth2: { grantType: "authorization_code", tokenUrl: "https://x/token", authUrl: "https://x/auth", clientId: "cid", redirectUri: "http://localhost/cb" }, + }) + }) + + it("unpacks graphql bodies, drops disabled auth, and falls back on unknown methods", () => { + const gql = reqs(collection.items)[4] + expect(gql.body).toEqual({ type: "graphql", content: "query { me { id } }", graphqlVariables: '{"a":1}' }) + expect(gql.auth).toEqual({ type: "none" }) + + const bogus = reqs(collection.items)[5] + expect(bogus.method).toBe("GET") + expect(bogus.auth).toEqual({ type: "none" }) + expect(bogus.body).toEqual({ type: "none", content: "" }) + }) + + it("flattens environments to dotted keys and skips empty ones", () => { + expect(environments.map((e) => e.name)).toEqual(["Base Environment", "Production"]) + const vars = Object.fromEntries(environments[0].variables.map((v) => [v.key, v.value])) + expect(vars).toEqual({ + base_url: "https://api.example.com", + "auth.token": "abc", + "auth.nested.deep": "1", + flag: "true", + list: "[1,2]", + ref: "{{base_url}}/v1", + }) + expect(environments[0].variables.every((v) => v.enabled && v.id)).toBe(true) + expect(environments[1].variables).toHaveLength(1) + }) +}) + +describe("Insomnia v5 YAML import", () => { + it("walks the collection tree", () => { + const { collection, environments } = importInsomniaWithMeta(v5) + expect(collection.name).toBe("Pet Store v5") + expect(collection.items.map((i) => i.name)).toEqual(["Users", "Ping"]) + + const users = folders(collection.items)[0] + expect(users.type).toBe("folder") + expect(users.preRequestScript).toBe('insomnia.environment.set("folder", 1)') + + const list = reqs(users.items)[0] + expect(list.method).toBe("GET") + expect(list.url).toBe("{{base_url}}/users") + expect(bare(list.params)).toEqual([{ key: "page", value: "1", active: true }, { key: "off", value: "x", active: false }]) + expect(bare(list.headers)).toEqual([{ key: "Accept", value: "application/json", active: true }]) + expect(list.auth).toEqual({ type: "api-key", apiKeyKey: "X-Key", apiKeyValue: "{{key}}", apiKeyLocation: "header" }) + expect(list.preRequestScript).toBe('insomnia.request.addHeader({ name: "a", value: "b" })') + expect(list.testScript).toBe('insomnia.test("ok", () => {})') + + const create = reqs(users.items)[1] + expect(create.body).toEqual({ type: "json", content: '{"name":"{{name}}"}' }) + expect(create.auth).toEqual({ type: "bearer", token: "{{token}}" }) + + const ping = reqs(collection.items)[1] + expect(ping.method).toBe("HEAD") + expect(ping.body).toEqual({ type: "none", content: "" }) + + expect(environments.map((e) => e.name)).toEqual(["Base Environment", "Staging"]) + expect(environments[0].variables.map((v) => [v.key, v.value])).toEqual([["base_url", "https://api.example.com"], ["key", "k1"]]) + expect(environments[1].variables.map((v) => [v.key, v.value])).toEqual([["base_url", "https://staging.example.com"]]) + }) + + it("imports a standalone environment file", () => { + const { collection, environments } = importInsomniaWithMeta(v5Env) + expect(collection.items).toEqual([]) + expect(environments).toHaveLength(1) + expect(environments[0].name).toBe("Prod") + expect(environments[0].variables.map((v) => [v.key, v.value])).toEqual([["base_url", "https://prod"], ["db.host", "h"]]) + }) + + it("throws on non-Insomnia input", () => { + expect(() => importInsomniaWithMeta("openapi: 3.0.0")).toThrow(/Not an Insomnia/) + expect(() => importInsomniaWithMeta('{"info":{"name":"pm"},"item":[]}')).toThrow(/Not an Insomnia/) + }) +}) + +describe("round-trip through export/insomnia.ts", () => { + const kv = (key: string, value: string, active = true): KeyValueItem => ({ id: crypto.randomUUID(), key, value, active }) + const req = (over: Partial): CollectionRequest => ({ + id: crypto.randomUUID(), + name: "r", + method: "GET", + url: "https://api.test/x", + params: [], + headers: [], + body: { type: "none", content: "" }, + auth: { type: "none" }, + ...over, + }) + + const original: Collection = { + id: "c1", + name: "Round trip", + items: [ + req({ + name: "Create", + method: "POST", + url: "https://api.test/users", + params: [kv("page", "2"), kv("off", "1", false)], + headers: [kv("Content-Type", "application/json"), kv("X-Off", "1", false)], + body: { type: "json", content: '{"a":1}' }, + auth: { type: "bearer", token: "{{TOKEN}}" }, + }), + { + id: "f", name: "Auth", type: "folder", isOpen: true, + items: [ + req({ + name: "Login", + method: "POST", + url: "https://api.test/login", + body: { type: "x-www-form-urlencoded", content: "", urlEncoded: [kv("user", "a b"), kv("pass", "p&q"), kv("skip", "x", false)] }, + auth: { type: "basic", username: "u", password: "p" }, + }), + { + id: "f2", name: "Deep", type: "folder", + items: [ + req({ + name: "Note", + method: "PUT", + url: "https://api.test/n", + body: { type: "text", content: "hello" }, + auth: { type: "api-key", apiKeyKey: "X-Key", apiKeyValue: "k", apiKeyLocation: "header" }, + }), + ], + }, + ], + }, + ], + } + + it("preserves structure, method, url, active rows, body and auth", () => { + const exported = exportCollectionAsInsomnia(original) + expect(looksLikeInsomniaExport(exported)).toBe(true) + const { collection, environments } = importInsomniaWithMeta(exported) + + expect(environments).toEqual([]) + expect(collection.name).toBe("Round trip") + expect(collection.id).not.toBe(original.id) + expect(collection.items.map((i) => i.name)).toEqual(["Create", "Auth"]) + + const create = reqs(collection.items)[0] + const origCreate = reqs(original.items)[0] + expect(create.id).not.toBe(origCreate.id) + expect(create.method).toBe("POST") + expect(create.url).toBe("https://api.test/users") + // Exporter drops inactive rows; everything active must survive. + expect(bare(create.params)).toEqual([{ key: "page", value: "2", active: true }]) + expect(bare(create.headers)).toEqual([{ key: "Content-Type", value: "application/json", active: true }]) + expect(create.body).toEqual({ type: "json", content: '{"a":1}' }) + expect(create.auth).toEqual({ type: "bearer", token: "{{TOKEN}}" }) + + const auth = folders(collection.items)[1] + expect(auth.type).toBe("folder") + expect(auth.items.map((i) => i.name)).toEqual(["Login", "Deep"]) + + const login = reqs(auth.items)[0] + expect(login.method).toBe("POST") + expect(login.url).toBe("https://api.test/login") + expect(login.body.type).toBe("x-www-form-urlencoded") + expect(bare(login.body.urlEncoded!)).toEqual([{ key: "user", value: "a b", active: true }, { key: "pass", value: "p&q", active: true }]) + expect(login.auth).toEqual({ type: "basic", username: "u", password: "p" }) + + const note = reqs((auth.items[1] as CollectionFolder).items)[0] + expect(note.method).toBe("PUT") + expect(note.url).toBe("https://api.test/n") + expect(note.body).toEqual({ type: "text", content: "hello" }) + expect(note.auth).toEqual({ type: "api-key", apiKeyKey: "X-Key", apiKeyValue: "k", apiKeyLocation: "header" }) + }) +}) diff --git a/apps/desktop-ui/src/lib/import/bruno.ts b/apps/desktop-ui/src/lib/import/bruno.ts new file mode 100644 index 00000000..eb03c8d0 --- /dev/null +++ b/apps/desktop-ui/src/lib/import/bruno.ts @@ -0,0 +1,472 @@ +/** + * Bruno import — both on-disk formats: + * - legacy `.bru` language (`meta { … }`, `post { … }`, `body:json { … }` …), + * - v4 OpenCollection YAML (`opencollection.yml` + one `request.yml` per request). + * + * Pure functions, no fs: the UI hands us a flat `{ path, text }[]` of the opened + * folder. `{{var}}` syntax is identical to ours so values pass through untouched. + * + * OpenCollection shape (from bruno-filestore `formats/yml`, docs 404 at time of + * writing): `info.{name,type,seq}`, `http.{method,url,params,headers,body,auth}` + * (`graphql.*` for graphql requests), `runtime.scripts[{type,code}]`. A flatter + * `{ name, type, http, params, headers, body, auth, scripts, tests }` is accepted + * too since the spec is still moving. + */ + +import { load } from "js-yaml" +import type { + Collection, + CollectionFolder, + CollectionRequest, + KeyValueItem, + RequestAuth, + RequestBody, + RequestFormDataItem, + RequestMethod, +} from "@/components/api-client/types" +import type { Environment, EnvironmentVariable } from "@/components/api-client/use-environments" +import type { OAuth2GrantType } from "@/lib/oauth2" + +const METHODS: RequestMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] + +function id(): string { return crypto.randomUUID() } + +// ── .bru block parser ────────────────────────────────────────────────────── +// A file is a sequence of `name {` … `}` (dict or raw text) and `name [` … `]` +// (array) blocks. Closing brackets sit at column 0; inner lines are indented 2. + +interface BruBlock { lines: string[] } +type BruBlocks = Map +interface Entry { key: string; value: string; enabled: boolean } + +export function parseBruBlocks(text: string): BruBlocks { + const out: BruBlocks = new Map() + const lines = text.split(/\r?\n/) + let i = 0 + while (i < lines.length) { + const m = /^([A-Za-z][\w:-]*)\s*([{[])\s*$/.exec(lines[i]) + if (!m) { i++; continue } + const close = m[2] === "{" ? "}" : "]" + const body: string[] = [] + i++ + while (i < lines.length && lines[i].trimEnd() !== close) body.push(lines[i++]) + i++ + out.set(m[1], { lines: body }) + } + return out +} + +/** `key: value` lines; `~key` = disabled; `'''` opens a multi-line value. */ +function dict(block: BruBlock | undefined): Entry[] { + if (!block) return [] + const out: Entry[] = [] + const ls = block.lines + for (let i = 0; i < ls.length; i++) { + const line = ls[i].trim() + const idx = line.indexOf(":") + if (!line || idx < 0) continue + let key = line.slice(0, idx).trim() + let value = line.slice(idx + 1).trim() + if (value === "[") { // nested array (meta.tags) — skip + while (i < ls.length && ls[i].trim() !== "]") i++ + continue + } + if (value === "'''") { + const acc: string[] = [] + i++ + while (i < ls.length && ls[i].trim() !== "'''") acc.push(ls[i++].replace(/^ {4}/, "")) + value = acc.join("\n") + } + const enabled = !key.startsWith("~") + if (!enabled) key = key.slice(1) + out.push({ key, value, enabled }) + } + return out +} + +function text(block: BruBlock | undefined): string { + return block ? block.lines.map((l) => l.replace(/^ {2}/, "")).join("\n") : "" +} + +function array(block: BruBlock | undefined): Entry[] { + if (!block) return [] + return block.lines + .map((l) => l.trim().replace(/,$/, "").trim()) + .filter(Boolean) + .map((k) => ({ key: k.replace(/^~/, ""), value: "", enabled: !k.startsWith("~") })) +} + +function get(entries: Entry[], key: string): string | undefined { + return entries.find((e) => e.key === key)?.value +} + +function toKv(entries: Entry[]): KeyValueItem[] { + return entries.map((e) => ({ id: id(), key: e.key, value: e.value, active: e.enabled })) +} + +function joinScripts(...parts: Array): string | undefined { + const cleaned = parts.map((p) => (p ?? "").trim()).filter(Boolean) + return cleaned.length ? cleaned.join("\n\n") : undefined +} + +// ── Shared converters (used by both formats) ─────────────────────────────── + +function formItem(key: string, value: string, enabled: boolean): RequestFormDataItem { + const file = /^@file\(([^)]*)\)/.exec(value) + return file + ? { id: id(), key, value: "", active: enabled, valueType: "file", fileName: file[1].split("|")[0] } + : { id: id(), key, value: value.replace(/\s*@contentType\([^)]*\)\s*$/, ""), active: enabled, valueType: "text" } +} + +function grantType(raw: string | undefined): OAuth2GrantType { + return raw === "authorization_code" || raw === "password" ? raw : "client_credentials" +} + +function bruAuth(mode: string | undefined, blocks: BruBlocks): RequestAuth { + const block = (name: string) => dict(blocks.get(`auth:${name}`)) + if (mode === "basic" || (!mode && blocks.has("auth:basic"))) { + const a = block("basic") + return { type: "basic", username: get(a, "username"), password: get(a, "password") } + } + if (mode === "bearer" || (!mode && blocks.has("auth:bearer"))) { + return { type: "bearer", token: get(block("bearer"), "token") } + } + if (mode === "apikey" || (!mode && blocks.has("auth:apikey"))) { + const a = block("apikey") + return { + type: "api-key", + apiKeyKey: get(a, "key"), + apiKeyValue: get(a, "value"), + apiKeyLocation: get(a, "placement") === "queryparams" || get(a, "placement") === "query" ? "query" : "header", + } + } + if (mode === "oauth2" || (!mode && blocks.has("auth:oauth2"))) { + const a = block("oauth2") + return { + type: "oauth2", + oauth2: { + grantType: grantType(get(a, "grant_type")), + tokenUrl: get(a, "access_token_url") ?? "", + clientId: get(a, "client_id") ?? "", + clientSecret: get(a, "client_secret"), + scope: get(a, "scope"), + authUrl: get(a, "authorization_url"), + redirectUri: get(a, "callback_url"), + }, + } + } + return { type: "none" } +} + +function bruBody(mode: string | undefined, blocks: BruBlocks): RequestBody { + const has = (n: string) => blocks.has(n) + if (mode === "json" || (!mode && (has("body:json") || has("body")))) { + return { type: "json", content: text(blocks.get("body:json") ?? blocks.get("body")) } + } + if (mode === "text" || mode === "xml" || mode === "sparql" || (!mode && (has("body:text") || has("body:xml")))) { + return { type: "text", content: text(blocks.get(`body:${mode ?? (has("body:text") ? "text" : "xml")}`)) } + } + if (mode === "formUrlEncoded" || (!mode && has("body:form-urlencoded"))) { + return { type: "x-www-form-urlencoded", content: "", urlEncoded: toKv(dict(blocks.get("body:form-urlencoded"))) } + } + if (mode === "multipartForm" || (!mode && has("body:multipart-form"))) { + return { + type: "form-data", + content: "", + formData: dict(blocks.get("body:multipart-form")).map((e) => formItem(e.key, e.value, e.enabled)), + } + } + if (mode === "graphql" || (!mode && has("body:graphql"))) { + return { + type: "graphql", + content: text(blocks.get("body:graphql")), + graphqlVariables: text(blocks.get("body:graphql:vars")) || undefined, + } + } + return { type: "none", content: "" } +} + +/** Folder-level defaults shared by `folder.bru` and request files. */ +function bruDefaults(blocks: BruBlocks, authMode: string | undefined) { + return { + headers: toKv(dict(blocks.get("headers"))), + auth: bruAuth(authMode, blocks), + preRequestScript: joinScripts(text(blocks.get("script:pre-request"))), + testScript: joinScripts(text(blocks.get("script:post-response")), text(blocks.get("tests"))), + } +} + +// ── .bru request ─────────────────────────────────────────────────────────── + +export function looksLikeBrunoBru(text: string): boolean { + return /^(meta|get|post|put|patch|delete|head|options|headers|params:(query|path)|body(:[\w-]+)*|auth:\w+|vars:[\w-]+|script:[\w-]+|tests|docs)\s*[{[]\s*$/m.test(text) +} + +export function parseBruRequest(text: string): CollectionRequest { + const blocks = parseBruBlocks(text) + const meta = dict(blocks.get("meta")) + const methodName = METHODS.find((m) => blocks.has(m.toLowerCase())) + const req = dict(methodName ? blocks.get(methodName.toLowerCase()) : undefined) + const authMode = get(req, "auth") ?? get(dict(blocks.get("auth")), "mode") + const d = bruDefaults(blocks, authMode) + // ponytail: `params:path` values are dropped — our model has no path params; + // the `:id` placeholders stay in the URL for the user to fill. + return { + id: id(), + name: get(meta, "name") || methodName || "Request", + method: methodName ?? "GET", + url: get(req, "url") ?? "", + params: toKv(dict(blocks.get("params:query") ?? blocks.get("query"))), + headers: d.headers, + body: bruBody(get(req, "body"), blocks), + auth: d.auth, + preRequestScript: d.preRequestScript, + testScript: d.testScript, + } +} + +export function parseBrunoEnvironment(text: string, name: string): Environment { + const blocks = parseBruBlocks(text) + const toVar = (e: Entry): EnvironmentVariable => ({ id: id(), key: e.key, value: e.value, enabled: e.enabled }) + return { + id: id(), + name, + variables: [...dict(blocks.get("vars")), ...array(blocks.get("vars:secret"))].map(toVar), + } +} + +// ── OpenCollection YAML ──────────────────────────────────────────────────── + +type Yaml = Record +const obj = (v: unknown): Yaml => (v && typeof v === "object" && !Array.isArray(v) ? (v as Yaml) : {}) +const str = (v: unknown): string => (v == null ? "" : typeof v === "string" ? v : typeof v === "object" ? str(obj(v).data) : String(v)) +const list = (v: unknown): Yaml[] => (Array.isArray(v) ? v.map(obj) : []) + +export function looksLikeOpenCollectionYaml(text: string): boolean { + return /^(http|graphql):\s*$/m.test(text) && /^\s+(method|url):/m.test(text) +} + +function ocKv(items: unknown): KeyValueItem[] { + return list(items).map((e) => ({ id: id(), key: str(e.name), value: str(e.value), active: e.disabled !== true })) +} + +function ocAuth(a: unknown): RequestAuth { + const y = obj(a) + const type = str(y.type) + if (type === "basic") return { type: "basic", username: str(y.username), password: str(y.password) } + if (type === "bearer") return { type: "bearer", token: str(y.token) } + if (type === "apikey") { + return { + type: "api-key", + apiKeyKey: str(y.key), + apiKeyValue: str(y.value), + apiKeyLocation: str(y.placement) === "query" ? "query" : "header", + } + } + if (type === "oauth2") { + return { + type: "oauth2", + oauth2: { + grantType: grantType(str(y.grantType)), + tokenUrl: str(y.accessTokenUrl), + clientId: str(y.clientId), + clientSecret: str(y.clientSecret) || undefined, + scope: str(y.scope) || undefined, + authUrl: str(y.authorizationUrl) || undefined, + redirectUri: str(y.callbackUrl) || undefined, + }, + } + } + return { type: "none" } +} + +function ocBody(b: unknown): RequestBody { + const y = obj(b) + if (y.query != null) return { type: "graphql", content: str(y.query), graphqlVariables: str(y.variables) || undefined } + const type = str(y.type).toLowerCase().replace(/[^a-z]/g, "") + if (type === "json") return { type: "json", content: str(y.data) } + if (type === "text" || type === "xml" || type === "sparql") return { type: "text", content: str(y.data) } + if (type === "formurlencoded") return { type: "x-www-form-urlencoded", content: "", urlEncoded: ocKv(y.data) } + if (type === "multipartform") { + return { + type: "form-data", + content: "", + formData: list(y.data).map((e) => + str(e.type) === "file" || Array.isArray(e.value) + ? formItem(str(e.name), `@file(${Array.isArray(e.value) ? str(e.value[0]) : str(e.value)})`, e.disabled !== true) + : formItem(str(e.name), str(e.value), e.disabled !== true)), + } + } + return { type: "none", content: "" } +} + +function ocScripts(scripts: unknown, tests: unknown): { pre?: string; test?: string } { + const y = obj(scripts) + const byType = (t: string) => list(scripts).find((s) => str(s.type) === t)?.code + return { + pre: joinScripts(str(byType("before-request") ?? y.preRequest)), + test: joinScripts(str(byType("after-response") ?? y.postResponse), str(byType("tests") ?? y.tests ?? tests)), + } +} + +/** Shared by request.yml and folder.yml: the `http`/`graphql` block (or root) carries headers + auth. */ +function ocDefaults(doc: Yaml) { + const block = obj(doc.http ?? doc.graphql ?? doc.request) + const runtime = obj(doc.runtime) + const s = ocScripts(runtime.scripts ?? doc.scripts, doc.tests) + return { + block, + headers: ocKv(block.headers ?? doc.headers), + auth: ocAuth(block.auth ?? doc.auth), + preRequestScript: s.pre, + testScript: s.test, + } +} + +export function parseOpenCollectionRequestYaml(text: string): CollectionRequest { + const doc = obj(load(text)) + const info = obj(doc.info) + const d = ocDefaults(doc) + const method = str(d.block.method ?? doc.method).toUpperCase() as RequestMethod + return { + id: id(), + name: str(info.name ?? doc.name) || "Request", + method: METHODS.includes(method) ? method : "GET", + url: str(d.block.url ?? doc.url), + params: ocKv(d.block.params ?? doc.params).filter((_, i) => str(list(d.block.params ?? doc.params)[i].type) !== "path"), + headers: d.headers, + body: ocBody(d.block.body ?? doc.body), + auth: d.auth, + preRequestScript: d.preRequestScript, + testScript: d.testScript, + } +} + +function parseOpenCollectionEnvironment(text: string, fallbackName: string): Environment { + const doc = obj(load(text)) + return { + id: id(), + name: str(doc.name) || fallbackName, + variables: list(doc.variables).map((v) => ({ + id: id(), + key: str(v.name), + value: v.secret === true ? "" : str(v.value), + enabled: v.disabled !== true, + })), + } +} + +// ── Folder import ────────────────────────────────────────────────────────── + +export interface BrunoFile { path: string; text: string } +export interface BrunoImportResult { collection: Collection; environments: Environment[] } + +interface Node { + name: string + seq?: number + order: number + item: CollectionFolder | CollectionRequest +} + +const basename = (p: string) => p.slice(p.lastIndexOf("/") + 1) +const dirname = (p: string) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "") +const stripExt = (p: string) => p.replace(/\.(bru|ya?ml)$/, "") + +export function importBrunoFolder(input: BrunoFile[]): BrunoImportResult { + const files = input.map((f) => ({ ...f, path: f.path.replace(/\\/g, "/").replace(/^\.?\//, "") })) + // Root = wherever the manifest lives (tolerates a zip with a wrapping directory). + const manifest = files.find((f) => /(^|\/)(bruno\.json|opencollection\.yml)$/.test(f.path)) + const root = manifest ? dirname(manifest.path) : "" + const rel = (p: string) => (root ? p.slice(root.length + 1) : p) + const inRoot = files.filter((f) => !root || f.path.startsWith(root + "/")) + + let name = "Bruno collection" + if (manifest?.path.endsWith(".json")) { + try { name = str(obj(JSON.parse(manifest.text)).name) || name } catch { /* keep default */ } + } else if (manifest) { + const doc = obj(load(manifest.text)) + name = str(obj(doc.info).name ?? doc.name) || name + } + + const environments: Environment[] = [] + const folders = new Map() // dir path → folder node + const children = new Map() // dir path → nodes + const push = (dir: string, node: Node) => { + if (!children.has(dir)) children.set(dir, []) + children.get(dir)!.push(node) + } + const folderNode = (dir: string): Node => { + let node = folders.get(dir) + if (!node) { + node = { + name: basename(dir), + order: Number.MAX_SAFE_INTEGER, // folders without seq sort after seq'd siblings, by path + item: { id: id(), name: basename(dir), type: "folder", items: [], isOpen: false }, + } + folders.set(dir, node) + if (dir) push(dirname(dir), node) + } + return node + } + + inRoot.forEach((file, order) => { + const p = rel(file.path) + const base = basename(p) + const dir = dirname(p) + if (p.startsWith("environments/")) { + if (base.endsWith(".bru")) environments.push(parseBrunoEnvironment(file.text, stripExt(base))) + else if (/\.ya?ml$/.test(base)) environments.push(parseOpenCollectionEnvironment(file.text, stripExt(base))) + return + } + if (base === "folder.bru" || base === "folder.yml") { + const node = folderNode(dir) + const folder = node.item as CollectionFolder + let d: ReturnType | ReturnType + let meta: Yaml + if (base === "folder.bru") { + const blocks = parseBruBlocks(file.text) + meta = Object.fromEntries(dict(blocks.get("meta")).map((e) => [e.key, e.value])) + d = bruDefaults(blocks, get(dict(blocks.get("auth")), "mode")) + } else { + const doc = obj(load(file.text)) + meta = obj(doc.info ?? doc) + d = ocDefaults(doc) + } + node.name = folder.name = str(meta.name) || folder.name + if (meta.seq != null && meta.seq !== "") node.seq = Number(meta.seq) + if (d.headers.length) folder.defaultHeaders = d.headers + if (d.auth.type !== "none") folder.defaultAuth = d.auth + folder.preRequestScript = d.preRequestScript + folder.testScript = d.testScript + return + } + if (base === "bruno.json" || base === "collection.bru" || base === "opencollection.yml") return + let request: CollectionRequest + let seq: string | undefined + if (base.endsWith(".bru")) { + const blocks = parseBruBlocks(file.text) + const meta = dict(blocks.get("meta")) + const type = get(meta, "type") + if (type && type !== "http" && type !== "graphql") return + request = parseBruRequest(file.text) + seq = get(meta, "seq") + } else if (/\.ya?ml$/.test(base) && looksLikeOpenCollectionYaml(file.text)) { + request = parseOpenCollectionRequestYaml(file.text) + seq = str(obj(obj(load(file.text)).info).seq) || undefined + } else { + return + } + folderNode(dir) + push(dir, { name: request.name, seq: seq ? Number(seq) : undefined, order, item: request }) + }) + + const build = (dir: string): Array => + (children.get(dir) ?? []) + .sort((a, b) => (a.seq ?? Infinity) - (b.seq ?? Infinity) || a.order - b.order) + .map((n) => { + if ("type" in n.item && n.item.type === "folder") n.item.items = build([...folders].find(([, v]) => v === n)![0]) + return n.item + }) + + return { collection: { id: id(), name, items: build("") }, environments } +} diff --git a/apps/desktop-ui/src/lib/import/detect.ts b/apps/desktop-ui/src/lib/import/detect.ts index c58e1cc7..3b7e4e02 100644 --- a/apps/desktop-ui/src/lib/import/detect.ts +++ b/apps/desktop-ui/src/lib/import/detect.ts @@ -1,4 +1,8 @@ -export type ImportFormat = "postman" | "postman-env" | "har" | "openapi" | "curl" | "unknown" +import { looksLikeInsomniaExport } from "./insomnia" +import { looksLikeBrunoBru, looksLikeOpenCollectionYaml } from "./bruno" + +export type ImportFormat = + | "postman" | "postman-env" | "har" | "openapi" | "insomnia" | "bruno" | "curl" | "unknown" interface ParsedRoot { info?: { schema?: string } @@ -22,6 +26,7 @@ export function detectImportFormat(raw: string): ImportFormat { if (t.startsWith("{") || t.startsWith("[")) { try { const data = JSON.parse(t) as ParsedRoot + if (looksLikeInsomniaExport(t)) return "insomnia" if (data?.info?.schema?.includes("schema.getpostman.com")) return "postman" if (data?.info && Array.isArray(data.item)) return "postman" // Postman environment: `{ name, values: [{ key, value }] }`, no `info`. @@ -40,5 +45,9 @@ export function detectImportFormat(raw: string): ImportFormat { if (/^openapi\s*:/m.test(head)) return "openapi" if (/^swagger\s*:/m.test(head)) return "openapi" + // Bruno's `.bru` language and Insomnia/Bruno YAML exports. + if (looksLikeInsomniaExport(t)) return "insomnia" + if (looksLikeBrunoBru(t) || looksLikeOpenCollectionYaml(t)) return "bruno" + return "unknown" } diff --git a/apps/desktop-ui/src/lib/import/insomnia.ts b/apps/desktop-ui/src/lib/import/insomnia.ts new file mode 100644 index 00000000..392ed89e --- /dev/null +++ b/apps/desktop-ui/src/lib/import/insomnia.ts @@ -0,0 +1,300 @@ +/** + * Insomnia import — v4 JSON (`_type: "export"`, `resources[]`) and v5 YAML + * (`type: collection.insomnia.rest/5.0` / `environment.insomnia.rest/5.0`). + * Round-trips `lib/export/insomnia.ts`. Template tags `{{ _.VAR }}` become + * our `{{VAR}}`; `insomnia.*` scripts are copied verbatim (no translation). + */ + +import yaml from "js-yaml" + +import type { + Collection, + CollectionFolder, + CollectionRequest, + KeyValueItem, + RequestAuth, + RequestBody, + RequestFormDataItem, + RequestMethod, +} from "@/components/api-client/types" +import type { Environment, EnvironmentVariable } from "@/components/api-client/use-environments" + +interface InsKV { name?: string; value?: string; disabled?: boolean; type?: string; fileName?: string } +interface InsBody { mimeType?: string; text?: string; params?: InsKV[] } +interface InsAuth { type?: string; disabled?: boolean; [k: string]: unknown } + +/** Fields shared by v4 `request`/`request_group` resources and v5 tree items. */ +interface InsNode { + name?: string + method?: string + url?: string + parameters?: InsKV[] + headers?: InsKV[] + body?: InsBody + authentication?: InsAuth + preRequestScript?: string // v4 + afterResponseScript?: string + scripts?: { preRequest?: string; afterResponse?: string } // v5 +} + +interface InsV4Resource extends InsNode { + _id: string + _type: string + parentId?: string | null + metaSortKey?: number + data?: Record +} + +interface InsV5Item extends InsNode { + children?: InsV5Item[] + meta?: { sortKey?: number } +} + +interface InsV5Env { name?: string; data?: Record; subEnvironments?: InsV5Env[] } + +interface InsV5Doc { + type?: string + name?: string + collection?: InsV5Item[] + environments?: InsV5Env | InsV5Env[] + /** `environment.insomnia.rest/5.0` files carry the variables at the root. */ + data?: Record +} + +export interface InsomniaImportResult { + collection: Collection + environments: Environment[] +} + +const VALID_METHODS = new Set([ + "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", +]) + +function id(): string { return crypto.randomUUID() } + +/** `{{ _.base_url }}` / `{{ base_url }}` → `{{base_url}}`. `{% tags %}` are left alone. */ +function tpl(v: unknown): string { + return typeof v === "string" ? v.replace(/\{\{\s*(?:_\.)?([^{}\s]+)\s*\}\}/g, "{{$1}}") : "" +} + +function opt(v: unknown): string | undefined { + return typeof v === "string" ? tpl(v) : undefined +} + +function kvList(items: InsKV[] | undefined): KeyValueItem[] { + return (items ?? []) + .filter((kv) => kv && (kv.name || kv.value)) + .map((kv) => ({ id: id(), key: tpl(kv.name), value: tpl(kv.value), active: !kv.disabled })) +} + +function convertBody(b: InsBody | undefined): RequestBody { + const mime = (b?.mimeType ?? "").toLowerCase() + const text = tpl(b?.text) + if (mime === "application/x-www-form-urlencoded") { + // Our exporter writes the encoded string; Insomnia writes params[]. + const urlEncoded = b?.params + ? kvList(b.params) + : [...new URLSearchParams(text)].map(([key, value]) => ({ id: id(), key, value, active: true })) + return { type: "x-www-form-urlencoded", content: "", urlEncoded } + } + if (mime === "multipart/form-data") { + const formData: RequestFormDataItem[] = (b?.params ?? []) + .filter((p) => p && (p.name || p.value || p.fileName)) + .map((p) => p.type === "file" + ? { id: id(), key: tpl(p.name), value: "", active: !p.disabled, valueType: "file", fileName: p.fileName?.split(/[\\/]/).pop() || undefined } + : { id: id(), key: tpl(p.name), value: tpl(p.value), active: !p.disabled, valueType: "text" }) + return { type: "form-data", content: "", formData } + } + if (mime === "application/graphql") { + try { + const g = JSON.parse(b?.text ?? "") as { query?: unknown; variables?: unknown } + const vars = g.variables + return { + type: "graphql", + content: tpl(g.query), + graphqlVariables: vars == null || vars === "" + ? undefined + : typeof vars === "string" ? tpl(vars) : JSON.stringify(vars, null, 2), + } + } catch { + return { type: "graphql", content: text } + } + } + if (mime === "application/json") return { type: "json", content: text } + if (!mime && !text) return { type: "none", content: "" } + // text/plain, application/xml, anything else → raw text. + return { type: "text", content: text } +} + +function convertAuth(a: InsAuth | undefined): RequestAuth { + if (!a || a.disabled || !a.type) return { type: "none" } + switch (a.type) { + case "basic": + return { type: "basic", username: opt(a.username), password: opt(a.password) } + case "bearer": + // ponytail: Insomnia's custom `prefix` is dropped — we always send "Bearer". + return { type: "bearer", token: opt(a.token) } + case "apikey": + return { + type: "api-key", + apiKeyKey: opt(a.key), + apiKeyValue: opt(a.value), + apiKeyLocation: a.addTo === "queryParams" ? "query" : "header", + } + case "oauth2": + return { + type: "oauth2", + oauth2: { + grantType: a.grantType === "authorization_code" ? "authorization_code" : "client_credentials", + tokenUrl: opt(a.accessTokenUrl) ?? "", + clientId: opt(a.clientId) ?? "", + clientSecret: opt(a.clientSecret), + scope: opt(a.scope), + audience: opt(a.audience), + authUrl: opt(a.authorizationUrl), + redirectUri: opt(a.redirectUrl), + }, + } + default: + return { type: "none" } + } +} + +function scripts(n: InsNode): { preRequestScript?: string; testScript?: string } { + return { + preRequestScript: (n.preRequestScript ?? n.scripts?.preRequest) || undefined, + testScript: (n.afterResponseScript ?? n.scripts?.afterResponse) || undefined, + } +} + +function convertRequest(r: InsNode): CollectionRequest { + const m = (r.method ?? "GET").toUpperCase() as RequestMethod + const method = VALID_METHODS.has(m) ? m : "GET" + return { + id: id(), + name: r.name || method, + method, + url: tpl(r.url), + params: kvList(r.parameters), + headers: kvList(r.headers), + body: convertBody(r.body), + auth: convertAuth(r.authentication), + ...scripts(r), + } +} + +function convertFolder(n: InsNode, items: CollectionFolder["items"]): CollectionFolder { + const defaultAuth = convertAuth(n.authentication) + const defaultHeaders = kvList(n.headers) + return { + id: id(), + name: n.name || "Folder", + type: "folder", + items, + isOpen: false, + defaultAuth: defaultAuth.type === "none" ? undefined : defaultAuth, + defaultHeaders: defaultHeaders.length ? defaultHeaders : undefined, + ...scripts(n), + } +} + +/** Nested env objects become dotted keys; non-string leaves are JSON-encoded. */ +function flatten(data: unknown, prefix: string, out: EnvironmentVariable[]): void { + if (!data || typeof data !== "object" || Array.isArray(data)) return + for (const [k, v] of Object.entries(data)) { + const key = prefix ? `${prefix}.${k}` : k + if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out) + else out.push({ id: id(), key, value: typeof v === "string" ? tpl(v) : v == null ? "" : JSON.stringify(v), enabled: true }) + } +} + +function toEnvironment(name: string | undefined, data: unknown): Environment | null { + const variables: EnvironmentVariable[] = [] + flatten(data, "", variables) + return variables.length ? { id: id(), name: name || "Insomnia environment", variables } : null +} + +function importV4(resources: InsV4Resource[]): InsomniaImportResult { + const res = resources.filter((r) => r && typeof r === "object") + const bySort = (a: InsV4Resource, b: InsV4Resource) => (a.metaSortKey ?? 0) - (b.metaSortKey ?? 0) + const ROOT = "__root__" + const groupIds = new Set(res.filter((r) => r._type === "request_group").map((r) => r._id)) + const children = new Map() + for (const r of res) { + if (r._type !== "request" && r._type !== "request_group") continue + // ponytail: everything not under a known group (workspace children, orphans, + // extra workspaces) lands at the root. + const parent = r.parentId && groupIds.has(r.parentId) ? r.parentId : ROOT + children.set(parent, [...(children.get(parent) ?? []), r]) + } + const walk = (parent: string): CollectionFolder["items"] => + (children.get(parent) ?? []).sort(bySort).map((r) => + r._type === "request_group" ? convertFolder(r, walk(r._id)) : convertRequest(r)) + + const workspace = res.find((r) => r._type === "workspace") + const environments = res + .filter((r) => r._type === "environment") + .sort(bySort) + .map((r) => toEnvironment(r.name, r.data)) + .filter((e): e is Environment => e !== null) + + return { + collection: { id: id(), name: workspace?.name || "Imported Insomnia collection", items: walk(ROOT) }, + environments, + } +} + +function importV5(doc: InsV5Doc): InsomniaImportResult { + const walk = (items: InsV5Item[] | undefined): CollectionFolder["items"] => + (items ?? []) + .filter((it) => it && typeof it === "object") + .sort((a, b) => (a.meta?.sortKey ?? 0) - (b.meta?.sortKey ?? 0)) + .map((it) => it.children || (!it.url && !it.method) + ? convertFolder(it, walk(it.children)) + : convertRequest(it)) + + const environments: Environment[] = [] + const collect = (e: InsV5Env | undefined): void => { + if (!e || typeof e !== "object") return + const env = toEnvironment(e.name, e.data) + if (env) environments.push(env) + for (const sub of e.subEnvironments ?? []) collect(sub) + } + if (Array.isArray(doc.environments)) doc.environments.forEach(collect) + else collect(doc.environments) + if (doc.data) collect({ name: doc.name, data: doc.data }) + + return { + collection: { id: id(), name: doc.name || "Imported Insomnia collection", items: walk(doc.collection) }, + environments, + } +} + +export function looksLikeInsomniaExport(text: string): boolean { + const t = text.trim() + if (t.startsWith("{")) { + try { + const d = JSON.parse(t) as { _type?: unknown; resources?: unknown } + return d?._type === "export" && Array.isArray(d.resources) + } catch { + return false + } + } + return /^type:\s*["']?(collection|environment)\.insomnia\.rest\/5\.0/m.test(t) + || /^_type:\s*["']?export\b/m.test(t) +} + +export function importInsomniaWithMeta(text: string): InsomniaImportResult { + const t = text.trim() + // yaml.load also parses JSON, so v4 YAML exports come for free. + const doc = (t.startsWith("{") ? JSON.parse(t) : yaml.load(t)) as + { _type?: string; resources?: InsV4Resource[] } & InsV5Doc + if (!doc || typeof doc !== "object") throw new Error("Not an Insomnia export") + if (doc._type === "export" && Array.isArray(doc.resources)) return importV4(doc.resources) + if (/insomnia\.rest\/5\.0$/.test(String(doc.type))) return importV5(doc) + throw new Error("Not an Insomnia export (expected v4 `_type: export` or v5 `type: *.insomnia.rest/5.0`)") +} + +export function importInsomnia(text: string): Collection { + return importInsomniaWithMeta(text).collection +}