feat(INS-3641): add Export OpenAPI Spec to the document context menu (#10538)

## Summary

Adds an **Export OpenAPI Spec** item right under the existing **Export** option in the sidebar workspace dropdown — the context menu for an API collection (opened via the ellipsis trigger button or by right-clicking the workspace row).

Selecting it prompts for **YAML or JSON**, then a save location, and writes the spec file — the same flow as the existing Insomnia/HAR exporters (format modal → native save dialog → `writeFile`), reusing `showSaveExportedFileDialog` / `writeExportedFileToFileSystem` so the last export path is remembered.

Closes INS-3641

## Behavior details

- The item shows for collection/design scope workspaces. If the spec is empty or missing, clicking it shows a `Cannot export` alert explaining there is nothing to export, rather than writing an empty file.
- Same serialization as inso: matching format passes the contents through untouched; cross-format converts via `YAML.parse` + `JSON.stringify(…, 2)` / `YAML.stringify`, with an error modal if the spec is invalid.
- Records `dataExport` telemetry like every sibling exporter in the file.
- Spec format detection/conversion are shared with the Spec editor toolbar via new `detectApiSpecSyntax` / `convertApiSpecSyntax` helpers in `common/api-specs` (the toolbar previously inlined the same JSON-parse probe).
This commit is contained in:
Bingbing authored and GitHub committed 2026-09-23 04:00:08 +00:00
1 parent b353ed3f44
commit 2d56f154d5
12 files changed
+389 -17

No files matched your search

+3
View File
@@ -20,3 +20,6 @@ fixtures/inso-nedb/insomnia.OAuth2Token.db
fixtures/inso-nedb/insomnia.PluginData.db
fixtures/inso-nedb/insomnia.CaCertificate.db
fixtures/inso-nedb/insomnia.CloudCredential.db
# Playwright junit/xml run artifacts
test-results.xml
@@ -11,7 +11,7 @@ export class ExportModal {
* Handles the export type selection modal (Insomnia v5 or HAR).
* @param format - The format to select ('yaml' for Insomnia v5, 'har' for HAR)
*/
async selectExportFormat(format: 'yaml' | 'har'): Promise<void> {
async selectExportFormat(format: 'yaml' | 'har' | 'json'): Promise<void> {
await this.page.getByText('Which format would you like to export as?').waitFor({ state: 'visible' });
// The modal uses a <select> element, so we need to use selectOption
@@ -145,7 +145,7 @@ export class NavigationSidebar {
workspaceName: string;
}): Promise<void> {
await this.openWorkspaceActionsDropdown(workspaceName);
await this.page.getByRole('menuitemradio', { name: actionName }).click();
await this.page.getByRole('menuitemradio', { name: actionName, exact: true }).click();
}
async expandWorkspace(workspaceName: string): Promise<void> {
@@ -363,7 +363,7 @@ export class ProjectPage extends BasePage {
await this.workspaceList.openWorkspaceCardDropdown(workspaceName);
// Click Export option
await this.page.getByRole('menuitem', { name: 'Export' }).click();
await this.page.getByRole('menuitem', { name: 'Export', exact: true }).click();
// Click Export button in the export requests modal (all requests selected by default)
await this.page.getByRole('dialog').getByRole('button', { name: 'Export' }).click();
@@ -12,7 +12,7 @@ test('can send requests', async ({ page, insomnia }) => {
await insomnia.projectPage.importFixture('smoke-test-collection.yaml');
await insomnia.navigationSidebar.openWorkspaceActionsDropdown('Smoke tests');
await page.getByRole('menuitemradio', { name: 'Export' }).click();
await page.getByRole('menuitemradio', { name: 'Export', exact: true }).click();
await page.getByRole('button', { name: 'Export' }).click();
await page.getByText('Which format would you like to export as?').click();
await insomnia.pressEscape();
@@ -0,0 +1,120 @@
import path from 'node:path';
import { expect, type Page } from '@playwright/test';
import { test } from '../../playwright/test';
import {
cleanupExportDir,
createTempExportDir,
mockSaveDialogForFile,
readExportedFile,
waitForExportFiles,
} from '../../playwright/utils';
test.describe('Export OpenAPI Spec', () => {
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
// Creates an API collection and fills its spec with the Pet Store example
const createApiCollectionWithPetStoreSpec = async (page: Page) => {
await page.getByRole('button', { name: 'Create document' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Create' }).click();
await page.click('text=Use example');
await page.click('text=Pet Store');
await expect.soft(page.locator('.pane-one').getByTestId('CodeEditor')).toContainText('openapi: 3.0.4');
};
test('exports the spec of an API collection as YAML from the sidebar dropdown', async ({ page, app, insomnia }) => {
await createApiCollectionWithPetStoreSpec(page);
const tempDir = createTempExportDir();
try {
const exportPath = path.join(tempDir, 'spec-export.yaml');
await mockSaveDialogForFile(app, exportPath);
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Export OpenAPI Spec',
workspaceName: 'My API Collection',
});
// The format modal defaults to YAML
await insomnia.exportModal.selectExportFormat('yaml');
await waitForExportFiles(tempDir, 1);
const contents = readExportedFile(exportPath);
expect.soft(contents).toContain('openapi: 3.0.4');
expect.soft(contents).toContain('Pet Store');
} finally {
cleanupExportDir(tempDir);
}
});
test('exports the spec of an API collection as JSON from the sidebar dropdown', async ({ page, app, insomnia }) => {
await createApiCollectionWithPetStoreSpec(page);
const tempDir = createTempExportDir();
try {
const exportPath = path.join(tempDir, 'spec-export.json');
await mockSaveDialogForFile(app, exportPath);
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Export OpenAPI Spec',
workspaceName: 'My API Collection',
});
await insomnia.exportModal.selectExportFormat('json');
await waitForExportFiles(tempDir, 1);
const contents = JSON.parse(readExportedFile(exportPath));
expect.soft(contents).toMatchObject({ openapi: '3.0.4' });
expect.soft(contents.info.title).toContain('Pet');
} finally {
cleanupExportDir(tempDir);
}
});
test('offers Export OpenAPI Spec when the API collection is opened via right-click', async ({ page, insomnia }) => {
await createApiCollectionWithPetStoreSpec(page);
// Right-click opens the dropdown via the controlled isOpen prop, not the trigger
await insomnia.navigationSidebar.workspaceRow('My API Collection').click({ button: 'right' });
await expect.soft(page.getByRole('menuitemradio', { name: 'Export OpenAPI Spec' })).toBeVisible();
await page.keyboard.press('Escape');
});
test('prompts an error while the spec is empty, then exports once it is filled in', async ({
page,
app,
insomnia,
}) => {
await page.getByRole('button', { name: 'Create document' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Create' }).click();
// Empty spec: the item is offered, but clicking it explains there is nothing to export
// instead of writing an empty file.
await insomnia.navigationSidebar.openWorkspaceActionsDropdown('My API Collection');
await page.getByRole('menuitemradio', { name: 'Export OpenAPI Spec' }).click();
await expect.soft(page.getByText('does not contain an OpenAPI specification to export')).toBeVisible();
await page.getByRole('button', { name: 'Modal Close Button' }).click();
// Fill the spec in: the same entry point now exports the file
const tempDir = createTempExportDir();
try {
const exportPath = path.join(tempDir, 'spec-export.yaml');
await mockSaveDialogForFile(app, exportPath);
await page.click('text=Use example');
await page.click('text=Pet Store');
await expect.soft(page.locator('.pane-one').getByTestId('CodeEditor')).toContainText('openapi: 3.0.4');
await insomnia.navigationSidebar.selectWorkspaceDropdownOption({
actionName: 'Export OpenAPI Spec',
workspaceName: 'My API Collection',
});
await insomnia.exportModal.selectExportFormat('yaml');
await waitForExportFiles(tempDir, 1);
expect.soft(readExportedFile(exportPath)).toContain('openapi: 3.0.4');
} finally {
cleanupExportDir(tempDir);
}
});
});
@@ -26,7 +26,7 @@ test.describe('test hidden window handling', () => {
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
await insomnia.navigationSidebar.openWorkspaceActionsDropdown('Pre-request Scripts');
await page.getByRole('menuitemradio', { name: 'Export' }).click();
await page.getByRole('menuitemradio', { name: 'Export', exact: true }).click();
await page.getByRole('button', { name: 'Export' }).click();
await page.getByText('Which format would you like to export as?').click();
await page.locator('.app').press('Escape');
+23
View File
@@ -39,6 +39,29 @@ export function parseApiSpec(rawDocument: string) {
return result;
}
/**
* Detects the serialization syntax of an API spec by probing it with JSON.parse.
* JSON is valid YAML, so anything that fails to parse as JSON is treated as YAML.
*/
export function detectApiSpecSyntax(contents: string): 'json' | 'yaml' {
try {
JSON.parse(contents);
return 'json';
} catch {
return 'yaml';
}
}
/**
* Re-serializes an API spec into the requested syntax. Throws if the spec cannot
* be parsed (invalid YAML or JSON).
*/
export function convertApiSpecSyntax(contents: string, to: 'json' | 'yaml'): string {
// YAML parses JSON as well
const parsed = YAML.parse(contents);
return to === 'json' ? JSON.stringify(parsed, null, 2) : YAML.stringify(parsed);
}
export function resolveComponentSchemaRefs(spec: ParsedApiSpec, methodInfo: Record<string, any>) {
const schemas = spec.contents?.components?.schemas;
if (!schemas) {
@@ -4,6 +4,7 @@ import {
exportGlobalEnvironmentToFile,
exportMcpClientToFile,
exportMockServerToFile,
exportSpecificationToFile,
} from 'insomnia/src/ui/components/settings/import-export';
import type { MockServer, Project, Workspace } from 'insomnia-data';
import { models, services } from 'insomnia-data';
@@ -312,6 +313,16 @@ export const SidebarWorkspaceDropdown = ({
return setIsExportModalOpen(true);
},
},
...(isCollectionLike
? [
{
id: 'ExportOpenApiSpec',
name: 'Export OpenAPI Spec',
icon: 'file-code' as IconName,
action: () => exportSpecificationToFile(workspace),
},
]
: []),
{
id: 'Settings',
name: 'Settings',
@@ -0,0 +1,146 @@
// @vitest-environment jsdom
import { AnalyticsEvent } from 'insomnia/src/ui/analytics';
import { showError, showModal } from 'insomnia/src/ui/components/modals';
import { exportSpecificationToFile } from 'insomnia/src/ui/components/settings/import-export';
import { services } from 'insomnia-data';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('~/root', () => ({
useRootLoaderData: () => ({}),
}));
vi.mock('insomnia/src/ui/components/modals', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
showModal: vi.fn(),
showError: vi.fn(),
};
});
const YAML_SPEC = ['openapi: 3.0.0', 'info:', ' title: Pet Store', ' version: 1.0.0', 'paths: {}'].join('\n');
const JSON_SPEC = JSON.stringify({ openapi: '3.0.0', info: { title: 'Pet Store', version: '1.0.0' }, paths: {} });
const mockShowModal = vi.mocked(showModal);
const mockShowError = vi.mocked(showError);
const mockShowSaveDialog = vi.fn();
const mockWriteFile = vi.fn();
const mockTrackAnalyticsEvent = vi.fn();
/**
* Drives the SelectModal shown by exportSpecificationToFile() as if the user had
* picked a format and pressed Done. The modal host is mocked, so we invoke the
* onDone callback directly.
*/
const selectExportFormat = async (format: 'json' | 'yaml') => {
const selectModalCall = mockShowModal.mock.calls.find(
([, options]) => typeof (options as { onDone?: unknown })?.onDone === 'function',
);
expect(selectModalCall, 'expected a format selection modal to be shown').toBeTruthy();
const [, options] = selectModalCall! as unknown as [{ name?: string }, { onDone: (format: string | null) => Promise<void> }];
mockShowModal.mockClear();
await options.onDone(format);
};
const writtenFiles = () => mockWriteFile.mock.calls.map(([{ path, content }]) => ({ path, content }));
describe('exportSpecificationToFile()', () => {
beforeEach(() => {
vi.clearAllMocks();
Object.assign(window, {
dialog: { showSaveDialog: mockShowSaveDialog },
main: { writeFile: mockWriteFile, trackAnalyticsEvent: mockTrackAnalyticsEvent },
app: { getPath: () => '/desktop' },
path: {
join: (...args: string[]) => args.join('/'),
dirname: (path: string) => path.slice(0, path.lastIndexOf('/')),
},
});
mockShowSaveDialog.mockResolvedValue({ filePath: '/tmp/export/out.yaml', canceled: false });
window.localStorage.clear();
});
it('shows an error modal when the API collection has no specification', async () => {
const workspace = await services.workspace.create({ name: 'Empty API Collection', scope: 'collection' });
await exportSpecificationToFile(workspace);
expect(mockShowModal).toHaveBeenCalledTimes(1);
let [, options] = mockShowModal.mock.calls[0] as unknown as [{ name?: string }, { title: string }];
expect(options.title).toBe('Cannot export');
// Same guard covers a spec record whose contents were cleared
mockShowModal.mockClear();
await services.apiSpec.updateOrCreateForParentId(workspace._id, { contents: '', contentType: 'yaml' });
await exportSpecificationToFile(workspace);
expect(mockShowModal).toHaveBeenCalledTimes(1);
[, options] = mockShowModal.mock.calls[0] as unknown as [{ name?: string }, { title: string }];
expect(options.title).toBe('Cannot export');
});
it('writes the spec unchanged when the requested format matches the source format', async () => {
const workspace = await services.workspace.create({ name: 'YAML API Collection', scope: 'collection' });
await services.apiSpec.updateOrCreateForParentId(workspace._id, { contents: YAML_SPEC, contentType: 'yaml' });
await exportSpecificationToFile(workspace);
await selectExportFormat('yaml');
expect(mockShowError).not.toHaveBeenCalled();
expect(writtenFiles()).toEqual([{ path: '/tmp/export/out.yaml', content: YAML_SPEC }]);
expect(mockTrackAnalyticsEvent).toHaveBeenCalledWith({
event: AnalyticsEvent.dataExport,
properties: { type: 'yaml', scope: 'collection' },
});
});
it('converts a YAML spec to JSON when requested', async () => {
const workspace = await services.workspace.create({ name: 'Converted API Collection', scope: 'collection' });
await services.apiSpec.updateOrCreateForParentId(workspace._id, { contents: YAML_SPEC, contentType: 'yaml' });
await exportSpecificationToFile(workspace);
await selectExportFormat('json');
expect(mockShowError).not.toHaveBeenCalled();
const [{ path, content }] = writtenFiles();
expect(path).toBe('/tmp/export/out.yaml');
expect(JSON.parse(content)).toMatchObject({ openapi: '3.0.0', info: { title: 'Pet Store' } });
});
it('converts a JSON spec to YAML when requested', async () => {
const workspace = await services.workspace.create({ name: 'JSON API Collection', scope: 'collection' });
await services.apiSpec.updateOrCreateForParentId(workspace._id, { contents: JSON_SPEC, contentType: 'json' });
await exportSpecificationToFile(workspace);
await selectExportFormat('yaml');
expect(mockShowError).not.toHaveBeenCalled();
const [{ content }] = writtenFiles();
expect(content).toContain('openapi: 3.0.0');
expect(content).toContain('title: Pet Store');
});
it('reports an error instead of writing a file when the spec cannot be converted', async () => {
const workspace = await services.workspace.create({ name: 'Invalid API Collection', scope: 'collection' });
await services.apiSpec.updateOrCreateForParentId(workspace._id, {
contents: 'openapi: [unclosed',
contentType: 'yaml',
});
await exportSpecificationToFile(workspace);
await selectExportFormat('json');
expect(mockShowError).toHaveBeenCalledTimes(1);
expect(mockWriteFile).not.toHaveBeenCalled();
});
it('does not write a file when the save dialog is cancelled', async () => {
const workspace = await services.workspace.create({ name: 'Cancelled API Collection', scope: 'collection' });
await services.apiSpec.updateOrCreateForParentId(workspace._id, { contents: YAML_SPEC, contentType: 'yaml' });
mockShowSaveDialog.mockResolvedValue({ filePath: undefined, canceled: true });
await exportSpecificationToFile(workspace);
await selectExportFormat('yaml');
expect(mockWriteFile).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,8 @@
import { format } from 'date-fns';
import {
convertApiSpecSyntax,
detectApiSpecSyntax,
} from 'insomnia/src/common/api-specs';
import { getProductName } from 'insomnia/src/common/constants';
import { getWorkspaceLabel } from 'insomnia/src/common/get-workspace-label';
import { getInsomniaV5DataExport } from 'insomnia/src/common/insomnia-v5';
@@ -31,6 +35,7 @@ import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features
import { usePlanData } from '~/ui/hooks/use-plan';
const VALUE_YAML = 'yaml';
const VALUE_JSON = 'json';
const VALUE_HAR = 'har';
export type SelectedFormat = typeof VALUE_HAR | typeof VALUE_YAML;
@@ -90,7 +95,7 @@ const showSaveExportedFileDialog = async ({
selectedFormat,
}: {
exportedFileNamePrefix: string;
selectedFormat: SelectedFormat;
selectedFormat: SelectedFormat | typeof VALUE_JSON;
}) => {
const date = format(Date.now(), 'yyyy-MM-dd-HH-mm-ss');
const name = exportedFileNamePrefix.replace(/ /g, '-');
@@ -359,6 +364,77 @@ export const exportRequestsToFile = (workspaceId: string, requestIds: string[])
});
};
export const exportSpecificationToFile = async (workspace: Workspace) => {
const apiSpec = await services.apiSpec.getByParentId(workspace._id);
if (!apiSpec?.contents) {
showModal(AlertModal, {
title: 'Cannot export',
message: (
<>
This <strong>{getWorkspaceLabel(workspace).singular.toLowerCase()}</strong> does not contain an OpenAPI
specification to export.
</>
),
});
return;
}
showModal(SelectModal, {
title: 'Select Specification Format',
value: VALUE_YAML,
options: [
{
name: 'YAML',
value: VALUE_YAML,
},
{
name: 'JSON',
value: VALUE_JSON,
},
],
message: 'Which format would you like to export as?',
onDone: async selectedFormat => {
if (selectedFormat !== VALUE_YAML && selectedFormat !== VALUE_JSON) {
return;
}
let specification = apiSpec.contents;
if (detectApiSpecSyntax(specification) !== selectedFormat) {
try {
specification = convertApiSpecSyntax(specification, selectedFormat);
} catch {
showError({
title: 'Export Failed',
message: `The specification is not valid, cannot convert to ${selectedFormat.toUpperCase()}`,
});
return;
}
}
const fileName = await showSaveExportedFileDialog({
exportedFileNamePrefix: workspace.name,
selectedFormat,
});
if (!fileName) {
return;
}
try {
await writeExportedFileToFileSystem(fileName, specification);
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.dataExport,
properties: { type: selectedFormat, scope: workspace.scope },
});
} catch {
showError({
title: 'Export Failed',
message: 'Export failed due to an unexpected error',
});
}
},
});
};
export const exportMcpClientToFile = async (workspace: Workspace) => {
const fileName = await showSaveExportedFileDialog({
exportedFileNamePrefix: workspace.name,
@@ -32,7 +32,7 @@ import * as reactUse from 'react-use';
import { SwaggerUIBundle } from 'swagger-ui-dist';
import YAML from 'yaml';
import { parseApiSpec } from '~/common/api-specs';
import { convertApiSpecSyntax, detectApiSpecSyntax, parseApiSpec } from '~/common/api-specs';
import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants';
import { debounce } from '~/common/misc';
import { utf8ByteLength } from '~/common/utils/utf8-bytes';
@@ -388,12 +388,7 @@ export const SpecView = ({
if (!contents) {
return null;
}
try {
JSON.parse(contents);
return 'json';
} catch {
return 'yaml';
}
return detectApiSpecSyntax(contents);
}, [apiSpec?.contents]);
const switchFormat = (to: 'json' | 'yaml') => {
@@ -401,10 +396,9 @@ export const SpecView = ({
if (!editorValue) {
return;
}
let parsedSpec: string | undefined;
let contents: string;
try {
// yaml parses json correctly
parsedSpec = YAML.parse(editorValue);
contents = convertApiSpecSyntax(editorValue, to);
} catch {
showToast({
title: 'Failed to convert spec format',
@@ -414,7 +408,6 @@ export const SpecView = ({
});
return;
}
const contents = to === 'json' ? JSON.stringify(parsedSpec, null, 2) : YAML.stringify(parsedSpec);
editor.current?.setValue(contents);
updateApiSpec({ organizationId, projectId, workspaceId, contents });
};