From e5d2f5a4fcf754ee509470de3f85f9942d649651 Mon Sep 17 00:00:00 2001 From: gotstu Date: Fri, 21 Mar 2025 13:39:30 -0700 Subject: [PATCH 1/9] added new support for auto token fetch with env change --- README.md | 39 +++++- package-lock.json | 14 ++ package.json | 2 +- src/controllers/environmentController.ts | 155 ++++++++++++++++++++++- 4 files changed, 205 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5110daa3..dac03a4c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,5 @@ # REST Client -[![Open in Visual Studio Code](https://img.shields.io/static/v1?logo=visualstudiocode&label=&message=Open%20in%20Visual%20Studio%20Code&labelColor=2c2c32&color=007acc&logoColor=007acc)](https://open.vscode.dev/Huachao/vscode-restclient) [![Node CI](https://github.com/Huachao/vscode-restclient/workflows/Node%20CI/badge.svg?event=push)](https://github.com/Huachao/vscode-restclient/actions?query=workflow%3A%22Node+CI%22) [![Join the chat at https://gitter.im/Huachao/vscode-restclient](https://badges.gitter.im/Huachao/vscode-restclient.svg)](https://gitter.im/Huachao/vscode-restclient?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Marketplace Version](https://vsmarketplacebadges.dev/version-short/humao.rest-client.svg)](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) [![Downloads](https://vsmarketplacebadges.dev/downloads-short/humao.rest-client.svg -)](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) [![Installs](https://vsmarketplacebadges.dev/installs-short/humao.rest-client.svg)](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) [![Rating](https://vsmarketplacebadges.dev/rating-short/humao.rest-client.svg)](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) - REST Client allows you to send HTTP request and view the response in Visual Studio Code directly. It eliminates the need for a separate tool to test REST APIs and makes API testing convenient and efficient. ## Main Features @@ -463,6 +460,42 @@ A sample usage in `http` file for above environment variables is listed below, n GET https://{{host}}/api/{{version}}comments/1 HTTP/1.1 Authorization: {{token}} ``` +### Auto Token Fetching With Environment switch +REST Client supports automatic token fetching when switching environments. This can be configured using the `auto_fetch_token_data` property in your environment settings: + +```json +"rest-client.environmentVariables": { + "$shared": {}, + "development": { + "host": "dev.example.com", + "auto_fetch_token_data": { + "client_id_variable_name": "CLIENT_ID", + "client_secret_variable_name": "CLIENT_SECRET", + "auth_type": "Basic", + "method": "POST", + "token_request_url": "https://auth.example.com/token", + "content_type": "application/x-www-form-urlencoded", + "grant_type": "client_credentials", + "scope": "api.access", + "response_token_value_tag_name": "access_token" + } + } +} +``` + +The `auto_fetch_token_data` configuration requires: +- `client_id_variable_name`: Name of the variable in .env file containing client ID +- `client_secret_variable_name`: Name of the variable in .env file containing client secret +- `auth_type`: Authentication type (e.g., "Basic", "Bearer") +- `method`: HTTP method for token request +- `token_request_url`: URL endpoint for token requests +- `response_token_value_tag_name`: JSON path to token value in response + +When switching to an environment with `auto_fetch_token_data` configured: +1. The extension checks for a .env file in the same directory as your .http file +2. Reads the client credentials from .env file +3. Makes a token request to the specified endpoint +4. Stores the received token in the $shared environment as "token" #### File Variables For file variables, the definition follows syntax __`@variableName = variableValue`__ which occupies a complete line. And variable name __MUST NOT__ contain any spaces. As for variable value, it can consist of any characters, even whitespaces are allowed for them (leading and trailing whitespaces will be trimmed). If you want to preserve some special characters like line break, you can use the _backslash_ `\` to escape, like `\n`. File variable value can even contain references to all of other kinds of variables. For instance, you can create a file variable with value of other [request variables](#request-variables) like `@token = {{loginAPI.response.body.token}}`. When referencing a file variable, you can use the _percent_ `%` to percent-encode the value. diff --git a/package-lock.json b/package-lock.json index acef7d9d..badadf72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15822,6 +15822,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "license": "MIT", + "peer": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", diff --git a/package.json b/package.json index 5afdf999..d841c290 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "rest-client", "displayName": "REST Client", "description": "REST Client for Visual Studio Code", - "version": "0.26.0", + "version": "0.26.1", "publisher": "humao", "author": { "name": "Huachao Mao", diff --git a/src/controllers/environmentController.ts b/src/controllers/environmentController.ts index 2fca9a5c..4c8448dc 100644 --- a/src/controllers/environmentController.ts +++ b/src/controllers/environmentController.ts @@ -1,3 +1,6 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; import { EventEmitter, QuickPickItem, window } from 'vscode'; import * as Constants from '../common/constants'; import { SystemSettings } from '../models/configurationSettings'; @@ -44,7 +47,7 @@ export class EnvironmentController { })); const itemPickList: EnvironmentPickItem[] = [EnvironmentController.noEnvironmentPickItem, ...userEnvironments]; - const item = await window.showQuickPick(itemPickList, { placeHolder: "Select REST Client Environment" }); + const item = await window.showQuickPick(itemPickList, { placeHolder: "Select REST Client Environment!!!" }); if (!item) { return; } @@ -54,6 +57,26 @@ export class EnvironmentController { EnvironmentController._onDidChangeEnvironment.fire(item.label); this.environmentStatusEntry.update(item.label); + if (this.settings.environmentVariables[item.label]?.auto_fetch_token_data) { + const { default: fetch } = await import('node-fetch'); + const token = await this.getTokenForEnvironment(item.label, this.settings.environmentVariables[item.label].auto_fetch_token_data, fetch); + if (token !== '') { + await this.setAuthToken(token); + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: `Token is Created Successfully for Environment ${item.label}!`, + cancellable: false + }, async (progress) => { + progress.report({ increment: 0 }); + + // Simulate a delay of 10 seconds + await new Promise(resolve => setTimeout(resolve, 5000)); + + progress.report({ increment: 100, message: "Done!" }); + }); + } + } + await UserDataManager.setEnvironment(item); } @@ -70,4 +93,134 @@ export class EnvironmentController { public dispose() { this.environmentStatusEntry.dispose(); } + + private async setAuthToken(token: string): Promise { + try { + // Add token to $shared environment variables + if (!this.settings.environmentVariables[EnvironmentController.sharedEnvironmentName]) { + this.settings.environmentVariables[EnvironmentController.sharedEnvironmentName] = {}; + } + + this.settings.environmentVariables[EnvironmentController.sharedEnvironmentName].token = token; + + // Update settings + await vscode.workspace.getConfiguration().update( + 'rest-client.environmentVariables', + this.settings.environmentVariables, + vscode.ConfigurationTarget.Global + ); + + console.log(`Token updated in $shared environment variables`); + } catch (error) { + vscode.window.showErrorMessage(`Failed to update token in shared environment: ${error}`); + } + } + + private getNestedValue(obj: any, path: string[]): any { + return path.reduce((acc, key) => acc && acc[key], obj); + } + + private async getTokenForEnvironment(environmentName: string, autoFetchTokenData: any, fetch: any): Promise { + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + vscode.window.showErrorMessage('No active editor found'); + return ''; + } + + // Add check for .http extension + if (!activeEditor.document.fileName.toLowerCase().endsWith('.http')) { + vscode.window.showErrorMessage('Please open a .http file to continue'); + return ''; + } + + const envFilePath = path.join(path.dirname(activeEditor.document.fileName), `.env`); + let clientId: string | undefined; + let clientSecret: string | undefined; + + if (!autoFetchTokenData.client_id_variable_name || !autoFetchTokenData.client_secret_variable_name) { + vscode.window.showErrorMessage(`client_id_variable_name and client_secret_variable_name must configured in settings.`); + return ''; + } + + try { + if (fs.existsSync(envFilePath)) { + const envContent = await fs.promises.readFile(envFilePath, 'utf8'); + const lines = envContent.split('\n'); + + for (const line of lines) { + const [key, value] = line.split('=').map(part => part.trim()); + if (key === autoFetchTokenData.client_id_variable_name) clientId = value; + if (key === autoFetchTokenData.client_secret_variable_name) clientSecret = value; + } + }else { + // Create new .env file if it doesn't exist + await fs.promises.writeFile(envFilePath, '', { flag: 'w' }); + vscode.window.showInformationMessage('Created new .env file. Please add your client credentials.'); + return ''; + } + } catch (error) { + vscode.window.showErrorMessage(`auto_fetch_token_data is configured for environment but there is no .env created adjacent to this http file.`); + return ''; + } + + if (!clientId || !clientSecret) { + vscode.window.showErrorMessage(`${autoFetchTokenData.client_id_variable_name } and ${autoFetchTokenData.client_secret_variable_name } must be defined in .env.`); + return ''; + } + const encodedCredentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + + const headers: any = { + 'Authorization': `${autoFetchTokenData['auth_type']} ${encodedCredentials}` + }; + if (autoFetchTokenData['content_type']) { + headers['Content-Type'] = autoFetchTokenData['content_type']; + } + const body = new URLSearchParams(); + if (autoFetchTokenData['grant_type']) { + body.append('grant_type', autoFetchTokenData['grant_type']); + } + if (autoFetchTokenData['scope']) { + body.append('scope', autoFetchTokenData['scope']); + } + + const tokenExpression = autoFetchTokenData['response_token_value_tag_name']; + + if (!tokenExpression) { + vscode.window.showErrorMessage(`response_token_value_tag_name is missing in settings for ${environmentName} where auto_fetch_token_data is configured`); + return ''; + } + + try { + const method = autoFetchTokenData['method']; + if (!method) { + throw new Error('method is missing in settings'); + } + const token_request_url = autoFetchTokenData['token_request_url']; + if (!token_request_url) { + throw new Error('token_request_url is missing in settings'); + } + + const response = await fetch(token_request_url, { + method: method, + headers: headers, + body: body + }); + const data = await response.json(); + + if (typeof data === 'object' && data !== null) { + const token = this.getNestedValue(data, tokenExpression.split(".")); + if (token) { + return token; + } else { + vscode.window.showErrorMessage(`Check the token_expression array in settings "${tokenExpression}" is not found in ${JSON.stringify(data)}`); + return ''; + } + } else { + throw new Error('Invalid response structure'); + } + } catch (error) { + vscode.window.showErrorMessage(`Error fetching token: ${error}`); + return ''; + } + } } \ No newline at end of file From 1d8afa86312f969db746a13edefe18362f28a833 Mon Sep 17 00:00:00 2001 From: gotstu Date: Fri, 28 Mar 2025 13:43:30 -0700 Subject: [PATCH 2/9] saving http run test --- package.json | 5 ++ src/controllers/httpTestingController.ts | 92 ++++++++++++++++++++++++ src/extension.ts | 3 + 3 files changed, 100 insertions(+) create mode 100644 src/controllers/httpTestingController.ts diff --git a/package.json b/package.json index d841c290..91e84f07 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,11 @@ "title": "Switch Environment", "category": "Rest Client" }, + { + "command": "rest-client.run-http-test", + "title": "Run HTTP Test", + "category": "Rest Client" + }, { "command": "rest-client.history", "title": "View Request History", diff --git a/src/controllers/httpTestingController.ts b/src/controllers/httpTestingController.ts new file mode 100644 index 00000000..fa232f08 --- /dev/null +++ b/src/controllers/httpTestingController.ts @@ -0,0 +1,92 @@ +import * as vscode from 'vscode'; +import { trace } from "../utils/decorator"; +import * as fs from 'fs'; +import * as path from 'path'; +import * as Constants from '../common/constants'; +import { QuickPickItem } from 'vscode'; +import { SystemSettings } from '../models/configurationSettings'; +import { UserDataManager } from '../utils/userDataManager'; + +type EnvironmentPickItem = QuickPickItem & { name: string }; + +export class HttpTestingController { + private static readonly noEnvironmentPickItem: EnvironmentPickItem = { + label: 'No Environment', + name: Constants.NoEnvironmentSelectedName, + description: 'You can still use variables defined in the $shared environment' + }; + private readonly settings: SystemSettings = SystemSettings.Instance; + // Helper function to filter out auto_fetch_token_data + private filterEnvironmentVars(vars: any): any { + const filtered = { ...vars }; + delete filtered.auto_fetch_token_data; + return filtered; + } + @trace('HTTP Testing') + public async runHttpTest() { + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + vscode.window.showErrorMessage('No active editor found.'); + return; + } + + const document = activeEditor.document; + const fileName = document.fileName; + + if (!fileName.endsWith('.http')) { + vscode.window.showErrorMessage('The active file is not an .http file.'); + return; + } + + try { + // Get current environment + const currentEnvironment = await HttpTestingController.getCurrentEnvironment(); + + // Merge $shared and current environment variables, excluding auto_fetch_token_data + const sharedVars = this.filterEnvironmentVars(this.settings.environmentVariables['$shared'] || {}); + const currentEnvVars = currentEnvironment.name !== Constants.NoEnvironmentSelectedName + ? this.filterEnvironmentVars(this.settings.environmentVariables[currentEnvironment.name] || {}) + : {}; + + // Combine variables with current environment taking precedence + const combinedVars = { + ...sharedVars, + ...currentEnvVars + }; + + // Create variables.json with combined values + const variablesPath = path.join(path.dirname(fileName), 'variables.json'); + await fs.promises.writeFile( + variablesPath, + JSON.stringify(combinedVars, null, 2), + 'utf8' + ); + + // Try to find existing HTTP Test terminal + let terminal = vscode.window.terminals.find(t => t.name === 'HTTP Test'); + + if (terminal) { + terminal.show(); + terminal.sendText('cls', true); // Use 'clear' for non-Windows + terminal.sendText(`http-test "${fileName}"`, true); + } else { + terminal = vscode.window.createTerminal('HTTP Test'); + terminal.show(); + terminal.sendText(`http-test "${fileName}"`, true); + } + + } catch (error) { + vscode.window.showErrorMessage(`Failed to create variables.json: ${error instanceof Error ? error.message : String(error)}`); + return; + } + } + + public static async create(): Promise { + return new HttpTestingController(); + } + + private static async getCurrentEnvironment(): Promise { + const currentEnvironment = await UserDataManager.getEnvironment() as EnvironmentPickItem | undefined; + return currentEnvironment || this.noEnvironmentPickItem; + } +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 9f8ad35d..606ce550 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,6 +4,7 @@ import { commands, ExtensionContext, languages, Range, TextDocument, Uri, window, workspace } from 'vscode'; import { CodeSnippetController } from './controllers/codeSnippetController'; import { EnvironmentController } from './controllers/environmentController'; +import { HttpTestingController } from './controllers/httpTestingController'; import { HistoryController } from './controllers/historyController'; import { RequestController } from './controllers/requestController'; import { SwaggerController } from './controllers/swaggerController'; @@ -33,6 +34,7 @@ export async function activate(context: ExtensionContext) { const historyController = new HistoryController(); const codeSnippetController = new CodeSnippetController(context); const environmentController = await EnvironmentController.create(); + const httpTestingController = await HttpTestingController.create(); const swaggerController = new SwaggerController(context); context.subscriptions.push(requestController); context.subscriptions.push(historyController); @@ -46,6 +48,7 @@ export async function activate(context: ExtensionContext) { context.subscriptions.push(commands.registerCommand('rest-client.generate-codesnippet', () => codeSnippetController.run())); context.subscriptions.push(commands.registerCommand('rest-client.copy-request-as-curl', () => codeSnippetController.copyAsCurl())); context.subscriptions.push(commands.registerCommand('rest-client.switch-environment', () => environmentController.switchEnvironment())); + context.subscriptions.push(commands.registerCommand('rest-client.run-http-test', () => httpTestingController.runHttpTest())); context.subscriptions.push(commands.registerCommand('rest-client.clear-aad-token-cache', () => AadTokenCache.clear())); context.subscriptions.push(commands.registerCommand('rest-client.clear-cookies', () => requestController.clearCookies())); context.subscriptions.push(commands.registerCommand('rest-client._openDocumentLink', args => { From 7b0328ddf7931c0edb1c9f28b4bc8563310c234a Mon Sep 17 00:00:00 2001 From: gotstu Date: Fri, 28 Mar 2025 14:43:06 -0700 Subject: [PATCH 3/9] added logger --- src/controllers/httpTestingController.ts | 32 +++-- src/types/index.ts | 97 +++++++++++++++ src/utils/logger.ts | 149 +++++++++++++++++++++++ 3 files changed, 266 insertions(+), 12 deletions(-) create mode 100644 src/types/index.ts create mode 100644 src/utils/logger.ts diff --git a/src/controllers/httpTestingController.ts b/src/controllers/httpTestingController.ts index fa232f08..54fbfd53 100644 --- a/src/controllers/httpTestingController.ts +++ b/src/controllers/httpTestingController.ts @@ -6,10 +6,13 @@ import * as Constants from '../common/constants'; import { QuickPickItem } from 'vscode'; import { SystemSettings } from '../models/configurationSettings'; import { UserDataManager } from '../utils/userDataManager'; +import { initLogger, log } from "../utils/logger"; +import { LogLevel } from "../types"; type EnvironmentPickItem = QuickPickItem & { name: string }; export class HttpTestingController { + private static readonly noEnvironmentPickItem: EnvironmentPickItem = { label: 'No Environment', name: Constants.NoEnvironmentSelectedName, @@ -22,10 +25,20 @@ export class HttpTestingController { delete filtered.auto_fetch_token_data; return filtered; } + + // Create output channel + private static readonly outputChannel = vscode.window.createOutputChannel('REST Client'); + + constructor() { + // Initialize logger with the output channel + initLogger(HttpTestingController.outputChannel); + } + @trace('HTTP Testing') public async runHttpTest() { const activeEditor = vscode.window.activeTextEditor; if (!activeEditor) { + log('No active editor found.', LogLevel.ERROR); vscode.window.showErrorMessage('No active editor found.'); return; } @@ -34,13 +47,17 @@ export class HttpTestingController { const fileName = document.fileName; if (!fileName.endsWith('.http')) { + log('The active file is not an .http file.', LogLevel.ERROR); vscode.window.showErrorMessage('The active file is not an .http file.'); return; } try { + log('Creating variables.json...', LogLevel.INFO); + // Get current environment const currentEnvironment = await HttpTestingController.getCurrentEnvironment(); + log(`Current environment: ${currentEnvironment.name}`, LogLevel.INFO); // Merge $shared and current environment variables, excluding auto_fetch_token_data const sharedVars = this.filterEnvironmentVars(this.settings.environmentVariables['$shared'] || {}); @@ -61,21 +78,12 @@ export class HttpTestingController { JSON.stringify(combinedVars, null, 2), 'utf8' ); + log(`Variables written to: ${variablesPath}`, LogLevel.INFO); - // Try to find existing HTTP Test terminal - let terminal = vscode.window.terminals.find(t => t.name === 'HTTP Test'); - - if (terminal) { - terminal.show(); - terminal.sendText('cls', true); // Use 'clear' for non-Windows - terminal.sendText(`http-test "${fileName}"`, true); - } else { - terminal = vscode.window.createTerminal('HTTP Test'); - terminal.show(); - terminal.sendText(`http-test "${fileName}"`, true); - } + log("Starting test run...", LogLevel.INFO); } catch (error) { + log(`Failed to create variables.json: ${error instanceof Error ? error.message : String(error)}`, LogLevel.ERROR); vscode.window.showErrorMessage(`Failed to create variables.json: ${error instanceof Error ? error.message : String(error)}`); return; } diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..baa51b18 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,97 @@ +import FormData from "form-data"; + +export interface RunOptions { + verbose?: boolean; + var?: string; +} + +export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + +export interface HttpRequest { + name: string; + method: HttpMethod; + url: string; + headers: Record; + body?: string | FormData | object; + tests: TestItem[]; + variableUpdates: VariableUpdate[]; + expectError?: boolean; +} + +export interface VariableUpdate { + key: string; + value: string; +} + +export interface TestItem { + type: "Assert"; + name?: string; + assertions: Assertion[]; +} + +export interface TestResult { + name: string; + passed: boolean; + error?: Error; + statusCode?: number; +} + +export interface TestSummary { + totalTests: number; + passedTests: number; + failedTests: number; + results: TestResult[]; +} + +export type AssertionType = "status" | "header" | "body" | "custom"; + +export interface Assertion { + type: AssertionType; + key?: string; + value?: unknown | ((value: unknown) => boolean) | string; +} + +export interface Variables { + [key: string]: string | number | boolean; +} + +export interface VariableManager { + setVariables(variables: Variables): void; + replaceVariables(content: string): string; + setVariable(key: string, value: string | number | boolean): void; + getVariable(key: string): string | number | boolean | undefined; + getAllVariables(): Variables; +} + +export interface HttpResponse { + status: number; + headers: Record; + data: unknown; +} + +export interface CustomValidatorContext { + request: HttpRequest; + variables: Variables; +} + +export type CustomValidatorFunction = ( + response: HttpResponse, + context: CustomValidatorContext +) => void; + +export interface FileUtils { + readFile(filePath: string): Promise; + loadVariables(filePath: string): Promise; +} + +export interface AssertionEngine { + assert(assertion: Assertion, response: HttpResponse): Promise; +} + +export enum LogLevel { + INFO, + WARNING, + ERROR, + VERBOSE, + PLAIN, +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 00000000..32c2f485 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,149 @@ +import * as vscode from 'vscode'; +import { + LogLevel, + TestSummary, + HttpRequest, + TestResult +} from "../types"; + +let verbose = false; +let outputChannel: vscode.OutputChannel; + +export function initLogger(channel: vscode.OutputChannel): void { + outputChannel = channel; +} + +export function setVerbose(v: boolean): void { + verbose = v; +} + +export function log(message?: unknown, level: LogLevel = LogLevel.INFO, ...optionalParams: unknown[]): void { + if (!outputChannel) { + return; + } + + let logMessage = `[${LogLevel[level]}] ${message}`; + if (optionalParams.length > 0) { + logMessage += ' ' + optionalParams.map(p => String(p)).join(' '); + } + + switch (level) { + case LogLevel.VERBOSE: + if (verbose) { + outputChannel.appendLine(logMessage); + } + break; + case LogLevel.INFO: + outputChannel.appendLine(logMessage); + break; + case LogLevel.WARNING: + outputChannel.appendLine(`⚠️ ${logMessage}`); + break; + case LogLevel.ERROR: + outputChannel.appendLine(`❌ ${logMessage}`); + break; + case LogLevel.PLAIN: + outputChannel.appendLine(String(message)); + break; + default: + outputChannel.appendLine(logMessage); + } +} + +export function logVerbose(message?: unknown, ...optionalParams: unknown[]): void { + log(message, LogLevel.VERBOSE, ...optionalParams); +} + +export function logInfo(message?: unknown, ...optionalParams: unknown[]): void { + log(message, LogLevel.INFO, ...optionalParams); +} + +export function logWarning(message?: unknown, ...optionalParams: unknown[]): void { + log(message, LogLevel.WARNING, ...optionalParams); +} + +export function logError(message?: unknown, ...optionalParams: unknown[]): void { + log(message, LogLevel.ERROR, ...optionalParams); +} + +export function logPlain(message?: unknown, ...optionalParams: unknown[]): void { + log(message, LogLevel.PLAIN, ...optionalParams); +} + +export function logRequestStart(request: HttpRequest): void { + logPlain("\n" + "=".repeat(50)); + logPlain(`πŸ“Œ Parsed Request: ${request.name}`); + logPlain("=".repeat(50)); + logVerbose(`Method: ${request.method}`); + logVerbose(`URL: ${request.url}`); + logVerbose(`Headers: ${JSON.stringify(request.headers)}`); + if (request.body) { + logVerbose(`Body: ${request.body}`); + } + + if (request.tests.length > 0) { + logVerbose("Tests:"); + request.tests.forEach((test, index) => { + logVerbose(` Test ${index + 1}: ${test.name}`); + test.assertions.forEach(assertion => { + logVerbose(` - ${JSON.stringify(assertion)}`); + }); + }); + } + + if (request.variableUpdates.length > 0) { + logVerbose("Variable Updates:"); + request.variableUpdates.forEach((update, index) => { + logVerbose(` Update ${index + 1}: ${update.key}`); + logVerbose(` - ${JSON.stringify(update)}`); + }); + } + + if (verbose == true) { + logPlain("=".repeat(50)); + } +} + +export function logTestResult(result: TestResult): void { + const status = result.passed ? "βœ… PASS" : "❌ FAIL"; + const statusCode = result.statusCode ? `(Status: ${result.statusCode})` : ""; + const message = `${result.name}: ${status} ${statusCode}`; + if (result.passed) { + logInfo(message); + } else { + logWarning(message); + if (result.error) { + logError( + result.error instanceof Error + ? result.error.message + : String(result.error) + ); + } + } +} + +export function logTestSummary(summary: TestSummary): void { + logPlain("\n" + "=".repeat(50)); + logPlain("πŸ“Š Test Summary"); + logPlain("=".repeat(50)); + logPlain(`Total Tests: ${summary.totalTests}`); + logPlain(`Passed Tests: ${summary.passedTests}`); + logPlain(`Failed Tests: ${summary.failedTests}`); + + const statusEmojis = summary.results + .map((r) => (r.passed ? "βœ…" : "❌")) + .join(""); + logPlain(`\n${statusEmojis}`); + + summary.results.forEach((result, index) => { + const indent = " "; + const status = result.passed ? "βœ… PASS" : "❌ FAIL"; + const statusCode = result.statusCode + ? `(Status: ${result.statusCode})` + : ""; + const message = `${indent}${index + 1}. ${ + result.name + }: ${status} ${statusCode}`; + logPlain(message); + }); +} From 2d1715021826d52f4ce2fc1d6b533ac3236afabc Mon Sep 17 00:00:00 2001 From: gotstu Date: Fri, 28 Mar 2025 16:09:03 -0700 Subject: [PATCH 4/9] added http-test-core and some utils --- package.json | 2 +- src/controllers/httpTestingController.ts | 49 +++- src/errors/AssertionError.ts | 9 + src/errors/ParserError.ts | 9 + src/errors/RequestError.ts | 9 + src/errors/ValidationError.ts | 6 + src/http-test-core/AssertionEngine.ts | 302 ++++++++++++++++++++++ src/http-test-core/HttpFileParser.ts | 60 +++++ src/http-test-core/HttpRequestParser.ts | 116 +++++++++ src/http-test-core/RequestExecutor.ts | 279 ++++++++++++++++++++ src/http-test-core/ResponseProcessor.ts | 77 ++++++ src/http-test-core/TestManager.ts | 160 ++++++++++++ src/http-test-core/TestParser.ts | 103 ++++++++ src/http-test-core/TestResultCollector.ts | 26 ++ src/http-test-core/VariableManager.ts | 28 ++ src/utils/fileUtils.ts | 28 ++ src/utils/httpUtils.ts | 17 ++ src/utils/jsonUtils.ts | 44 ++++ src/utils/variableUtils.ts | 14 + src/validators/customValidator.ts | 46 ++++ 20 files changed, 1380 insertions(+), 4 deletions(-) create mode 100644 src/errors/AssertionError.ts create mode 100644 src/errors/ParserError.ts create mode 100644 src/errors/RequestError.ts create mode 100644 src/errors/ValidationError.ts create mode 100644 src/http-test-core/AssertionEngine.ts create mode 100644 src/http-test-core/HttpFileParser.ts create mode 100644 src/http-test-core/HttpRequestParser.ts create mode 100644 src/http-test-core/RequestExecutor.ts create mode 100644 src/http-test-core/ResponseProcessor.ts create mode 100644 src/http-test-core/TestManager.ts create mode 100644 src/http-test-core/TestParser.ts create mode 100644 src/http-test-core/TestResultCollector.ts create mode 100644 src/http-test-core/VariableManager.ts create mode 100644 src/utils/fileUtils.ts create mode 100644 src/utils/httpUtils.ts create mode 100644 src/utils/jsonUtils.ts create mode 100644 src/utils/variableUtils.ts create mode 100644 src/validators/customValidator.ts diff --git a/package.json b/package.json index 91e84f07..dbf9fbb7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "rest-client", "displayName": "REST Client", "description": "REST Client for Visual Studio Code", - "version": "0.26.1", + "version": "0.26.2", "publisher": "humao", "author": { "name": "Huachao Mao", diff --git a/src/controllers/httpTestingController.ts b/src/controllers/httpTestingController.ts index 54fbfd53..2d66e318 100644 --- a/src/controllers/httpTestingController.ts +++ b/src/controllers/httpTestingController.ts @@ -7,12 +7,17 @@ import { QuickPickItem } from 'vscode'; import { SystemSettings } from '../models/configurationSettings'; import { UserDataManager } from '../utils/userDataManager'; import { initLogger, log } from "../utils/logger"; -import { LogLevel } from "../types"; +import { fileExists, loadVariables } from "../utils/fileUtils"; +import { LogLevel, HttpRequest } from "../types"; +import { VariableManager } from "../http-test-core/VariableManager"; +import { HttpFileParser } from "../http-test-core/HttpFileParser"; +import { TestManager } from "../http-test-core/TestManager"; + type EnvironmentPickItem = QuickPickItem & { name: string }; export class HttpTestingController { - + private static readonly noEnvironmentPickItem: EnvironmentPickItem = { label: 'No Environment', name: Constants.NoEnvironmentSelectedName, @@ -28,6 +33,7 @@ export class HttpTestingController { // Create output channel private static readonly outputChannel = vscode.window.createOutputChannel('REST Client'); + constructor() { // Initialize logger with the output channel @@ -36,6 +42,9 @@ export class HttpTestingController { @trace('HTTP Testing') public async runHttpTest() { + + // Ensure output is visible + HttpTestingController.outputChannel.show(true); const activeEditor = vscode.window.activeTextEditor; if (!activeEditor) { log('No active editor found.', LogLevel.ERROR); @@ -54,13 +63,17 @@ export class HttpTestingController { try { log('Creating variables.json...', LogLevel.INFO); - + // Get current environment const currentEnvironment = await HttpTestingController.getCurrentEnvironment(); log(`Current environment: ${currentEnvironment.name}`, LogLevel.INFO); // Merge $shared and current environment variables, excluding auto_fetch_token_data const sharedVars = this.filterEnvironmentVars(this.settings.environmentVariables['$shared'] || {}); + const options = { + verbose: sharedVars.http_test_output_verbose + }; + const currentEnvVars = currentEnvironment.name !== Constants.NoEnvironmentSelectedName ? this.filterEnvironmentVars(this.settings.environmentVariables[currentEnvironment.name] || {}) : {}; @@ -81,8 +94,22 @@ export class HttpTestingController { log(`Variables written to: ${variablesPath}`, LogLevel.INFO); log("Starting test run...", LogLevel.INFO); + const variableManager = new VariableManager(); + await this.loadVariablesFile(variableManager, fileName, undefined); + const httpFileParser = new HttpFileParser(variableManager); + const requests: HttpRequest[] = await httpFileParser.parse(fileName); + const testManager = new TestManager(fileName); + const results = await testManager.run(requests, options); + + const failedTests = results.filter((result) => !result.passed); + if (failedTests.length > 0) { + log(`${failedTests.length} test(s) failed.`, LogLevel.ERROR); + } else { + log(`All tests passed successfully.`, LogLevel.INFO); + } } catch (error) { + HttpTestingController.outputChannel.show(true); // Make sure errors are visible log(`Failed to create variables.json: ${error instanceof Error ? error.message : String(error)}`, LogLevel.ERROR); vscode.window.showErrorMessage(`Failed to create variables.json: ${error instanceof Error ? error.message : String(error)}`); return; @@ -97,4 +124,20 @@ export class HttpTestingController { const currentEnvironment = await UserDataManager.getEnvironment() as EnvironmentPickItem | undefined; return currentEnvironment || this.noEnvironmentPickItem; } + + private async loadVariablesFile( + variableManager: VariableManager, + filePath: string, + varFile: string | undefined + ): Promise { + const variableFile = + varFile || path.join(path.dirname(filePath), "variables.json"); + if (await fileExists(variableFile)) { + log(`Loading variables from ${variableFile}`, LogLevel.INFO); + const variables = await loadVariables(variableFile); + variableManager.setVariables(variables); + } else { + log(`No variable file specified or found. Proceeding without external variables.`, LogLevel.INFO); + } + } } \ No newline at end of file diff --git a/src/errors/AssertionError.ts b/src/errors/AssertionError.ts new file mode 100644 index 00000000..c98d9d27 --- /dev/null +++ b/src/errors/AssertionError.ts @@ -0,0 +1,9 @@ +/** + * Represents an error that occurs during assertion. + */ +export class AssertionError extends Error { + constructor(message: string) { + super(message); + this.name = "AssertionError"; + } +} diff --git a/src/errors/ParserError.ts b/src/errors/ParserError.ts new file mode 100644 index 00000000..6ac4b7dc --- /dev/null +++ b/src/errors/ParserError.ts @@ -0,0 +1,9 @@ +/** + * Represents an error that occurs during parsing. + */ +export class ParserError extends Error { + constructor(message: string) { + super(message); + this.name = "ParserError"; + } +} diff --git a/src/errors/RequestError.ts b/src/errors/RequestError.ts new file mode 100644 index 00000000..cc5968ba --- /dev/null +++ b/src/errors/RequestError.ts @@ -0,0 +1,9 @@ +/** + * Represents an error that occurs during HTTP request execution. + */ +export class RequestError extends Error { + constructor(message: string) { + super(message); + this.name = "RequestError"; + } +} diff --git a/src/errors/ValidationError.ts b/src/errors/ValidationError.ts new file mode 100644 index 00000000..06a035b2 --- /dev/null +++ b/src/errors/ValidationError.ts @@ -0,0 +1,6 @@ +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } + } \ No newline at end of file diff --git a/src/http-test-core/AssertionEngine.ts b/src/http-test-core/AssertionEngine.ts new file mode 100644 index 00000000..8ee28b0d --- /dev/null +++ b/src/http-test-core/AssertionEngine.ts @@ -0,0 +1,302 @@ +import { + Assertion, + CustomValidatorContext, + HttpRequest, + HttpResponse, +} from "../types"; +import { JSONPath } from "jsonpath-plus"; +import { VariableManager } from "./VariableManager"; +import { logVerbose, logError } from "../utils/logger"; +import { loadCustomValidator } from "../validators/customValidator"; +import path from "path"; +import { AssertionError } from "../errors/AssertionError"; + +export class AssertionEngine { + constructor( + private variableManager: VariableManager, + private baseDir: string + ) {} + + async assert( + assertion: Assertion, + response: HttpResponse, + request: HttpRequest + ): Promise { + if (typeof assertion.value === "string") { + assertion.value = this.variableManager.replaceVariables(assertion.value); + } + + logVerbose(`Asserting ${JSON.stringify(assertion)}`); + + switch (assertion.type) { + case "status": + this.assertStatus(assertion, response); + break; + case "header": + this.assertHeader(assertion, response); + break; + case "body": + await this.assertBody(assertion, response); + break; + case "custom": + await this.assertCustom(assertion, response, request); + break; + default: + throw new AssertionError( + `Unknown assertion type: ${(assertion as Assertion).type}` + ); + } + } + + private assertStatus(assertion: Assertion, response: HttpResponse): void { + if (typeof assertion.value === "function") { + if (!assertion.value(response.status)) { + throw new AssertionError( + `Status ${response.status} does not meet the assertion criteria` + ); + } + } else if (typeof assertion.value === "string") { + const statusRange = assertion.value.toLowerCase(); + if (statusRange === "2xx") { + if (response.status < 200 || response.status >= 300) { + throw new AssertionError( + `Expected status in 2xx range, got ${response.status}` + ); + } + } else if (statusRange === "3xx") { + if (response.status < 300 || response.status >= 400) { + throw new AssertionError( + `Expected status in 3xx range, got ${response.status}` + ); + } + } else if (statusRange === "4xx") { + if (response.status < 400 || response.status >= 500) { + throw new AssertionError( + `Expected status in 4xx range, got ${response.status}` + ); + } + } else if (statusRange === "5xx") { + if (response.status < 500 || response.status >= 600) { + throw new AssertionError( + `Expected status in 5xx range, got ${response.status}` + ); + } + } else { + throw new AssertionError(`Invalid status range: ${statusRange}`); + } + } else if (typeof assertion.value === "number") { + if (response.status !== assertion.value) { + throw new AssertionError( + `Expected status ${assertion.value}, got ${response.status}` + ); + } + } else { + throw new AssertionError("Invalid assertion value for status"); + } + } + + private assertHeader(assertion: Assertion, response: HttpResponse): void { + if (!assertion.key) { + throw new AssertionError("Header key is missing in assertion"); + } + const headerValue = response.headers[assertion.key.toLowerCase()]; + if ( + typeof headerValue === "string" && + typeof assertion.value === "string" + ) { + if (assertion.key.toLowerCase() === "content-type") { + this.assertContentType(headerValue, assertion.value); + } else if (headerValue !== assertion.value) { + throw new AssertionError( + `Expected ${assertion.key} to be ${assertion.value}, got ${headerValue}` + ); + } + } else { + throw new AssertionError( + `Invalid header value type for ${assertion.key}` + ); + } + } + + private assertContentType(actual: string, expected: string): void { + const expectedType = expected.split(";")[0].trim(); + const actualType = actual.split(";")[0].trim(); + if (expectedType !== actualType) { + throw new AssertionError( + `Expected Content-Type to be ${expectedType}, got ${actualType}` + ); + } + } + + private async assertBody( + assertion: Assertion, + response: HttpResponse + ): Promise { + let responseData = this.parseResponseData(response.data); + + if (assertion.key === "$") { + return; + } else if ( + typeof assertion.key === "string" && + assertion.key.startsWith("$") + ) { + this.assertJsonPath(assertion, responseData); + } else { + throw new AssertionError(`Invalid body assertion key: ${assertion.key}`); + } + } + + private parseResponseData(data: unknown): object { + if (typeof data === "string" && data.trim() !== "") { + try { + return JSON.parse(data); + } catch (error) { + throw new AssertionError( + `Failed to parse response data as JSON: ${error}` + ); + } + } + return data as object; + } + + private assertJsonPath(assertion: Assertion, responseData: object): void { + const jsonPath = this.adjustJsonPath(assertion.key as string, responseData); + const result = JSONPath({ path: jsonPath, json: responseData }); + if (result.length === 0) { + throw new AssertionError( + `JSON path ${jsonPath} not found in response: ${JSON.stringify( + responseData + )}` + ); + } + const actualValue = result[0]; + const expectedValue = this.parseValue(assertion.value as string); + if (!this.isEqual(actualValue, expectedValue)) { + throw new AssertionError( + `Expected ${jsonPath} to be ${JSON.stringify( + expectedValue + )}, got ${JSON.stringify(actualValue)}` + ); + } + } + + private parseValue( + value: string | number | boolean + ): string | number | boolean { + if (typeof value === "string") { + if (!isNaN(Number(value))) { + return Number(value); + } else if (value.toLowerCase() === "true") { + return true; + } else if (value.toLowerCase() === "false") { + return false; + } + } + return value; + } + + private adjustJsonPath(jsonPath: string, data: unknown): string { + if ( + typeof data === "object" && + data !== null && + !Array.isArray(data) && + jsonPath.startsWith("$[") + ) { + return "$" + jsonPath.slice(2); + } + return jsonPath; + } + + private isEqual(actual: unknown, expected: unknown): boolean { + if (Array.isArray(actual) && Array.isArray(expected)) { + return ( + actual.length === expected.length && + actual.every((value, index) => this.isEqual(value, expected[index])) + ); + } + if ( + typeof actual === "object" && + actual !== null && + typeof expected === "object" && + expected !== null + ) { + const actualKeys = Object.keys(actual as object); + const expectedKeys = Object.keys(expected as object); + return ( + actualKeys.length === expectedKeys.length && + actualKeys.every((key) => + this.isEqual( + (actual as Record)[key], + (expected as Record)[key] + ) + ) + ); + } + return actual === expected; + } + + private async assertCustom( + assertion: Assertion, + response: HttpResponse, + request: HttpRequest + ): Promise { + const functionPath = assertion.value as string; + + try { + logVerbose( + `Running custom validator script: ${functionPath}` + ); + await this.runCustomValidator( + functionPath, + response, + request + ); + logVerbose(`Custom validator script executed successfully`); + } catch (error) { + logError( + `Custom validation failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); + throw new AssertionError( + `Custom validation failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + private async runCustomValidator( + customFunctionPath: string, + response: HttpResponse, + request: HttpRequest + ): Promise { + const resolvedPath = this.resolvePath(customFunctionPath); + const customValidator = await loadCustomValidator(resolvedPath); + + const context: CustomValidatorContext = { + request, + variables: this.variableManager.getAllVariables(), + }; + + try { + logVerbose(`Executing custom validator from path: ${resolvedPath}`); + customValidator(response, context); + logVerbose(`Custom validator executed without error`); + } catch (error) { + logError( + `Error in custom validator: ${ + error instanceof Error ? error.message : String(error) + }` + ); + throw error; + } + } + + private resolvePath(customFunctionPath: string): string { + if (path.isAbsolute(customFunctionPath)) { + return customFunctionPath; + } + return path.resolve(this.baseDir, customFunctionPath); + } +} diff --git a/src/http-test-core/HttpFileParser.ts b/src/http-test-core/HttpFileParser.ts new file mode 100644 index 00000000..f90852d8 --- /dev/null +++ b/src/http-test-core/HttpFileParser.ts @@ -0,0 +1,60 @@ +import { HttpRequestParser } from './HttpRequestParser'; +import { HttpRequest } from "../types"; +import { readFile } from "../utils/fileUtils"; +import { logVerbose } from "../utils/logger"; +import { VariableManager } from "./VariableManager"; + +export class HttpFileParser { + private variableManager: VariableManager; + + constructor(variableManager: VariableManager) { + this.variableManager = variableManager; + } + + async parse(filePath: string): Promise { + const content = await readFile(filePath); + logVerbose(`File content loaded: ${filePath}`); + const cleanedContent = this.removeComments(content); + const sections = this.splitIntoSections(cleanedContent); + return this.parseRequests(sections); + } + + private removeComments(content: string): string { + return content.split('\n').filter(line => { + const trimmedLine = line.trim(); + return !trimmedLine.startsWith('#') || trimmedLine.startsWith('###') || trimmedLine.startsWith('####'); + }).join('\n'); + } + + private splitIntoSections(content: string): string[] { + return content.split(/(?=^###\s)/m).filter(section => section.trim() !== ''); + } + + private parseRequests(sections: string[]): HttpRequest[] { + const requestParser = new HttpRequestParser(this.variableManager); + const requests: HttpRequest[] = []; + + for (const section of sections) { + if (section.startsWith('@')) { + this.handleGlobalVariables(section); + } else { + const request = requestParser.parse(section); + requests.push(request); + } + } + + logVerbose(`Total parsed requests: ${requests.length}`); + return requests; + } + + private handleGlobalVariables(section: string): void { + const lines = section.split('\n'); + for (const line of lines) { + if (line.startsWith('@')) { + const [key, value] = line.slice(1).split('=').map(s => s.trim()); + this.variableManager.setVariable(key, value); + logVerbose(`Set global variable: ${key} = ${value}`); + } + } + } +} diff --git a/src/http-test-core/HttpRequestParser.ts b/src/http-test-core/HttpRequestParser.ts new file mode 100644 index 00000000..13106beb --- /dev/null +++ b/src/http-test-core/HttpRequestParser.ts @@ -0,0 +1,116 @@ +import { HttpRequest, HttpMethod } from "../types"; +import { logVerbose } from "../utils/logger"; +import { VariableManager } from "./VariableManager"; +import { TestParser } from "./TestParser"; + +export class HttpRequestParser { + private variableManager: VariableManager; + private testParser: TestParser; + + constructor(variableManager: VariableManager) { + this.variableManager = variableManager; + this.testParser = new TestParser(variableManager); + } + + parse(section: string): HttpRequest { + const lines = section.split('\n'); + const request = this.initializeRequest(lines[0]); + + const [requestLines, testLines] = this.splitRequestAndTestLines(lines.slice(1)); + + this.parseRequestLines(requestLines, request); + const { tests, variableUpdates } = this.testParser.parse(testLines); + request.tests = tests; + request.variableUpdates.push(...variableUpdates); + + return request; + } + + private initializeRequest(firstLine: string): HttpRequest { + return { + name: firstLine.replace(/^###\s*/, '').trim(), + method: 'GET', + url: '', + headers: {}, + tests: [], + variableUpdates: [], + }; + } + + private splitRequestAndTestLines(lines: string[]): [string[], string[]] { + const testStartIndex = lines.findIndex(line => line.startsWith('####')); + if (testStartIndex === -1) { + return [lines, []]; + } + return [lines.slice(0, testStartIndex), lines.slice(testStartIndex)]; + } + + private parseRequestLines(lines: string[], request: HttpRequest): void { + let isParsingBody = false; + let bodyContent = ''; + + for (const line of lines) { + if (line.trim() === '') { + isParsingBody = true; + continue; + } + + if (isParsingBody) { + bodyContent += line + '\n'; + } else if (line.startsWith('@')) { + this.handleVariable(line, request); + } else if (this.isHttpMethod(line)) { + this.setRequestMethod(line, request); + } else if (line.includes(':')) { + this.handleHeader(line, request); + } + } + + if (bodyContent.trim()) { + request.body = this.parseBody(bodyContent); + } + } + + private isHttpMethod(line: string): boolean { + return /^(GET|POST|PUT|DELETE|PATCH)\s/.test(line); + } + + private setRequestMethod(line: string, request: HttpRequest): void { + const [method, url] = line.split(/\s+/); + request.method = method as HttpMethod; + + // Decode URL after variable replacement + const replacedUrl = this.variableManager.replaceVariables(url.trim()); + try { + request.url = decodeURIComponent(replacedUrl); + logVerbose(`Set method: ${request.method}, URL: ${request.url}`); + } catch (error) { + // If decoding fails, use the URL as-is + request.url = replacedUrl; + logVerbose(`URL decoding failed, using raw URL: ${request.url}`); + } +} + + private handleHeader(line: string, request: HttpRequest): void { + const [key, ...valueParts] = line.split(':'); + const value = valueParts.join(':').trim(); + request.headers[key.trim()] = this.variableManager.replaceVariables(value); + logVerbose(`Added header: ${key.trim()}: ${request.headers[key.trim()]}`); + } + + private handleVariable(line: string, request: HttpRequest): void { + const [key, value] = line.slice(1).split('=').map(s => s.trim()); + if (value.startsWith('$.')) { + // JSONPath ν‘œν˜„μ‹μ„ μ‚¬μš©ν•˜λŠ” λ³€μˆ˜ μ—…λ°μ΄νŠΈ + request.variableUpdates.push({ key, value }); + } else { + // 일반 λ³€μˆ˜ μ„€μ • + this.variableManager.setVariable(key, value); + } + logVerbose(`Added variable update: ${key} = ${value}`); + } + + private parseBody(bodyContent: string): string { + return bodyContent.trim(); + } +} \ No newline at end of file diff --git a/src/http-test-core/RequestExecutor.ts b/src/http-test-core/RequestExecutor.ts new file mode 100644 index 00000000..72c8ed7c --- /dev/null +++ b/src/http-test-core/RequestExecutor.ts @@ -0,0 +1,279 @@ +import fetch from 'node-fetch'; +import { HttpRequest, HttpResponse } from "../types"; +import { VariableManager } from "./VariableManager"; +import { logVerbose, logError } from "../utils/logger"; +import { URL } from "url"; +import { RequestError } from "../errors/RequestError"; +import FormData from "form-data"; +import fs from "fs"; +import path from "path"; +import https from "https"; +import { JsonUtils } from "../utils/jsonUtils"; + +/** + * Executes HTTP requests and processes responses. + */ +export class RequestExecutor { + private serverCheckTimeout = 5000; + private requestTimeout = 10000; + private agent = new https.Agent({ + rejectUnauthorized: false, + }); + + /** + * Creates an instance of RequestExecutor. + * @param variableManager - The VariableManager instance to use. + */ + constructor( + private variableManager: VariableManager, + private baseDir: string + ) {} + + async execute(request: HttpRequest): Promise { + const processedRequest = this.applyVariables(request); + logVerbose( + `Executing request: ${processedRequest.method} ${processedRequest.url}` + ); + + try { + await this.validateUrl(processedRequest.url); + await this.checkServerStatus(processedRequest.url); + const response = await this.sendRequest(processedRequest); + + logVerbose("Full response:"); + logVerbose(`Status: ${response.status}`); + logVerbose( + `Headers: ${JSON.stringify( + Object.fromEntries(response.headers.entries()), + null, + 2 + )}` + ); + + const responseData = await response.text(); + const parsedData = JsonUtils.parseJson(responseData) || responseData; + logVerbose(`Data: ${JSON.stringify(parsedData, null, 2)}`); + + return { + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + data: parsedData + }; + } catch (error) { + return this.handleRequestError(error, processedRequest); + } + } + + private applyVariables(request: HttpRequest): HttpRequest { + return { + ...request, + url: this.variableManager.replaceVariables(request.url), + headers: Object.fromEntries( + Object.entries(request.headers).map(([key, value]) => [ + key, + this.variableManager.replaceVariables(value), + ]) + ), + body: + typeof request.body === "string" + ? this.variableManager.replaceVariables(request.body) + : request.body, + }; + } + + private async validateUrl(url: string): Promise { + try { + new URL(url); + } catch { + throw new RequestError(`Invalid URL: ${url}`); + } + } + + private async checkServerStatus(url: string): Promise { + try { + await fetch(url, { + method: "HEAD", + timeout: this.serverCheckTimeout, + agent: this.agent, + }); + } catch (error) { + if (error instanceof Error && "type" in error && error.type === "request-timeout") { + throw new RequestError( + `Server is not responding at ${url}. Please check if the server is running.` + ); + } + } + } + + private async sendRequest(request: HttpRequest) { + const { method, url, headers, body } = request; + + let data: string | FormData | undefined = body as string; + let requestHeaders = { ...headers }; + + const contentType = headers["Content-Type"] || headers["content-type"]; + if (contentType) { + if (contentType.includes("application/json")) { + data = typeof body === "string" ? body : JSON.stringify(body); + } else if (contentType.includes("multipart/form-data")) { + const formData = this.parseFormData(headers, body as string); + data = formData; + + delete requestHeaders["Content-Type"]; + delete requestHeaders["content-type"]; + requestHeaders = { + ...requestHeaders, + ...formData.getHeaders(), + }; + } + } + + logVerbose(`Sending request with config:`, { + method, + url, + headers: requestHeaders, + body: data instanceof FormData ? "[FormData]" : data, + }); + + return fetch(url, { + method, + headers: requestHeaders, + body: data, + agent: this.agent, + timeout: this.requestTimeout, + }); + } + + private parseFormData( + headers: Record, + body: string + ): FormData { + const formData = new FormData(); + const contentType = headers["Content-Type"] || headers["content-type"]; + if (!contentType) { + throw new Error("Content-Type header not found."); + } + const boundaryMatch = contentType.match(/boundary=(.+)$/); + if (!boundaryMatch) { + throw new Error("Boundary not found in Content-Type header."); + } + const boundary = boundaryMatch[1].trim(); + + const parts = body.split(new RegExp(`--${boundary}`)); + parts.forEach((part) => { + if (part.trim().length === 0 || part == "--") return; + + this.buildFormData(formData, part); + }); + + return formData; + } + + private buildFormData(formData: FormData, part: string) { + const lines = part.split("\r\n"); + const headers: Record = {}; + let name: string | null = null; + let filename: string | null = null; + let contentType: string | null = null; + let content: string | null = null; + + lines.forEach((line) => { + if (line.trim().length === 0) return; + + const headerMatch = line.match(/(.+?): (.+)/); + if (headerMatch) { + headers[headerMatch[1].toLowerCase()] = headerMatch[2]; + } else { + if (content == null) { + content = line; + } else { + content += "\r\n" + line; + } + } + }); + + const contentDisposition = headers["content-disposition"]; + if (contentDisposition) { + const match = contentDisposition.match(/name="(.+?)"/); + if (match) { + name = match[1]; + } + const filenameMatch = contentDisposition.match(/filename="(.+?)"/); + if (filenameMatch) { + filename = filenameMatch[1]; + } + } + + const contentTypeHeader = headers["content-type"]; + if (contentTypeHeader) { + contentType = contentTypeHeader; + } + + if (!name) { + throw new Error("Name not found in Content-Disposition header."); + } + + let options: { + filename?: string; + contentType?: string; + } = {}; + if (filename) { + options.filename = filename; + } + if (contentType) { + options.contentType = contentType; + } + + if (filename && content) { + const [, filePath] = (content as string).split(" "); + + if (filePath) { + const absoluteFilePath = path.resolve(this.baseDir, filePath); + if (!fs.existsSync(absoluteFilePath)) { + throw new Error(filePath + " is not found."); + } + + formData.append(name, fs.createReadStream(absoluteFilePath), options); + } else { + throw new Error("Invalid file path format."); + } + } else { + const value = content!; + formData.append(name, value, options); + } + } + + private async handleRequestError( + error: unknown, + request: HttpRequest + ): Promise { + if (error instanceof Error) { + if ("type" in error && error.type === "request-timeout") { + await logError(`Request timeout: ${error.message}`); + throw new RequestError( + `Request to ${request.url} timed out. Please check your network connection and server status.` + ); + } + if ("response" in error) { + const response = (error as any).response; + await logError(`Request failed with status ${response.status}: ${error.message}`); + return { + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + data: await response.text() + }; + } + } + + logError( + `Request failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); + throw new RequestError( + `Request to ${request.url} failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} diff --git a/src/http-test-core/ResponseProcessor.ts b/src/http-test-core/ResponseProcessor.ts new file mode 100644 index 00000000..8934d107 --- /dev/null +++ b/src/http-test-core/ResponseProcessor.ts @@ -0,0 +1,77 @@ +import { HttpResponse, VariableUpdate } from "../types"; +import { VariableManager } from "./VariableManager"; +import { JSONPath } from "jsonpath-plus"; +import { logVerbose, logWarning } from "../utils/logger"; + +export class ResponseProcessor { + constructor(private variableManager: VariableManager) {} + + async process( + response: HttpResponse, + variableUpdates: VariableUpdate[] + ): Promise { + logVerbose(`Processing response with status ${response.status}`); + await this.processVariableUpdates(variableUpdates, response); + } + + private async processVariableUpdates( + updates: VariableUpdate[], + response: HttpResponse + ): Promise { + let responseBody; + if (response.data && typeof response.data === 'string' && response.data.trim() !== '') { + try { + responseBody = JSON.parse(response.data); + } catch (error) { + logWarning(`Failed to parse response data as JSON: ${error}`); + responseBody = response.data; + } + } else { + responseBody = response.data || {}; + } + + for (const update of updates) { + let value: string | number | boolean; + + value = this.evaluateExpression(update.value, responseBody); + + if (typeof value === 'string' && !isNaN(Number(value))) { + value = Number(value); + } + + this.variableManager.setVariable(update.key, value); + logVerbose(`Updated variable: ${update.key} = ${value}`); + } + } + + private evaluateExpression(expression: string, context: unknown): string | number | boolean { + if (expression.startsWith('$.')) { + // JSONPath + return this.extractValueFromJsonPath(expression, context as string); + } else if (expression.startsWith('{{') && expression.endsWith('}}')) { + // Simple variable reference + const varName = expression.slice(2, -2).trim(); + return this.variableManager.getVariable(varName) || ''; + } else if (expression.includes('{{')) { + // String with embedded variables + return this.variableManager.replaceVariables(expression); + } else if (expression.toLowerCase() === 'true' || expression.toLowerCase() === 'false') { + // Boolean + return expression.toLowerCase() === 'true'; + } else if (expression.startsWith('"') && expression.endsWith('"')) { + // Quoted string + return expression.slice(1, -1); + } else { + // Treat as a plain string or number + return isNaN(Number(expression)) ? expression : Number(expression); + } + } + + private extractValueFromJsonPath(jsonPath: string, json: string): string | number | boolean { + const result = JSONPath({ path: jsonPath, json }); + if (!Array.isArray(result) || result.length === 0) { + throw new Error(`JSONPath ${jsonPath} not found in response`); + } + return result[0]; + } +} diff --git a/src/http-test-core/TestManager.ts b/src/http-test-core/TestManager.ts new file mode 100644 index 00000000..72cd4b25 --- /dev/null +++ b/src/http-test-core/TestManager.ts @@ -0,0 +1,160 @@ +import { + HttpRequest, + TestResult, + RunOptions, + HttpResponse, + TestItem, + Assertion, + LogLevel, +} from "../types"; +import { AssertionEngine } from "./AssertionEngine"; +import { VariableManager } from "./VariableManager"; +import { RequestExecutor } from "./RequestExecutor"; +import { ResponseProcessor } from "./ResponseProcessor"; +import { TestResultCollector } from "./TestResultCollector"; +import { + logRequestStart, + logTestResult, + logTestSummary, + log, + setVerbose, + logError, +} from "../utils/logger"; +import path from "path"; + +export class TestManager { + private requestExecutor: RequestExecutor; + private responseProcessor: ResponseProcessor; + private resultCollector: TestResultCollector; + private variableManager: VariableManager; + private assertionEngine: AssertionEngine; + private baseDir: string; + + constructor(httpFilePath: string) { + this.baseDir = path.dirname(httpFilePath); + this.variableManager = new VariableManager(); + this.assertionEngine = new AssertionEngine(this.variableManager, this.baseDir); + this.requestExecutor = new RequestExecutor(this.variableManager, this.baseDir); + this.responseProcessor = new ResponseProcessor(this.variableManager); + this.resultCollector = new TestResultCollector(); + } + + async run( + requests: HttpRequest[], + options?: RunOptions + ): Promise { + setVerbose(!!options?.verbose); + + for (const request of requests) { + try { + await this.processRequest(request); + } catch (error) { + await this.handleRequestError(error, request); + } + } + + const summary = this.resultCollector.getSummary(); + await logTestSummary(summary); + + return this.resultCollector.getResults(); + } + + private async processRequest(request: HttpRequest): Promise { + logRequestStart(request); + try { + const response = await this.requestExecutor.execute(request); + await this.responseProcessor.process(response, request.variableUpdates); + const testResults = await this.runTests(request, response); + for (const result of testResults) { + this.resultCollector.addResult(result); + logTestResult(result); + } + } catch (error) { + const errorMessage = `Request failed: ${request.name}\n${ + error instanceof Error ? error.message : String(error) + }`; + logError(errorMessage); + this.resultCollector.addResult({ + name: request.name, + passed: false, + error: new Error(errorMessage), + statusCode: undefined, + }); + } + } + + private async runTests( + request: HttpRequest, + response: HttpResponse + ): Promise { + const tests = + request.tests.length > 0 + ? request.tests + : [this.createDefaultStatusCodeTest(request.name)]; + const results: TestResult[] = []; + + for (const test of tests) { + try { + for (const assertion of test.assertions) { + await this.assertionEngine.assert(assertion, response, request); + } + results.push(this.createTestResult(test, request, response, true)); + } catch (error) { + results.push( + this.createTestResult(test, request, response, false, error) + ); + } + } + + return results; + } + + private createTestResult( + test: TestItem, + request: HttpRequest, + response: HttpResponse, + passed: boolean, + error?: unknown + ): TestResult { + const expectedErrorPassed = request.expectError && !passed; + return { + name: test.name || request.name, + passed: expectedErrorPassed || passed, + statusCode: response.status, + error: passed + ? undefined + : error instanceof Error + ? error + : new Error(String(error)), + }; + } + + private createDefaultStatusCodeTest(name: string): TestItem { + return { + type: "Assert", + name: `${name} Status OK`, + assertions: [ + { + type: "status", + value: (status: number) => status >= 200 && status < 300, + } as Assertion, + ], + }; + } + + private async handleRequestError( + error: unknown, + request: HttpRequest + ): Promise { + const errorMessage = `Request failed: ${request.name}\n${ + error instanceof Error ? error.message : String(error) + }`; + log(errorMessage, LogLevel.ERROR); + this.resultCollector.addResult({ + name: request.name, + passed: false, + error: new Error(errorMessage), + statusCode: undefined, + }); + } +} \ No newline at end of file diff --git a/src/http-test-core/TestParser.ts b/src/http-test-core/TestParser.ts new file mode 100644 index 00000000..ee72a4f5 --- /dev/null +++ b/src/http-test-core/TestParser.ts @@ -0,0 +1,103 @@ +import { TestItem, Assertion } from "../types"; +import { VariableManager } from "./VariableManager"; + +export class TestParser { + private variableManager: VariableManager; + + constructor(variableManager: VariableManager) { + this.variableManager = variableManager; + } + + parse(lines: string[]): { + tests: TestItem[]; + variableUpdates: { key: string; value: string }[]; + } { + const tests: TestItem[] = []; + const variableUpdates: { key: string; value: string }[] = []; + let currentTest: TestItem | null = null; + for (const line of lines) { + + if (line.startsWith("####")) { + if (currentTest) { + tests.push(currentTest); + } + currentTest = this.createNewTest(line); + } else if (line.trim().match(/^@\w+\s*=\s*.+/)) { + + // λ³€μˆ˜ μ—…λ°μ΄νŠΈ 라인 처리 + const [key, value] = line.split(/\s*=\s*/, 2); + variableUpdates.push({ + key: key.slice(1).trim(), + value: this.variableManager.replaceVariables(value.trim()) + }); + } else if (currentTest && line.includes(":")) { + const assertion = this.parseAssertion(line); + if (assertion) { + currentTest.assertions.push(assertion); + } + } + } + if (currentTest) { + tests.push(currentTest); + } + return { tests, variableUpdates }; + } + + private createNewTest(line: string): TestItem { + return { + type: "Assert", + name: line.replace(/^####\s*/, "").trim(), + assertions: [], + }; + } + + private parseAssertion(line: string): Assertion | null { + const [key, ...valueParts] = line.split(":"); + const value = valueParts.join(":").trim(); + switch (key.trim().toLowerCase()) { + case "status": + return this.parseStatusAssertion(value); + case "content-type": + return { type: "header", key: "Content-Type", value }; + case "body": + return null; + case "_customassert": + return { + type: "custom", + value: this.variableManager.replaceVariables(value), + }; + default: + if (key.trim().startsWith("$")) { + return { + type: "body", + key: key.trim(), + value: this.parseValue(value), + }; + } + return { type: "body", key: key.trim(), value }; + } + } + + private parseStatusAssertion(value: string): Assertion | null { + const statusValue = value.trim().toLowerCase(); + if (["2xx", "3xx", "4xx", "5xx"].includes(statusValue)) { + return { type: "status", value: statusValue }; + } + const statusCode = parseInt(value, 10); + if (!isNaN(statusCode)) { + return { type: "status", value: statusCode }; + } + return null; + } + + private parseValue(value: string): string | number | boolean { + if (!isNaN(Number(value))) { + return Number(value); + } else if (value.toLowerCase() === "true") { + return true; + } else if (value.toLowerCase() === "false") { + return false; + } + return this.variableManager.replaceVariables(value); + } +} diff --git a/src/http-test-core/TestResultCollector.ts b/src/http-test-core/TestResultCollector.ts new file mode 100644 index 00000000..4738cf57 --- /dev/null +++ b/src/http-test-core/TestResultCollector.ts @@ -0,0 +1,26 @@ +import { TestResult, TestSummary } from '../types'; + +export class TestResultCollector { + private results: TestResult[] = []; + + addResult(result: TestResult): void { + this.results.push(result); + } + + getResults(): TestResult[] { + return this.results; + } + + getSummary(): TestSummary { + const totalTests = this.results.length; + const passedTests = this.results.filter(r => r.passed).length; + const failedTests = totalTests - passedTests; + + return { + totalTests, + passedTests, + failedTests, + results: this.results + }; + } +} \ No newline at end of file diff --git a/src/http-test-core/VariableManager.ts b/src/http-test-core/VariableManager.ts new file mode 100644 index 00000000..2da88300 --- /dev/null +++ b/src/http-test-core/VariableManager.ts @@ -0,0 +1,28 @@ +import { Variables } from '../types'; +import { logVerbose } from '../utils/logger'; +import { replaceVariablesInString } from '../utils/variableUtils'; + +export class VariableManager { + private variables: Variables = {}; + + setVariables(variables: Variables): void { + this.variables = { ...this.variables, ...variables }; + } + + replaceVariables(content: string): string { + return replaceVariablesInString(content, this.variables); + } + + setVariable(key: string, value: string | number | boolean): void { + this.variables[key] = value; + logVerbose(`Set variable: ${key} = ${value}`); + } + + getVariable(key: string): string | number | boolean | undefined { + return this.variables[key]; + } + + getAllVariables(): Variables { + return this.variables; + } +} diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts new file mode 100644 index 00000000..fdce08ec --- /dev/null +++ b/src/utils/fileUtils.ts @@ -0,0 +1,28 @@ +import fs from 'fs/promises'; +import { Variables } from '../types'; + +export async function readFile(filePath: string): Promise { + try { + return await fs.readFile(filePath, 'utf-8'); + } catch (error) { + throw new Error(`Failed to read file ${filePath}: ${error}`); + } +} + +export async function loadVariables(filePath: string): Promise { + try { + const content = await readFile(filePath); + return JSON.parse(content); + } catch (error) { + throw new Error(`Failed to load variables from ${filePath}: ${error}`); + } +} + +export async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} \ No newline at end of file diff --git a/src/utils/httpUtils.ts b/src/utils/httpUtils.ts new file mode 100644 index 00000000..c200ff1a --- /dev/null +++ b/src/utils/httpUtils.ts @@ -0,0 +1,17 @@ +import { AxiosResponse } from "axios"; +import { HttpResponse } from "../types"; + +export function convertAxiosResponse( + axiosResponse: AxiosResponse +): HttpResponse { + return { + status: axiosResponse.status, + headers: Object.fromEntries( + Object.entries(axiosResponse.headers).map(([key, value]) => [ + key.toLowerCase(), + String(value), + ]) + ), + data: axiosResponse.data !== undefined ? axiosResponse.data : null, + }; +} diff --git a/src/utils/jsonUtils.ts b/src/utils/jsonUtils.ts new file mode 100644 index 00000000..c93f0d0b --- /dev/null +++ b/src/utils/jsonUtils.ts @@ -0,0 +1,44 @@ +import JSON5 from 'json5'; +import { logVerbose, logError } from './logger'; + +export class JsonUtils { + static parseJson(body: string | undefined): object | undefined { + if (!body) return undefined; + + // Remove any non-JSON content after the last closing brace + const jsonEndIndex = body.lastIndexOf('}'); + if (jsonEndIndex !== -1) { + body = body.substring(0, jsonEndIndex + 1); + } + + body = body.trim(); + + try { + // First, try standard JSON parsing + return JSON.parse(body); + } catch (error) { + logVerbose(`Standard JSON parse failed, attempting JSON5 parsing: ${error}`); + + try { + // Try parsing with JSON5 + const parsed = JSON5.parse(body); + logVerbose(`JSON5 parsing succeeded`); + return parsed; + } catch (json5Error) { + logError(`Failed to parse JSON body even with JSON5: ${json5Error}`); + logVerbose(`Raw body content: ${body}`); + + // If all else fails, try to salvage the JSON by removing trailing commas + try { + const withoutTrailingCommas = body.replace(/,\s*([\]}])/g, '$1'); + const salvaged = JSON5.parse(withoutTrailingCommas); + logVerbose(`Salvaged JSON parsing succeeded`); + return salvaged; + } catch (salvageError) { + logError(`Failed to salvage JSON: ${salvageError}`); + return {}; + } + } + } + } +} \ No newline at end of file diff --git a/src/utils/variableUtils.ts b/src/utils/variableUtils.ts new file mode 100644 index 00000000..7673b592 --- /dev/null +++ b/src/utils/variableUtils.ts @@ -0,0 +1,14 @@ +import { Variables } from "../types"; +import { logVerbose } from "./logger"; + +export function replaceVariablesInString( + content: string, + variables: Variables +): string { + return content.replace(/\{\{(.+?)\}\}/g, (_, key) => { + const trimmedKey = key.trim(); + const value = variables[trimmedKey]; + logVerbose(`Replacing variable: {{${trimmedKey}}} with ${value}`); + return value !== undefined ? String(value) : `{{${trimmedKey}}}`; + }); +} \ No newline at end of file diff --git a/src/validators/customValidator.ts b/src/validators/customValidator.ts new file mode 100644 index 00000000..44e7139c --- /dev/null +++ b/src/validators/customValidator.ts @@ -0,0 +1,46 @@ +import vm from "vm"; +import { readFile } from "../utils/fileUtils"; +import { HttpResponse, CustomValidatorContext } from "../types"; +import path from "path"; +import fs from "fs"; +import http from "http"; + +export async function loadCustomValidator( + functionPath: string +): Promise<(response: HttpResponse, context: CustomValidatorContext) => void> { + const script = await readFile(functionPath); + + const sandbox = { + module: { exports: {} }, + async import(moduleName: string) { + if (['path', 'fs', 'http'].includes(moduleName)) { + switch (moduleName) { + case 'path': + return path; + case 'fs': + return fs; + case 'http': + return http; + } + } + throw new Error(`Module ${moduleName} is not allowed`); + }, + console, + }; + + vm.createContext(sandbox); + await vm.runInContext(` + (async () => { + ${script} + })(); + `, sandbox); + + if (typeof sandbox.module.exports !== "function") { + throw new Error("Custom validator must export a function"); + } + + return sandbox.module.exports as ( + response: HttpResponse, + context: CustomValidatorContext + ) => void; +} \ No newline at end of file From fb3d9a78b374915bfa69911e674a7316e2237f7f Mon Sep 17 00:00:00 2001 From: gotstu Date: Fri, 28 Mar 2025 16:14:06 -0700 Subject: [PATCH 5/9] updated selector to show Send Request only when exactly 3 ### --- src/utils/selector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/selector.ts b/src/utils/selector.ts index a1ea6621..167b76bc 100644 --- a/src/utils/selector.ts +++ b/src/utils/selector.ts @@ -239,7 +239,7 @@ export class Selector { private static getDelimiterRows(lines: string[]): number[] { return Object.entries(lines) - .filter(([, value]) => /^#{3,}/.test(value)) + .filter(([, value]) => /^###$/.test(value.trim())) // Changed regex to match exactly 3 # .map(([index, ]) => +index); } From f95220af6a08fbe908ac1e4911f5d9e25eedfc8a Mon Sep 17 00:00:00 2001 From: gotstu Date: Mon, 31 Mar 2025 12:38:34 -0700 Subject: [PATCH 6/9] removed use of variables.json, --- src/controllers/environmentController.ts | 43 +++++++++++++----------- src/controllers/httpTestingController.ts | 32 ++++++++++++------ 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/src/controllers/environmentController.ts b/src/controllers/environmentController.ts index 4c8448dc..a7d9f58f 100644 --- a/src/controllers/environmentController.ts +++ b/src/controllers/environmentController.ts @@ -100,23 +100,28 @@ export class EnvironmentController { if (!this.settings.environmentVariables[EnvironmentController.sharedEnvironmentName]) { this.settings.environmentVariables[EnvironmentController.sharedEnvironmentName] = {}; } - + this.settings.environmentVariables[EnvironmentController.sharedEnvironmentName].token = token; - - // Update settings + + // Check if workspace exists and has settings + const workspaceConfig = vscode.workspace.getConfiguration('rest-client'); + const hasWorkspaceConfig = workspaceConfig.inspect('environmentVariables')?.workspaceValue !== undefined; + + // Update settings at workspace level if available, otherwise fallback to global await vscode.workspace.getConfiguration().update( 'rest-client.environmentVariables', this.settings.environmentVariables, - vscode.ConfigurationTarget.Global + hasWorkspaceConfig ? vscode.ConfigurationTarget.Workspace : vscode.ConfigurationTarget.Global ); - - console.log(`Token updated in $shared environment variables`); + + const scope = hasWorkspaceConfig ? 'workspace' : 'global'; + console.log(`Token updated in $shared environment variables (${scope} scope)`); } catch (error) { vscode.window.showErrorMessage(`Failed to update token in shared environment: ${error}`); } } - private getNestedValue(obj: any, path: string[]): any { + private getNestedValue(obj: any, path: string[]): any { return path.reduce((acc, key) => acc && acc[key], obj); } @@ -132,7 +137,7 @@ export class EnvironmentController { vscode.window.showErrorMessage('Please open a .http file to continue'); return ''; } - + const envFilePath = path.join(path.dirname(activeEditor.document.fileName), `.env`); let clientId: string | undefined; let clientSecret: string | undefined; @@ -141,18 +146,18 @@ export class EnvironmentController { vscode.window.showErrorMessage(`client_id_variable_name and client_secret_variable_name must configured in settings.`); return ''; } - + try { if (fs.existsSync(envFilePath)) { const envContent = await fs.promises.readFile(envFilePath, 'utf8'); const lines = envContent.split('\n'); - + for (const line of lines) { const [key, value] = line.split('=').map(part => part.trim()); if (key === autoFetchTokenData.client_id_variable_name) clientId = value; if (key === autoFetchTokenData.client_secret_variable_name) clientSecret = value; } - }else { + } else { // Create new .env file if it doesn't exist await fs.promises.writeFile(envFilePath, '', { flag: 'w' }); vscode.window.showInformationMessage('Created new .env file. Please add your client credentials.'); @@ -162,13 +167,13 @@ export class EnvironmentController { vscode.window.showErrorMessage(`auto_fetch_token_data is configured for environment but there is no .env created adjacent to this http file.`); return ''; } - + if (!clientId || !clientSecret) { - vscode.window.showErrorMessage(`${autoFetchTokenData.client_id_variable_name } and ${autoFetchTokenData.client_secret_variable_name } must be defined in .env.`); + vscode.window.showErrorMessage(`${autoFetchTokenData.client_id_variable_name} and ${autoFetchTokenData.client_secret_variable_name} must be defined in .env.`); return ''; } const encodedCredentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); - + const headers: any = { 'Authorization': `${autoFetchTokenData['auth_type']} ${encodedCredentials}` }; @@ -182,14 +187,14 @@ export class EnvironmentController { if (autoFetchTokenData['scope']) { body.append('scope', autoFetchTokenData['scope']); } - + const tokenExpression = autoFetchTokenData['response_token_value_tag_name']; - + if (!tokenExpression) { vscode.window.showErrorMessage(`response_token_value_tag_name is missing in settings for ${environmentName} where auto_fetch_token_data is configured`); return ''; } - + try { const method = autoFetchTokenData['method']; if (!method) { @@ -199,14 +204,14 @@ export class EnvironmentController { if (!token_request_url) { throw new Error('token_request_url is missing in settings'); } - + const response = await fetch(token_request_url, { method: method, headers: headers, body: body }); const data = await response.json(); - + if (typeof data === 'object' && data !== null) { const token = this.getNestedValue(data, tokenExpression.split(".")); if (token) { diff --git a/src/controllers/httpTestingController.ts b/src/controllers/httpTestingController.ts index 2d66e318..b617d789 100644 --- a/src/controllers/httpTestingController.ts +++ b/src/controllers/httpTestingController.ts @@ -7,7 +7,6 @@ import { QuickPickItem } from 'vscode'; import { SystemSettings } from '../models/configurationSettings'; import { UserDataManager } from '../utils/userDataManager'; import { initLogger, log } from "../utils/logger"; -import { fileExists, loadVariables } from "../utils/fileUtils"; import { LogLevel, HttpRequest } from "../types"; import { VariableManager } from "../http-test-core/VariableManager"; import { HttpFileParser } from "../http-test-core/HttpFileParser"; @@ -95,7 +94,7 @@ export class HttpTestingController { log("Starting test run...", LogLevel.INFO); const variableManager = new VariableManager(); - await this.loadVariablesFile(variableManager, fileName, undefined); + await this.loadVariablesFile(variableManager, currentEnvironment); const httpFileParser = new HttpFileParser(variableManager); const requests: HttpRequest[] = await httpFileParser.parse(fileName); const testManager = new TestManager(fileName); @@ -127,17 +126,28 @@ export class HttpTestingController { private async loadVariablesFile( variableManager: VariableManager, - filePath: string, - varFile: string | undefined + currentEnvironment: EnvironmentPickItem ): Promise { - const variableFile = - varFile || path.join(path.dirname(filePath), "variables.json"); - if (await fileExists(variableFile)) { - log(`Loading variables from ${variableFile}`, LogLevel.INFO); - const variables = await loadVariables(variableFile); + try { + // Get shared and current environment variables + const sharedVars = this.filterEnvironmentVars(this.settings.environmentVariables['$shared'] || {}); + const currentEnvVars = currentEnvironment.name !== Constants.NoEnvironmentSelectedName + ? this.filterEnvironmentVars(this.settings.environmentVariables[currentEnvironment.name] || {}) + : {}; + + // Combine variables with current environment taking precedence + const variables = { + ...sharedVars, + ...currentEnvVars + }; + + // Set variables in manager variableManager.setVariables(variables); - } else { - log(`No variable file specified or found. Proceeding without external variables.`, LogLevel.INFO); + log(`Loaded ${Object.keys(variables).length} variables from environment`, LogLevel.INFO); + + } catch (error) { + log(`Failed to load environment variables: ${error instanceof Error ? error.message : String(error)}`, LogLevel.ERROR); + throw error; } } } \ No newline at end of file From ce8a82fc55934134bb00915873bdb06e5d089fc2 Mon Sep 17 00:00:00 2001 From: gotstu Date: Mon, 31 Mar 2025 14:41:52 -0700 Subject: [PATCH 7/9] added httpTestVerboseOutput config and updated readme --- README.md | 158 ++++++++++++++++++++++- package.json | 6 + src/controllers/httpTestingController.ts | 4 +- 3 files changed, 164 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dac03a4c..d77d2ba1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# REST Client +# REST Client with HTTP TEST -REST Client allows you to send HTTP request and view the response in Visual Studio Code directly. It eliminates the need for a separate tool to test REST APIs and makes API testing convenient and efficient. +REST Client allows you to send HTTP request and view the response in Visual Studio Code directly. It eliminates the need for a separate tool to test REST APIs and makes API testing convenient and efficient. It also allows you to execute the HTTP file as an HTTP test. ## Main Features * Send/Cancel/Rerun __HTTP request__ in editor and view response in a separate pane with syntax highlight @@ -14,7 +14,7 @@ REST Client allows you to send HTTP request and view the response in Visual Stud * Customize font(size/family/weight) in response preview * Preview response with expected parts(_headers only_, _body only_, _full response_ and _both request and response_) * Authentication support for: - - Basic Auth + - Basic Auth(Auto Fetch Token Support) NEW! - Digest Auth - SSL Client Certificates - Azure Active Directory @@ -40,6 +40,7 @@ REST Client allows you to send HTTP request and view the response in Visual Stud + `{{$aadToken [new] [public|cn|de|us|ppe] [] [aud:]}}` + `{{$oidcAccessToken [new] [] [] [authorizeEndpoint: Date: Mon, 31 Mar 2025 15:10:47 -0700 Subject: [PATCH 8/9] readme tweak --- README.md | 81 +++++++++++++++++++++---------------------------------- 1 file changed, 30 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index d77d2ba1..6206b3e0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# REST Client with HTTP TEST +# REST Client with __HTTP TEST!__ -REST Client allows you to send HTTP request and view the response in Visual Studio Code directly. It eliminates the need for a separate tool to test REST APIs and makes API testing convenient and efficient. It also allows you to execute the HTTP file as an HTTP test. +REST Client allows you to send HTTP request and view the response in Visual Studio Code directly. It eliminates the need for a separate tool to test REST APIs and makes API testing convenient and efficient. __NEW!__ It also allows you to execute the HTTP file as an HTTP test. ## Main Features * Send/Cancel/Rerun __HTTP request__ in editor and view response in a separate pane with syntax highlight @@ -14,7 +14,7 @@ REST Client allows you to send HTTP request and view the response in Visual Stud * Customize font(size/family/weight) in response preview * Preview response with expected parts(_headers only_, _body only_, _full response_ and _both request and response_) * Authentication support for: - - Basic Auth(Auto Fetch Token Support) NEW! + - Basic Auth(Auto Fetch Token Support) __NEW!__ - Digest Auth - SSL Client Certificates - Azure Active Directory @@ -59,29 +59,39 @@ REST Client allows you to send HTTP request and view the response in Visual Stud - CodeLens support to add an actionable link to send request - Fold/Unfold for request block * Support for Markdown fenced code blocks with either `http` or `rest` +* __NEW!__ - Run HTTP Test for complete http file with basic and custom assertions. See [HTTP Testing Support](#-HTTP-Testing-Support) -### Example Configuration for OAuth2 Client Credentials Flow -Here's a complete example for setting up automatic token fetching using OAuth2 client credentials flow: +### Auto Token Fetching With Environment switch +REST Client supports automatic token fetching when switching environments. This can be configured using the `auto_fetch_token_data` property in your environment settings: ```json "rest-client.environmentVariables": { "$shared": {}, - "qa": { - "host": "api.qa.se.com", + "development": { + "host": "dev.example.com", "auto_fetch_token_data": { - "method": "POST", - "token_request_url": "https://api.qa.se.com/token", + "client_id_variable_name": "CLIENT_ID", + "client_secret_variable_name": "CLIENT_SECRET", "auth_type": "Basic", - "grant_type": "client_credentials", + "method": "POST", + "token_request_url": "https://auth.example.com/token", "content_type": "application/x-www-form-urlencoded", - "response_token_value_tag_name": "access_token", - "client_id_variable_name": "CLIENT_ID", - "client_secret_variable_name": "CLIENT_SECRET" + "grant_type": "client_credentials", + "scope": "api.access", + "response_token_value_tag_name": "access_token" } } } ``` +The `auto_fetch_token_data` configuration requires: +- `client_id_variable_name`: Name of the variable in .env file containing client ID +- `client_secret_variable_name`: Name of the variable in .env file containing client secret +- `auth_type`: Authentication type (e.g., "Basic", "Bearer") +- `method`: HTTP method for token request +- `token_request_url`: URL endpoint for token requests +- `response_token_value_tag_name`: JSON path to token value in response + This configuration: - Uses OAuth2 client credentials flow - Makes a POST request to token_request_url value endpoint @@ -94,11 +104,16 @@ This configuration: - Extracts token from response using "access_token" JSON path - Stores token in `$shared` environment as "token" +When switching to an environment with `auto_fetch_token_data` configured: +1. The extension checks for a .env file in the same directory as your .http file +2. Reads the client credentials from .env file +3. Makes a token request to the specified endpoint +4. Stores the received token in the $shared environment as "token" + You can then use the token in your requests: ```http GET https://{{host}}/api/v1/data Authorization: Bearer {{token}} -``` ## Usage In editor, type an HTTP request as simple as below: @@ -501,42 +516,6 @@ A sample usage in `http` file for above environment variables is listed below, n GET https://{{host}}/api/{{version}}comments/1 HTTP/1.1 Authorization: {{token}} ``` -### Auto Token Fetching With Environment switch -REST Client supports automatic token fetching when switching environments. This can be configured using the `auto_fetch_token_data` property in your environment settings: - -```json -"rest-client.environmentVariables": { - "$shared": {}, - "development": { - "host": "dev.example.com", - "auto_fetch_token_data": { - "client_id_variable_name": "CLIENT_ID", - "client_secret_variable_name": "CLIENT_SECRET", - "auth_type": "Basic", - "method": "POST", - "token_request_url": "https://auth.example.com/token", - "content_type": "application/x-www-form-urlencoded", - "grant_type": "client_credentials", - "scope": "api.access", - "response_token_value_tag_name": "access_token" - } - } -} -``` - -The `auto_fetch_token_data` configuration requires: -- `client_id_variable_name`: Name of the variable in .env file containing client ID -- `client_secret_variable_name`: Name of the variable in .env file containing client secret -- `auth_type`: Authentication type (e.g., "Basic", "Bearer") -- `method`: HTTP method for token request -- `token_request_url`: URL endpoint for token requests -- `response_token_value_tag_name`: JSON path to token value in response - -When switching to an environment with `auto_fetch_token_data` configured: -1. The extension checks for a .env file in the same directory as your .http file -2. Reads the client credentials from .env file -3. Makes a token request to the specified endpoint -4. Stores the received token in the $shared environment as "token" #### File Variables For file variables, the definition follows syntax __`@variableName = variableValue`__ which occupies a complete line. And variable name __MUST NOT__ contain any spaces. As for variable value, it can consist of any characters, even whitespaces are allowed for them (leading and trailing whitespaces will be trimmed). If you want to preserve some special characters like line break, you can use the _backslash_ `\` to escape, like `\n`. File variable value can even contain references to all of other kinds of variables. For instance, you can create a file variable with value of other [request variables](#request-variables) like `@token = {{loginAPI.response.body.token}}`. When referencing a file variable, you can use the _percent_ `%` to percent-encode the value. @@ -782,7 +761,7 @@ headers | Only the response headers(including _status line_) are previewed body | Only the response body is previewed exchange | Preview the whole HTTP exchange(request and response) -## HTTP Testing Support +### HTTP Testing Support REST Client includes built-in HTTP testing capabilities that allow you to write and execute API tests directly in your `.http` files. This feature helps you validate API responses without writing complex test scripts. ### Writing Tests From 68e4e0d88f6d43ee9c1dc2297f2b4878b00f6842 Mon Sep 17 00:00:00 2001 From: gotstu Date: Tue, 1 Apr 2025 12:04:25 -0700 Subject: [PATCH 9/9] updated comments regex and updated selector to remove test lines --- src/common/constants.ts | 2 +- src/utils/selector.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/common/constants.ts b/src/common/constants.ts index 65da8bbd..7fadf57f 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -66,7 +66,7 @@ export const AzureClouds: { [key: string]: { aad: string, arm: string, armAudien export const RequestMetadataRegex: RegExp = /^\s*(?:#|\/{2})\s*@([\w-]+)(?:\s+(.*?))?\s*$/; -export const CommentIdentifiersRegex: RegExp = /^\s*(#|\/{2})/; +export const CommentIdentifiersRegex: RegExp = /^\s*(?:#{1,3}|\/{2})(?!\#)/; export const FileVariableDefinitionRegex: RegExp = /^\s*@([^\s=]+)\s*=\s*(.*?)\s*$/; diff --git a/src/utils/selector.ts b/src/utils/selector.ts index 167b76bc..b01a5960 100644 --- a/src/utils/selector.ts +++ b/src/utils/selector.ts @@ -128,7 +128,13 @@ export class Selector { for (const current of delimitedLines) { let start = prev + 1; let end = current - 1; + let lineCounter = 0; while (start <= end) { + lineCounter++; + if (lines[lineCounter].substring(0,4) === '####') { + requestRanges.push([start, lineCounter-1]); + break; + } const startLine = lines[start]; if (options.ignoreResponseRange && this.isResponseStatusLine(startLine)) { break; @@ -147,7 +153,7 @@ export class Selector { end--; continue; } - + requestRanges.push([start, end]); break; }