Skip to main content
Glama
RequestExecutionService.ts13.6 kB
/** * Service for executing Bruno requests and collections * Handles running requests, collections, and parsing results */ import { randomUUID } from 'crypto'; import * as fs from 'fs/promises'; import { tmpdir } from 'os'; import * as path from 'path'; import { execa } from 'execa'; import type { BrunoRunOptions, BrunoRunResult } from '../bruno-cli.js'; import type { ConfigLoader } from '../config.js'; /** * Service responsible for executing Bruno requests and collections * Single Responsibility: Execute and parse Bruno CLI operations */ export class RequestExecutionService { constructor( private readonly brunoCommand: string, private readonly configLoader: ConfigLoader ) {} /** * Run a single request from a collection */ async runRequest( collectionPath: string, requestName: string, options: BrunoRunOptions = {}, findRequestFile: (collectionPath: string, requestName: string) => Promise<string | null> ): Promise<BrunoRunResult> { // Find the .bru file for this request const requestFile = await findRequestFile(collectionPath, requestName); if (!requestFile) { throw new Error(`Request "${requestName}" not found in collection`); } // Get relative path from collection root const relativePath = path.relative(collectionPath, requestFile); // Create temporary file for JSON output const tempFile = path.join(tmpdir(), `bruno-result-${randomUUID()}.json`); // Build command arguments - run specific file const args = ['run', relativePath]; // Always use JSON format to capture response data args.push('--format', 'json'); args.push('--output', tempFile); // Add optional parameters this.addEnvironmentArgs(args, options); this.addEnvironmentVariableArgs(args, options); this.addTestArgs(args, options); this.addReporterArgs(args, options); try { // Get timeout configuration const timeout = this.configLoader.getTimeout(); // Determine command and args based on whether brunoCommand is a .js file let command: string; let execArgs: string[]; if (this.brunoCommand.endsWith('.js')) { // If it's a .js file path, explicitly invoke with node command = 'node'; execArgs = [this.brunoCommand, ...args]; } else { // If it's a binary name like 'bru', use it directly command = this.brunoCommand; execArgs = args; } // Run Bruno CLI from within the collection directory const result = await execa(command, execArgs, { cwd: collectionPath, env: { ...process.env }, reject: false, timeout: timeout.request }); // If Bruno CLI failed, handle the error if (result.exitCode !== 0 || result.failed) { throw result; } // Read the JSON output file const jsonResult = await this.readJsonOutput(tempFile, options); // Clean up temp file await this.cleanupTempFile(tempFile); return this.parseRunResult(result, jsonResult); } catch (error) { // Clean up temp file on error await this.cleanupTempFile(tempFile); throw error; } } /** * Run an entire collection or folder */ async runCollection( collectionPath: string, options: BrunoRunOptions = {} ): Promise<BrunoRunResult> { // Determine what to run (folder or entire collection) let targetPath = '.'; if (options.folderPath) { // Run specific folder targetPath = options.folderPath; } // Create temporary file for JSON output const tempFile = path.join(tmpdir(), `bruno-result-${randomUUID()}.json`); // Build command arguments const args = ['run', targetPath]; // Add recursive flag (usually needed for folders) if (options.recursive !== false) { args.push('-r'); } // Always use JSON format to capture response data args.push('--format', 'json'); args.push('--output', tempFile); // Add optional parameters this.addEnvironmentArgs(args, options); this.addEnvironmentVariableArgs(args, options); this.addTestArgs(args, options); this.addReporterArgs(args, options); try { // Get timeout configuration const timeout = this.configLoader.getTimeout(); // Determine command and args based on whether brunoCommand is a .js file let command: string; let execArgs: string[]; if (this.brunoCommand.endsWith('.js')) { // If it's a .js file path, explicitly invoke with node command = 'node'; execArgs = [this.brunoCommand, ...args]; } else { // If it's a binary name like 'bru', use it directly command = this.brunoCommand; execArgs = args; } // Run Bruno CLI from within the collection directory const result = await execa(command, execArgs, { cwd: collectionPath, env: { ...process.env }, reject: false, timeout: timeout.collection }); // Read the JSON output file const jsonResult = await this.readJsonOutput(tempFile, options); // Clean up temp file await this.cleanupTempFile(tempFile); return this.parseRunResult(result, jsonResult); } catch (error) { // Clean up temp file on error await this.cleanupTempFile(tempFile); throw error; } } /** * Add environment arguments to command */ private addEnvironmentArgs(args: string[], options: BrunoRunOptions): void { if (options.environment) { let envPath = options.environment; // Check if it's an absolute path if (path.isAbsolute(envPath)) { // If absolute path, get just the filename without extension // Bruno expects just the environment name, not the full path const basename = path.basename(envPath, '.bru'); envPath = basename; } else if (envPath.includes('/') || envPath.includes('\\')) { // If it's a relative path with separators, get just the filename const basename = path.basename(envPath, '.bru'); envPath = basename; } else if (envPath.endsWith('.bru')) { // If it ends with .bru, remove the extension envPath = envPath.replace(/\.bru$/, ''); } // If just a name (no path separators, no extension), use as-is args.push('--env', envPath); } } /** * Add environment variable overrides to command */ private addEnvironmentVariableArgs(args: string[], options: BrunoRunOptions): void { if (options.envVariables) { for (const [key, value] of Object.entries(options.envVariables)) { args.push('--env-var', `${key}=${value}`); } } } /** * Add test-related arguments to command */ private addTestArgs(args: string[], options: BrunoRunOptions): void { if (options.testsOnly) { args.push('--tests-only'); } if (options.bail) { args.push('--bail'); } } /** * Add reporter arguments to command */ private addReporterArgs(args: string[], options: BrunoRunOptions): void { if (options.reporterJson) { args.push('--reporter-json', options.reporterJson); } if (options.reporterJunit) { args.push('--reporter-junit', options.reporterJunit); } if (options.reporterHtml) { args.push('--reporter-html', options.reporterHtml); } } /** * Read JSON output from temp file */ private async readJsonOutput(tempFile: string, options: BrunoRunOptions): Promise<any> { const hasCustomReporters = options.reporterJson || options.reporterJunit || options.reporterHtml; if (!hasCustomReporters) { // Only try to read temp file if no custom reporters are specified try { const jsonContent = await fs.readFile(tempFile, 'utf-8'); return JSON.parse(jsonContent); } catch (error) { console.error('Failed to read Bruno output file:', error); return null; } } return null; } /** * Clean up temporary file */ private async cleanupTempFile(tempFile: string): Promise<void> { try { await fs.unlink(tempFile); } catch { // Ignore cleanup errors } } /** * Parse Bruno CLI execution result */ private parseRunResult(result: any, jsonOutput?: any): BrunoRunResult { const runResult: BrunoRunResult = { stdout: result.stdout || '', stderr: result.stderr || '', exitCode: result.exitCode || 0 }; // Use the JSON output if available (from file) let jsonResult = jsonOutput || this.tryParseJson(result.stdout); // Bruno CLI returns an array with a single object containing summary and results // Unwrap it if needed if (Array.isArray(jsonResult) && jsonResult.length > 0) { jsonResult = jsonResult[0]; } if (jsonResult) { // Extract summary information if (jsonResult.summary) { runResult.summary = { totalRequests: jsonResult.summary.totalRequests || jsonResult.summary.total || 0, passedRequests: jsonResult.summary.passedRequests || jsonResult.summary.passed || 0, failedRequests: jsonResult.summary.failedRequests || jsonResult.summary.failed || 0, totalDuration: jsonResult.summary.totalDuration || jsonResult.summary.duration || 0 }; } // Extract individual results with response data if (jsonResult.results && Array.isArray(jsonResult.results)) { runResult.results = jsonResult.results.map((r: any) => { const result: any = { name: r.suitename || r.name || r.test?.filename || 'Unknown', passed: r.error === null || r.error === undefined, status: r.response?.status || 0, duration: r.response?.responseTime || r.runtime || 0, error: r.error }; // Add request details if available if (r.request) { result.request = { method: r.request.method, url: r.request.url, headers: r.request.headers, body: r.request.body || r.request.data }; } // Add response details if available if (r.response) { result.response = { status: r.response.status, statusText: r.response.statusText, headers: r.response.headers, body: r.response.data || r.response.body, responseTime: r.response.responseTime }; } // Add test/assertion results (Bruno uses testResults and assertionResults) const testResults = r.testResults || r.tests || r.assertions; const assertionResults = r.assertionResults || []; const allTests = []; if (Array.isArray(testResults)) { allTests.push(...testResults.map((t: any) => ({ name: t.description || t.name || t.test, passed: t.status === 'pass' || t.passed === true, error: t.status === 'fail' ? t.error || t.message : undefined }))); } if (Array.isArray(assertionResults)) { allTests.push(...assertionResults.map((a: any) => ({ name: a.description || a.name, passed: a.status === 'pass' || a.passed === true, error: a.status === 'fail' ? a.error || a.message : undefined }))); } if (allTests.length > 0) { result.assertions = allTests; } return result; }); } // If results are in a different structure (check for 'items' or direct array) if (!runResult.results && jsonResult.items && Array.isArray(jsonResult.items)) { runResult.results = this.parseItems(jsonResult.items); } } return runResult; } /** * Try to parse JSON from string */ private tryParseJson(str: string): any { if (!str || !str.trim()) return null; try { // Try to parse as-is if (str.trim().startsWith('{') || str.trim().startsWith('[')) { return JSON.parse(str); } // Look for JSON in the output (sometimes there's extra text) const jsonMatch = str.match(/(\{[\s\S]*\}|\[[\s\S]*\])/); if (jsonMatch) { return JSON.parse(jsonMatch[1]); } } catch { // Not JSON or parsing failed } return null; } /** * Parse items from alternative JSON structure */ private parseItems(items: any[]): any[] { return items.map((item: any) => { const result: any = { name: item.name || 'Unknown', passed: item.status === 'passed' || item.passed !== false, status: item.response?.status || 0, duration: item.duration || 0 }; if (item.request) { result.request = { method: item.request.method, url: item.request.url, headers: item.request.headers, body: item.request.body }; } if (item.response) { result.response = { status: item.response.status, statusText: item.response.statusText, headers: item.response.headers, body: item.response.body || item.response.data, responseTime: item.response.time || item.response.responseTime }; } if (item.tests) { result.assertions = item.tests.map((t: any) => ({ name: t.name || t.test, passed: t.passed || t.status === 'passed', error: t.error })); } return result; }); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jcr82/bruno-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server