Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions vscode/microsoft-kiota/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,11 @@
"command": "kiota.migrateFromLockFile",
"when": "resourceExtname == .json && resourceFilename == kiota-lock.json",
"group": "navigation"
},
{
"command": "kiota.openApiExplorer.loadOpenApiDescriptionFromFile",
"when": "resourceExtname =~ /\\.(yaml|yml)$/",
"group": "2_kiota@2"
}
],
"view/title": [
Expand Down Expand Up @@ -454,6 +459,12 @@
"title": "%kiota.openApiExplorer.openDescription.title%",
"icon": "$(new-file)"
},
{
"command": "kiota.openApiExplorer.loadOpenApiDescriptionFromFile",
"category": "Kiota",
"title": "%kiota.openApiExplorer.loadFromFile.title%",
"icon": "$(file-code)"
},
{
"command": "kiota.workspace.openWorkspaceFile",
"title": "%kiota.openApiExplorer.openFile.title%"
Expand Down Expand Up @@ -520,14 +531,23 @@
"@vscode/l10n": "^0.0.18"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
"@stylistic/eslint-plugin-ts": "^4.4.1",
"@types/chai": "^5.0.1",
"@types/mocha": "^10.0.10",
"@types/sinon": "^17.0.4",
"@types/vscode": "^1.101.0",
"@typescript-eslint/eslint-plugin": "^8.34.1",
"@typescript-eslint/parser": "^8.34.1",
"@vscode/test-cli": "^0.0.11",
"@vscode/test-electron": "^2.4.1",
"chai": "^5.2.0",
"eslint": "^9.29.0",
"eslint-plugin-import": "^2.31.0",
"globals": "^16.2.0",
"mocha": "^11.1.0",
"sinon": "^21.0.0",
"typescript": "^5.8.3",
"webpack": "^5.98.0",
"webpack-cli": "^6.0.1"
}
Expand Down
1 change: 1 addition & 0 deletions vscode/microsoft-kiota/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"kiota.openApiExplorer.removeAllFromSelectedEndpoints.title": "Remove all",
"kiota.openApiExplorer.closeDescription.title": "Close API description",
"kiota.openApiExplorer.openDescription.title": "Add API description",
"kiota.openApiExplorer.loadFromFile.title": "Load in Kiota API Explorer",
"kiota.searchLock.title": "Search for a lock file",
"kiota.openApiExplorer.filterDescription.title": "Filter API description",
"kiota.openApiExplorer.openDocumentationPage.title": "Open documentation page",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { getKiotaTree } from "@microsoft/kiota";
import { TelemetryReporter } from "@vscode/extension-telemetry";
import * as vscode from "vscode";
import * as fs from "fs";

import { extensionId, SHOW_MESSAGE_AFTER_API_LOAD, treeViewId } from "../../constants";
import { OpenApiTreeProvider } from "../../providers/openApiTreeProvider";
import { getExtensionSettings } from "../../types/extensionSettings";
import { updateTreeViewIcons } from "../../util";
import { openTreeViewWithProgress } from "../../utilities/progress";
import { Command } from "../Command";

export class LoadOpenApiDescriptionFromFileCommand extends Command {

constructor(
private openApiTreeProvider: OpenApiTreeProvider,
private context: vscode.ExtensionContext
) {
super();
}

public getName(): string {
return `${treeViewId}.loadOpenApiDescriptionFromFile`;
}

public async execute(fileUri: vscode.Uri): Promise<void> {
const reporter = new TelemetryReporter(this.context.extension.packageJSON.telemetryInstrumentationKey);

try {
// Get the file path
const filePath = fileUri.fsPath;

// Check if file exists and is readable
if (!fs.existsSync(filePath)) {
vscode.window.showErrorMessage(vscode.l10n.t("File not found: {0}", filePath));
return;
}

// Check if it's a YAML file
const fileExtension = filePath.toLowerCase();
if (!fileExtension.endsWith('.yaml') && !fileExtension.endsWith('.yml')) {
vscode.window.showErrorMessage(vscode.l10n.t("Selected file must be a YAML file (.yaml or .yml)"));
return;
}

// Try to validate if it's an OpenAPI file by reading its content
let fileContent: string;
try {
fileContent = fs.readFileSync(filePath, 'utf8');
} catch (error) {
vscode.window.showErrorMessage(vscode.l10n.t("Unable to read file: {0}", (error as Error).message));
return;
}

// Basic check for OpenAPI/Swagger keywords
const isOpenApiFile = this.isOpenApiContent(fileContent);
if (!isOpenApiFile) {
const loadAnyway = vscode.l10n.t("Load anyway");
const response = await vscode.window.showWarningMessage(
vscode.l10n.t("This file doesn't appear to be an OpenAPI description. It should contain 'openapi' or 'swagger' keywords."),
loadAnyway,
vscode.l10n.t("Cancel")
);

if (response !== loadAnyway) {
return;
}
}

// Check if there are changes that would be lost
const yesAnswer = vscode.l10n.t("Yes, override it");
if (this.openApiTreeProvider.hasChanges()) {
const response = await vscode.window.showWarningMessage(
vscode.l10n.t("Before adding a new API description, consider that your changes and current selection will be lost."),
yesAnswer,
vscode.l10n.t("Cancel")
);
if (response !== yesAnswer) {
return;
}
}

// Try to load the OpenAPI description
const settings = getExtensionSettings(extensionId);

// First validate the file using Kiota
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
cancellable: false,
title: vscode.l10n.t("Validating OpenAPI description...")
}, async (progress, _) => {
try {
await getKiotaTree({
descriptionPath: filePath,
clearCache: settings.clearCache,
includeKiotaValidationRules: true
});
} catch (error) {
throw new Error(vscode.l10n.t("Failed to validate OpenAPI description: {0}", (error as Error).message));
}
});

// If validation passed, load it into the tree view
await openTreeViewWithProgress(async () => {
await this.openApiTreeProvider.setDescriptionUrl(filePath);
await updateTreeViewIcons(treeViewId, true, false);
});

// Show success message and offer to generate
const generateAnswer = vscode.l10n.t("Generate");
const showGenerateMessage = this.context.globalState.get<boolean>(SHOW_MESSAGE_AFTER_API_LOAD, true);

if (showGenerateMessage) {
const doNotShowAgainOption = vscode.l10n.t("Do not show this again");
const response = await vscode.window.showInformationMessage(
vscode.l10n.t('OpenAPI description loaded successfully. Click on Generate after selecting the paths in the API Explorer'),
generateAnswer,
doNotShowAgainOption
);

if (response === generateAnswer) {
await vscode.commands.executeCommand(`${treeViewId}.generateClient`);
} else if (response === doNotShowAgainOption) {
await this.context.globalState.update(SHOW_MESSAGE_AFTER_API_LOAD, false);
}
}

// Send telemetry
reporter.sendTelemetryEvent("LoadOpenApiDescriptionFromFile", {
"fileExtension": fileExtension.endsWith('.yaml') ? 'yaml' : 'yml',
"isDetectedAsOpenApi": isOpenApiFile.toString()
});

} catch (error) {
const errorMessage = (error as Error).message;
vscode.window.showErrorMessage(errorMessage);

// Send error telemetry
reporter.sendTelemetryEvent("LoadOpenApiDescriptionFromFile", {
"error": errorMessage
});
}
}

private isOpenApiContent(content: string): boolean {
// Convert to lowercase for case-insensitive matching
const lowerContent = content.toLowerCase();

// Look for OpenAPI or Swagger keywords
return lowerContent.includes('openapi:') ||
lowerContent.includes('swagger:') ||
lowerContent.includes('"openapi"') ||
lowerContent.includes('"swagger"') ||
lowerContent.includes('openapi ') ||
lowerContent.includes('swagger ');
}
}
5 changes: 5 additions & 0 deletions vscode/microsoft-kiota/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { displayGenerationResults } from './commands/generate/generation-util';
import { checkForLockFileAndPrompt } from "./commands/migrate/migrateFromLockFile.util";
import { MigrateFromLockFileCommand } from './commands/migrate/migrateFromLockFileCommand';
import { SearchOrOpenApiDescriptionCommand } from './commands/openApidescription/searchOrOpenApiDescriptionCommand';
import { LoadOpenApiDescriptionFromFileCommand } from './commands/openApidescription/loadOpenApiDescriptionFromFileCommand';
import { AddAllToSelectedEndpointsCommand } from './commands/openApiTreeView/addAllToSelectedEndpointsCommand';
import { AddToSelectedEndpointsCommand } from './commands/openApiTreeView/addToSelectedEndpointsCommand';
import { FilterDescriptionCommand } from './commands/openApiTreeView/filterDescriptionCommand';
Expand Down Expand Up @@ -75,6 +76,7 @@ export async function activate(
const openDocumentationPageCommand = new OpenDocumentationPageCommand();
const editPathsCommand = new EditPathsCommand(openApiTreeProvider, context);
const searchOrOpenApiDescriptionCommand = new SearchOrOpenApiDescriptionCommand(openApiTreeProvider, context);
const loadOpenApiDescriptionFromFileCommand = new LoadOpenApiDescriptionFromFileCommand(openApiTreeProvider, context);
const generateClientCommand = new GenerateClientCommand(openApiTreeProvider, context, dependenciesInfoProvider, setWorkspaceGenerationContext, kiotaOutputChannel);
const regenerateCommand = new RegenerateCommand(context, openApiTreeProvider, kiotaOutputChannel);
const regenerateButtonCommand = new RegenerateButtonCommand(context, openApiTreeProvider, kiotaOutputChannel);
Expand Down Expand Up @@ -117,6 +119,9 @@ export async function activate(
registerCommandWithTelemetry(reporter, searchOrOpenApiDescriptionCommand.getName(),
async (searchParams: Partial<IntegrationParams> = {}) => await searchOrOpenApiDescriptionCommand.execute(searchParams)
),
registerCommandWithTelemetry(reporter, loadOpenApiDescriptionFromFileCommand.getName(),
async (fileUri: vscode.Uri) => await loadOpenApiDescriptionFromFileCommand.execute(fileUri)
),
registerCommandWithTelemetry(reporter, closeDescriptionCommand.getName(), async () => await closeDescriptionCommand.execute()),
registerCommandWithTelemetry(reporter, filterDescriptionCommand.getName(), async () => await filterDescriptionCommand.execute()),
registerCommandWithTelemetry(reporter, editPathsCommand.getName(), async (clientOrPluginKey: string, clientOrPluginObject: ClientOrPluginProperties, generationType: string) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// You can import and use all API from the 'vscode' module
// as well as import your extension to test it
import assert from "assert";
import * as sinon from "sinon";
import * as vscode from 'vscode';
import * as fs from "fs";

import * as loadModule from "../../../commands/openApidescription/loadOpenApiDescriptionFromFileCommand";
import * as treeModule from "../../../providers/openApiTreeProvider";
import * as kiotaModule from "@microsoft/kiota";

suite('LoadOpenApiDescriptionFromFileCommand Test Suite', () => {
void vscode.window.showInformationMessage('Start LoadOpenApiDescriptionFromFileCommand tests.');
const sandbox = sinon.createSandbox();

teardown(async () => {
sandbox.restore();
});

test('test function getName of loadOpenApiDescriptionFromFileCommand', () => {
const treeProvider = sinon.createStubInstance(treeModule.OpenApiTreeProvider);
const mockContext = {} as vscode.ExtensionContext;
const loadCommand = new loadModule.LoadOpenApiDescriptionFromFileCommand(treeProvider, mockContext);
assert.strictEqual("kiota.openApiExplorer.loadOpenApiDescriptionFromFile", loadCommand.getName());
});

test('test isOpenApiContent detects OpenAPI files correctly', () => {
const treeProvider = sinon.createStubInstance(treeModule.OpenApiTreeProvider);
const mockContext = {} as vscode.ExtensionContext;
const loadCommand = new loadModule.LoadOpenApiDescriptionFromFileCommand(treeProvider, mockContext);

// Access the private method for testing
const command = loadCommand as any;

// Test positive cases
assert.strictEqual(true, command.isOpenApiContent('openapi: 3.0.1'));
assert.strictEqual(true, command.isOpenApiContent('swagger: "2.0"'));
assert.strictEqual(true, command.isOpenApiContent('{\n "openapi": "3.0.1"\n}'));
assert.strictEqual(true, command.isOpenApiContent('{\n "swagger": "2.0"\n}'));

// Test negative cases
assert.strictEqual(false, command.isOpenApiContent('version: 1.0\nname: test'));
assert.strictEqual(false, command.isOpenApiContent('apiVersion: v1\nkind: Deployment'));
});

test('test execute with non-existing file shows error', async () => {
const treeProvider = sinon.createStubInstance(treeModule.OpenApiTreeProvider);
const mockContext = {
extension: { packageJSON: { telemetryInstrumentationKey: 'test-key' } }
} as any;

// Mock fs.existsSync to return false
const existsSyncStub = sandbox.stub(fs, 'existsSync').returns(false);
const showErrorMessageStub = sandbox.stub(vscode.window, 'showErrorMessage');

const loadCommand = new loadModule.LoadOpenApiDescriptionFromFileCommand(treeProvider, mockContext);
const mockUri = { fsPath: '/non/existing/file.yaml' } as vscode.Uri;

await loadCommand.execute(mockUri);

sinon.assert.calledOnce(existsSyncStub);
sinon.assert.calledOnce(showErrorMessageStub);
sinon.assert.calledWith(showErrorMessageStub, sinon.match(/File not found/));
});

test('test execute with non-YAML file shows error', async () => {
const treeProvider = sinon.createStubInstance(treeModule.OpenApiTreeProvider);
const mockContext = {
extension: { packageJSON: { telemetryInstrumentationKey: 'test-key' } }
} as any;

// Mock fs.existsSync to return true
const existsSyncStub = sandbox.stub(fs, 'existsSync').returns(true);
const showErrorMessageStub = sandbox.stub(vscode.window, 'showErrorMessage');

const loadCommand = new loadModule.LoadOpenApiDescriptionFromFileCommand(treeProvider, mockContext);
const mockUri = { fsPath: '/some/file.json' } as vscode.Uri;

await loadCommand.execute(mockUri);

sinon.assert.calledOnce(existsSyncStub);
sinon.assert.calledOnce(showErrorMessageStub);
sinon.assert.calledWith(showErrorMessageStub, sinon.match(/must be a YAML file/));
});
});
Loading