diff --git a/README.md b/README.md index 5110daa3..6206b3e0 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@ -# REST Client +# REST Client with __HTTP TEST!__ -[![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. +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 @@ -17,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 @@ -43,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:=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..19ce2d4f 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.2", "publisher": "humao", "author": { "name": "Huachao Mao", @@ -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", @@ -686,6 +691,12 @@ "default": true, "scope": "resource", "description": "Enable/disable using filename from 'content-disposition' header, when saving response body" + }, + "rest-client.httpTestVerboseOutput": { + "type": "boolean", + "default": false, + "scope": "resource", + "description": "Display verbose output of test result to help debug." } } }, 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/controllers/environmentController.ts b/src/controllers/environmentController.ts index 2fca9a5c..a7d9f58f 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,139 @@ 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; + + // 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, + hasWorkspaceConfig ? vscode.ConfigurationTarget.Workspace : vscode.ConfigurationTarget.Global + ); + + 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 { + 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 diff --git a/src/controllers/httpTestingController.ts b/src/controllers/httpTestingController.ts new file mode 100644 index 00000000..b9a9af05 --- /dev/null +++ b/src/controllers/httpTestingController.ts @@ -0,0 +1,155 @@ +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'; +import { initLogger, log } from "../utils/logger"; +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, + 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; + } + + // 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() { + + // Ensure output is visible + HttpTestingController.outputChannel.show(true); + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + log('No active editor found.', LogLevel.ERROR); + vscode.window.showErrorMessage('No active editor found.'); + return; + } + + const document = activeEditor.document; + 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'] || {}); + const workspaceConfig = vscode.workspace.getConfiguration('rest-client'); + + const options = { + verbose: workspaceConfig.httpTestVerboseOutput ? workspaceConfig.httpTestVerboseOutput : false + }; + + 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' + ); + log(`Variables written to: ${variablesPath}`, LogLevel.INFO); + + log("Starting test run...", LogLevel.INFO); + const variableManager = new VariableManager(); + await this.loadVariablesFile(variableManager, currentEnvironment); + 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; + } + } + + 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; + } + + private async loadVariablesFile( + variableManager: VariableManager, + currentEnvironment: EnvironmentPickItem + ): Promise { + 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); + 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 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/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 => { 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/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/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/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); + }); +} diff --git a/src/utils/selector.ts b/src/utils/selector.ts index a1ea6621..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; } @@ -239,7 +245,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); } 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