Cannot GET /test/v4\n\n\n" }, + "error": null, "assertionResults": [ { - "uid": "mTrKBl5YU6jiAVG-phKT4", + "uid": "oidgfXLiyD8Jv0NBAHUHF", "lhsExpr": "res.status", "rhsExpr": "200", "rhsOperand": "200", "operator": "eq", - "status": "pass" + "status": "fail", + "error": "expected 404 to equal 200" } ], "testResults": [] @@ -65,53 +48,33 @@ { "request": { "method": "GET", - "url": "http://localhost:8080/test/v2", + "url": "http://localhost:3000/test/v2", "headers": {} }, "response": { - "status": 200, - "statusText": "OK", + "status": 404, + "statusText": "Not Found", "headers": { "x-powered-by": "Express", - "content-type": "application/json; charset=utf-8", - "content-length": "497", - "etag": "W/\"1f1-lMqxZgVOJiQXjF5yk3AFEU8O9Ro\"", - "date": "Mon, 25 Sep 2023 21:43:02 GMT", + "content-security-policy": "default-src 'none'", + "x-content-type-options": "nosniff", + "content-type": "text/html; charset=utf-8", + "content-length": "146", + "date": "Fri, 29 Sep 2023 00:37:50 GMT", "connection": "close" }, - "data": { - "path": "/test/v2", - "headers": { - "accept": "application/json, text/plain, */*", - "user-agent": "axios/1.5.0", - "accept-encoding": "gzip, compress, deflate, br", - "host": "localhost:8080", - "connection": "close" - }, - "method": "GET", - "body": "", - "fresh": false, - "hostname": "localhost", - "ip": "", - "ips": [], - "protocol": "http", - "query": {}, - "subdomains": [], - "xhr": false, - "os": { - "hostname": "05512cb2102c" - }, - "connection": {} - } + "data": "\n\n\n\n
Cannot GET /test/v2\n\n\n" }, + "error": null, "assertionResults": [ { - "uid": "XsjjGx9cjt5t8tE_t69ZB", + "uid": "IgliYuHd9wKp6JNyqyHFK", "lhsExpr": "res.status", "rhsExpr": "200", "rhsOperand": "200", "operator": "eq", - "status": "pass" + "status": "fail", + "error": "expected 404 to equal 200" } ], "testResults": [] @@ -119,53 +82,33 @@ { "request": { "method": "GET", - "url": "http://localhost:8080/test/v3", + "url": "http://localhost:3000/test/v3", "headers": {} }, "response": { - "status": 200, - "statusText": "OK", + "status": 404, + "statusText": "Not Found", "headers": { "x-powered-by": "Express", - "content-type": "application/json; charset=utf-8", - "content-length": "497", - "etag": "W/\"1f1-tSiYu0/vWz3r+NYRCaed0aW1waw\"", - "date": "Mon, 25 Sep 2023 21:43:02 GMT", + "content-security-policy": "default-src 'none'", + "x-content-type-options": "nosniff", + "content-type": "text/html; charset=utf-8", + "content-length": "146", + "date": "Fri, 29 Sep 2023 00:37:50 GMT", "connection": "close" }, - "data": { - "path": "/test/v3", - "headers": { - "accept": "application/json, text/plain, */*", - "user-agent": "axios/1.5.0", - "accept-encoding": "gzip, compress, deflate, br", - "host": "localhost:8080", - "connection": "close" - }, - "method": "GET", - "body": "", - "fresh": false, - "hostname": "localhost", - "ip": "", - "ips": [], - "protocol": "http", - "query": {}, - "subdomains": [], - "xhr": false, - "os": { - "hostname": "05512cb2102c" - }, - "connection": {} - } + "data": "\n\n\n\n
Cannot GET /test/v3\n\n\n" }, + "error": null, "assertionResults": [ { - "uid": "i_8MmDMtJA9YfvB_FrW15", + "uid": "u-3sRebrCyuUbZOkwS0z8", "lhsExpr": "res.status", "rhsExpr": "200", "rhsOperand": "200", "operator": "eq", - "status": "pass" + "status": "fail", + "error": "expected 404 to equal 200" } ], "testResults": [] @@ -173,7 +116,7 @@ { "request": { "method": "POST", - "url": "http://localhost:8080/test/v1", + "url": "http://localhost:3000/test/v1", "headers": { "content-type": "application/json" }, @@ -181,57 +124,201 @@ "test": "hello" } }, + "response": { + "status": 404, + "statusText": "Not Found", + "headers": { + "x-powered-by": "Express", + "content-security-policy": "default-src 'none'", + "x-content-type-options": "nosniff", + "content-type": "text/html; charset=utf-8", + "content-length": "147", + "date": "Fri, 29 Sep 2023 00:37:50 GMT", + "connection": "close" + }, + "data": "\n\n\n\n
Cannot POST /test/v1\n\n\n" + }, + "error": null, + "assertionResults": [ + { + "uid": "PpKLK6I38I5_ibw4lZqLb", + "lhsExpr": "res.status", + "rhsExpr": "eq 200", + "rhsOperand": "200", + "operator": "eq", + "status": "fail", + "error": "expected 404 to equal 200" + } + ], + "testResults": [] + }, + { + "request": { + "method": "POST", + "url": "http://localhost:3000/test", + "headers": {} + }, + "response": { + "status": 404, + "statusText": "Not Found", + "headers": { + "x-powered-by": "Express", + "content-security-policy": "default-src 'none'", + "x-content-type-options": "nosniff", + "content-type": "text/html; charset=utf-8", + "content-length": "144", + "date": "Fri, 29 Sep 2023 00:37:50 GMT", + "connection": "close" + }, + "data": "\n\n\n\n
Cannot POST /test\n\n\n" + }, + "error": null, + "assertionResults": [], + "testResults": [] + }, + { + "request": { + "method": "HEAD", + "url": "http://localhost:3000/", + "headers": {} + }, "response": { "status": 200, "statusText": "OK", "headers": { "x-powered-by": "Express", - "content-type": "application/json; charset=utf-8", - "content-length": "623", - "etag": "W/\"26f-ku5QGz4p9f02u79vJIve7JH3QYM\"", - "date": "Mon, 25 Sep 2023 21:43:02 GMT", + "content-type": "text/html; charset=utf-8", + "content-length": "12", + "etag": "W/\"c-Lve95gjOVATpfV8EL5X4nxwjKHE\"", + "date": "Fri, 29 Sep 2023 00:37:50 GMT", "connection": "close" }, + "data": "" + }, + "error": null, + "assertionResults": [], + "testResults": [] + }, + { + "request": { + "method": "POST", + "url": "http://localhost:3000", + "headers": {} + }, + "response": { + "status": 404, + "statusText": "Not Found", + "headers": { + "x-powered-by": "Express", + "content-security-policy": "default-src 'none'", + "x-content-type-options": "nosniff", + "content-type": "text/html; charset=utf-8", + "content-length": "140", + "date": "Fri, 29 Sep 2023 00:37:50 GMT", + "connection": "close" + }, + "data": "\n\n\n\n
Cannot POST /\n\n\n" + }, + "error": null, + "assertionResults": [], + "testResults": [] + }, + { + "request": { + "method": "POST", + "url": "http://localhost:3000/", + "headers": { + "content-type": "multipart/form-data; boundary=--------------------------897965859410704836065858" + }, "data": { - "path": "/test/v1", - "headers": { - "accept": "application/json, text/plain, */*", - "content-type": "application/json", - "user-agent": "axios/1.5.0", - "content-length": "16", - "accept-encoding": "gzip, compress, deflate, br", - "host": "localhost:8080", - "connection": "close" - }, - "method": "POST", - "body": "{\"test\":\"hello\"}", - "fresh": false, - "hostname": "localhost", - "ip": "", - "ips": [], - "protocol": "http", - "query": {}, - "subdomains": [], - "xhr": false, - "os": { - "hostname": "05512cb2102c" - }, - "connection": {}, - "json": { - "test": "hello" - } + "_overheadLength": 103, + "_valueLength": 3, + "_valuesToMeasure": [], + "writable": false, + "readable": true, + "dataSize": 0, + "maxDataSize": 2097152, + "pauseStreams": true, + "_released": true, + "_streams": [], + "_currentStream": null, + "_insideLoop": false, + "_pendingNext": false, + "_boundary": "--------------------------897965859410704836065858", + "_events": {}, + "_eventsCount": 3 } }, - "assertionResults": [ - { - "uid": "hNBSF_GBdSTFHNiyCcOn9", - "lhsExpr": "res.status", - "rhsExpr": "200", - "rhsOperand": "200", - "operator": "eq", - "status": "pass" - } - ], + "response": { + "status": 404, + "statusText": "Not Found", + "headers": { + "x-powered-by": "Express", + "content-security-policy": "default-src 'none'", + "x-content-type-options": "nosniff", + "content-type": "text/html; charset=utf-8", + "content-length": "140", + "date": "Fri, 29 Sep 2023 00:37:50 GMT", + "connection": "close" + }, + "data": "\n\n\n\n
Cannot POST /\n\n\n" + }, + "error": null, + "assertionResults": [], + "testResults": [] + }, + { + "request": { + "method": "POST", + "url": "http://localhost:3000/", + "headers": { + "content-type": "application/x-www-form-urlencoded" + }, + "data": "a=b&c=d" + }, + "response": { + "status": 404, + "statusText": "Not Found", + "headers": { + "x-powered-by": "Express", + "content-security-policy": "default-src 'none'", + "x-content-type-options": "nosniff", + "content-type": "text/html; charset=utf-8", + "content-length": "140", + "date": "Fri, 29 Sep 2023 00:37:50 GMT", + "connection": "close" + }, + "data": "\n\n\n\n
Cannot POST /\n\n\n" + }, + "error": null, + "assertionResults": [], + "testResults": [] + }, + { + "request": { + "method": "POST", + "url": "http://localhost:3000/test", + "headers": { + "content-type": "text/xml" + }, + "data": "
Cannot POST /test\n\n\n" + }, + "error": null, + "assertionResults": [], "testResults": [] } ] diff --git a/packages/bruno-cli/package.json b/packages/bruno-cli/package.json index 91ce4a19..dca3a823 100644 --- a/packages/bruno-cli/package.json +++ b/packages/bruno-cli/package.json @@ -1,6 +1,6 @@ { "name": "@usebruno/cli", - "version": "0.10.1", + "version": "0.13.0", "license": "MIT", "main": "src/index.js", "bin": { @@ -13,6 +13,9 @@ "type": "git", "url": "git+https://github.com/usebruno/bruno.git" }, + "scripts": { + "test": "jest" + }, "files": [ "src", "bin", @@ -21,14 +24,17 @@ "package.json" ], "dependencies": { - "@usebruno/js": "0.6.0", - "@usebruno/lang": "0.4.0", + "@usebruno/js": "0.8.0", + "@usebruno/lang": "0.5.0", "axios": "^1.5.1", "chai": "^4.3.7", "chalk": "^3.0.0", + "decomment": "^0.9.5", "form-data": "^4.0.0", "fs-extra": "^10.1.0", "handlebars": "^4.7.8", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", "inquirer": "^9.1.4", "lodash": "^4.17.21", "mustache": "^4.2.0", diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index 087b85d4..7866425e 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -12,17 +12,56 @@ const { dotenvToJson } = require('@usebruno/lang'); const command = 'run [filename]'; const desc = 'Run a request'; -const printRunSummary = (assertionResults, testResults) => { - // display assertion results and test results summary - const totalAssertions = assertionResults.length; - const passedAssertions = assertionResults.filter((result) => result.status === 'pass').length; - const failedAssertions = totalAssertions - passedAssertions; +const printRunSummary = (results) => { + let totalRequests = 0; + let passedRequests = 0; + let failedRequests = 0; + let totalAssertions = 0; + let passedAssertions = 0; + let failedAssertions = 0; + let totalTests = 0; + let passedTests = 0; + let failedTests = 0; + + for (const result of results) { + totalRequests += 1; + totalTests += result.testResults.length; + totalAssertions += result.assertionResults.length; + let anyFailed = false; + let hasAnyTestsOrAssertions = false; + for (const testResult of result.testResults) { + hasAnyTestsOrAssertions = true; + if (testResult.status === 'pass') { + passedTests += 1; + } else { + anyFailed = true; + failedTests += 1; + } + } + for (const assertionResult of result.assertionResults) { + hasAnyTestsOrAssertions = true; + if (assertionResult.status === 'pass') { + passedAssertions += 1; + } else { + anyFailed = true; + failedAssertions += 1; + } + } + if (!hasAnyTestsOrAssertions && result.error) { + failedRequests += 1; + } else { + passedRequests += 1; + } + } - const totalTests = testResults.length; - const passedTests = testResults.filter((result) => result.status === 'pass').length; - const failedTests = totalTests - passedTests; const maxLength = 12; + let requestSummary = `${rpad('Requests:', maxLength)} ${chalk.green(`${passedRequests} passed`)}`; + if (failedRequests > 0) { + requestSummary += `, ${chalk.red(`${failedRequests} failed`)}`; + } + requestSummary += `, ${totalRequests} total`; + let assertSummary = `${rpad('Tests:', maxLength)} ${chalk.green(`${passedTests} passed`)}`; if (failedTests > 0) { assertSummary += `, ${chalk.red(`${failedTests} failed`)}`; @@ -35,10 +74,14 @@ const printRunSummary = (assertionResults, testResults) => { } testSummary += `, ${totalAssertions} total`; - console.log('\n' + chalk.bold(assertSummary)); + console.log('\n' + chalk.bold(requestSummary)); + console.log(chalk.bold(assertSummary)); console.log(chalk.bold(testSummary)); return { + totalRequests, + passedRequests, + failedRequests, totalAssertions, passedAssertions, failedAssertions, @@ -255,9 +298,7 @@ const handler = async function (argv) { } const _isFile = await isFile(filename); - let assertionResults = []; - let testResults = []; - let testrunResults = []; + let results = []; let bruJsons = []; @@ -311,17 +352,12 @@ const handler = async function (argv) { brunoConfig ); - if (result) { - testrunResults.push(result); - const { assertionResults: _assertionResults, testResults: _testResults } = result; - - assertionResults = assertionResults.concat(_assertionResults); - testResults = testResults.concat(_testResults); - } + results.push(result); } - const summary = printRunSummary(assertionResults, testResults); - console.log(chalk.dim(chalk.grey('Ran all requests.'))); + const summary = printRunSummary(results); + const totalTime = results.reduce((acc, res) => acc + res.response.responseTime, 0); + console.log(chalk.dim(chalk.grey(`Ran all requests - ${totalTime} ms`))); if (outputPath && outputPath.length) { const outputDir = path.dirname(outputPath); @@ -333,14 +369,14 @@ const handler = async function (argv) { const outputJson = { summary, - results: testrunResults + results }; fs.writeFileSync(outputPath, JSON.stringify(outputJson, null, 2)); console.log(chalk.dim(chalk.grey(`Wrote results to ${outputPath}`))); } - if (summary.failedAssertions > 0 || summary.failedTests > 0) { + if (summary.failedAssertions + summary.failedTests + summary.failedRequests > 0) { process.exit(1); } } catch (err) { @@ -354,5 +390,6 @@ module.exports = { command, desc, builder, - handler + handler, + printRunSummary }; diff --git a/packages/bruno-cli/src/runner/interpolate-string.js b/packages/bruno-cli/src/runner/interpolate-string.js new file mode 100644 index 00000000..33701dd0 --- /dev/null +++ b/packages/bruno-cli/src/runner/interpolate-string.js @@ -0,0 +1,55 @@ +const Handlebars = require('handlebars'); +const { forOwn, cloneDeep } = require('lodash'); + +const interpolateEnvVars = (str, processEnvVars) => { + if (!str || !str.length || typeof str !== 'string') { + return str; + } + + const template = Handlebars.compile(str, { noEscape: true }); + + return template({ + process: { + env: { + ...processEnvVars + } + } + }); +}; + +const interpolateString = (str, { envVars, collectionVariables, processEnvVars }) => { + if (!str || !str.length || typeof str !== 'string') { + return str; + } + + processEnvVars = processEnvVars || {}; + collectionVariables = collectionVariables || {}; + + // we clone envVars because we don't want to modify the original object + envVars = envVars ? cloneDeep(envVars) : {}; + + // envVars can inturn have values as {{process.env.VAR_NAME}} + // so we need to interpolate envVars first with processEnvVars + forOwn(envVars, (value, key) => { + envVars[key] = interpolateEnvVars(value, processEnvVars); + }); + + const template = Handlebars.compile(str, { noEscape: true }); + + // collectionVariables take precedence over envVars + const combinedVars = { + ...envVars, + ...collectionVariables, + process: { + env: { + ...processEnvVars + } + } + }; + + return template(combinedVars); +}; + +module.exports = { + interpolateString +}; diff --git a/packages/bruno-cli/src/runner/interpolate-vars.js b/packages/bruno-cli/src/runner/interpolate-vars.js index f4072dbe..b9261647 100644 --- a/packages/bruno-cli/src/runner/interpolate-vars.js +++ b/packages/bruno-cli/src/runner/interpolate-vars.js @@ -109,6 +109,18 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces } } + // todo: we have things happening in two places w.r.t basic auth + // need to refactor this in the future + // the request.auth (basic auth) object gets set inside the prepare-request.js file + if (request.auth) { + const username = interpolate(request.auth.username) || ''; + const password = interpolate(request.auth.password) || ''; + + // use auth header based approach and delete the request.auth object + request.headers['authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + delete request.auth; + } + return request; }; diff --git a/packages/bruno-cli/src/runner/prepare-request.js b/packages/bruno-cli/src/runner/prepare-request.js index 1cab8a1c..e52cb541 100644 --- a/packages/bruno-cli/src/runner/prepare-request.js +++ b/packages/bruno-cli/src/runner/prepare-request.js @@ -1,4 +1,5 @@ const { get, each, filter } = require('lodash'); +const decomment = require('decomment'); const prepareRequest = (request) => { const headers = {}; @@ -18,6 +19,20 @@ const prepareRequest = (request) => { headers: headers }; + // Authentication + if (request.auth) { + if (request.auth.mode === 'basic') { + axiosRequest.auth = { + username: get(request, 'auth.basic.username'), + password: get(request, 'auth.basic.password') + }; + } + + if (request.auth.mode === 'bearer') { + axiosRequest.headers['authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`; + } + } + request.body = request.body || {}; if (request.body.mode === 'json') { @@ -25,7 +40,7 @@ const prepareRequest = (request) => { axiosRequest.headers['content-type'] = 'application/json'; } try { - axiosRequest.data = JSON.parse(request.body.json); + axiosRequest.data = JSON.parse(decomment(request.body.json)); } catch (ex) { axiosRequest.data = request.body.json; } @@ -64,7 +79,7 @@ const prepareRequest = (request) => { if (request.body.mode === 'graphql') { const graphqlQuery = { query: get(request, 'body.graphql.query'), - variables: JSON.parse(get(request, 'body.graphql.variables') || '{}') + variables: JSON.parse(decomment(get(request, 'body.graphql.variables') || '{}')) }; if (!contentTypeDefined) { axiosRequest.headers['content-type'] = 'application/json'; diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 0d3a99e8..f86e1b02 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -1,15 +1,19 @@ const qs = require('qs'); const chalk = require('chalk'); +const decomment = require('decomment'); const fs = require('fs'); const { forOwn, each, extend, get } = require('lodash'); const FormData = require('form-data'); -const axios = require('axios'); -const https = require('https'); const prepareRequest = require('./prepare-request'); const interpolateVars = require('./interpolate-vars'); +const { interpolateString } = require('./interpolate-string'); const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime } = require('@usebruno/js'); const { stripExtension } = require('../utils/filesystem'); const { getOptions } = require('../utils/bru'); +const https = require('https'); +const { HttpsProxyAgent } = require('https-proxy-agent'); +const { HttpProxyAgent } = require('http-proxy-agent'); +const { makeAxiosInstance } = require('../utils/axios-instance'); const runSingleRequest = async function ( filename, @@ -20,11 +24,13 @@ const runSingleRequest = async function ( processEnvVars, brunoConfig ) { - let request; - try { + let request; + request = prepareRequest(bruJson.request); + const scriptingConfig = get(brunoConfig, 'scripts', {}); + // make axios work in node using form data // reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427 if (request.headers && request.headers['content-type'] === 'multipart/form-data') { @@ -55,41 +61,17 @@ const runSingleRequest = async function ( if (requestScriptFile && requestScriptFile.length) { const scriptRuntime = new ScriptRuntime(); await scriptRuntime.runRequestScript( - requestScriptFile, + decomment(requestScriptFile), request, envVariables, collectionVariables, collectionPath, null, - processEnvVars + processEnvVars, + scriptingConfig ); } - // set proxy if enabled - const proxyEnabled = get(brunoConfig, 'proxy.enabled', false); - if (proxyEnabled) { - const proxyProtocol = get(brunoConfig, 'proxy.protocol'); - const proxyHostname = get(brunoConfig, 'proxy.hostname'); - const proxyPort = get(brunoConfig, 'proxy.port'); - const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false); - - const proxyConfig = { - protocol: proxyProtocol, - hostname: proxyHostname, - port: proxyPort - }; - if (proxyAuthEnabled) { - const proxyAuthUsername = get(brunoConfig, 'proxy.auth.username'); - const proxyAuthPassword = get(brunoConfig, 'proxy.auth.password'); - proxyConfig.auth = { - username: proxyAuthUsername, - password: proxyAuthPassword - }; - } - - request.proxy = proxyConfig; - } - // interpolate variables inside request interpolateVars(request, envVariables, collectionVariables, processEnvVars); @@ -111,7 +93,39 @@ const runSingleRequest = async function ( } } - if (Object.keys(httpsAgentRequestFields).length > 0) { + // set proxy if enabled + const proxyEnabled = get(brunoConfig, 'proxy.enabled', false); + if (proxyEnabled) { + let proxy; + const interpolationOptions = { + envVars: envVariables, + collectionVariables, + processEnvVars + }; + + const proxyProtocol = interpolateString(get(brunoConfig, 'proxy.protocol'), interpolationOptions); + const proxyHostname = interpolateString(get(brunoConfig, 'proxy.hostname'), interpolationOptions); + const proxyPort = interpolateString(get(brunoConfig, 'proxy.port'), interpolationOptions); + const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false); + + interpolateString; + + if (proxyAuthEnabled) { + const proxyAuthUsername = interpolateString(get(brunoConfig, 'proxy.auth.username'), interpolationOptions); + const proxyAuthPassword = interpolateString(get(brunoConfig, 'proxy.auth.password'), interpolationOptions); + + proxy = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}:${proxyPort}`; + } else { + proxy = `${proxyProtocol}://${proxyHostname}:${proxyPort}`; + } + + request.httpsAgent = new HttpsProxyAgent( + proxy, + Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined + ); + + request.httpAgent = new HttpProxyAgent(proxy); + } else if (Object.keys(httpsAgentRequestFields).length > 0) { request.httpsAgent = new https.Agent({ ...httpsAgentRequestFields }); @@ -122,10 +136,51 @@ const runSingleRequest = async function ( request.data = qs.stringify(request.data); } - // run request - const response = await axios(request); + let response, responseTime; + try { + // run request + const axiosInstance = makeAxiosInstance(); - console.log(chalk.green(stripExtension(filename)) + chalk.dim(` (${response.status} ${response.statusText})`)); + /** @type {import('axios').AxiosResponse} */ + response = await axiosInstance(request); + + // Prevents the duration on leaking to the actual result + responseTime = response.headers.get('request-duration'); + response.headers.delete('request-duration'); + } catch (err) { + if (err && err.response) { + response = err.response; + + // Prevents the duration on leaking to the actual result + responseTime = response.headers.get('request-duration'); + response.headers.delete('request-duration'); + } else { + console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`)); + return { + request: { + method: request.method, + url: request.url, + headers: request.headers, + data: request.data + }, + response: { + status: null, + statusText: null, + headers: null, + data: null, + responseTime: 0 + }, + error: err.message, + assertionResults: [], + testResults: [] + }; + } + } + + console.log( + chalk.green(stripExtension(filename)) + + chalk.dim(` (${response.status} ${response.statusText}) - ${responseTime} ms`) + ); // run post-response vars const postResponseVars = get(bruJson, 'request.vars.res'); @@ -147,21 +202,22 @@ const runSingleRequest = async function ( if (responseScriptFile && responseScriptFile.length) { const scriptRuntime = new ScriptRuntime(); await scriptRuntime.runResponseScript( - responseScriptFile, + decomment(responseScriptFile), request, response, envVariables, collectionVariables, collectionPath, null, - processEnvVars + processEnvVars, + scriptingConfig ); } // run assertions let assertionResults = []; const assertions = get(bruJson, 'request.assertions'); - if (assertions && assertions.length) { + if (assertions) { const assertRuntime = new AssertRuntime(); assertionResults = assertRuntime.runAssertions( assertions, @@ -185,17 +241,18 @@ const runSingleRequest = async function ( // run tests let testResults = []; const testFile = get(bruJson, 'request.tests'); - if (testFile && testFile.length) { + if (typeof testFile === 'string') { const testRuntime = new TestRuntime(); const result = await testRuntime.runTests( - testFile, + decomment(testFile), request, response, envVariables, collectionVariables, collectionPath, null, - processEnvVars + processEnvVars, + scriptingConfig ); testResults = get(result, 'results', []); } @@ -221,102 +278,32 @@ const runSingleRequest = async function ( status: response.status, statusText: response.statusText, headers: response.headers, - data: response.data + data: response.data, + responseTime }, + error: null, assertionResults, testResults }; } catch (err) { - if (err && err.response) { - console.log( - chalk.green(stripExtension(filename)) + chalk.dim(` (${err.response.status} ${err.response.statusText})`) - ); - - // run post-response vars - const postResponseVars = get(bruJson, 'request.vars.res'); - if (postResponseVars && postResponseVars.length) { - const varsRuntime = new VarsRuntime(); - varsRuntime.runPostResponseVars( - postResponseVars, - request, - err.response, - envVariables, - collectionVariables, - collectionPath, - processEnvVars - ); - } - - // run post response script - const responseScriptFile = get(bruJson, 'request.script.res'); - if (responseScriptFile && responseScriptFile.length) { - const scriptRuntime = new ScriptRuntime(); - await scriptRuntime.runResponseScript( - responseScriptFile, - request, - err.response, - envVariables, - collectionVariables, - collectionPath, - null, - processEnvVars - ); - } - - // run assertions - let assertionResults = []; - const assertions = get(bruJson, 'request.assertions'); - if (assertions && assertions.length) { - const assertRuntime = new AssertRuntime(); - assertionResults = assertRuntime.runAssertions( - assertions, - request, - err.response, - envVariables, - collectionVariables, - collectionPath - ); - - each(assertionResults, (r) => { - if (r.status === 'pass') { - console.log(chalk.green(` ✓ `) + chalk.dim(`assert: ${r.lhsExpr}: ${r.rhsExpr}`)); - } else { - console.log(chalk.red(` ✕ `) + chalk.red(`assert: ${r.lhsExpr}: ${r.rhsExpr}`)); - console.log(chalk.red(` ${r.error}`)); - } - }); - } - - // run tests - let testResults = []; - const testFile = get(bruJson, 'request.tests'); - if (testFile && testFile.length) { - const testRuntime = new TestRuntime(); - const result = await testRuntime.runTests( - testFile, - request, - err.response, - envVariables, - collectionVariables, - collectionPath, - null, - processEnvVars - ); - testResults = get(result, 'results', []); - } - - if (testResults && testResults.length) { - each(testResults, (testResult) => { - if (testResult.status === 'pass') { - console.log(chalk.green(` ✓ `) + chalk.dim(testResult.description)); - } else { - console.log(chalk.red(` ✕ `) + chalk.red(testResult.description)); - } - }); - } - } else { - console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`)); - } + return { + request: { + method: null, + url: null, + headers: null, + data: null + }, + response: { + status: null, + statusText: null, + headers: null, + data: null, + responseTime: 0 + }, + error: err.message, + assertionResults: [], + testResults: [] + }; } }; diff --git a/packages/bruno-cli/src/utils/axios-instance.js b/packages/bruno-cli/src/utils/axios-instance.js new file mode 100644 index 00000000..286ffc0f --- /dev/null +++ b/packages/bruno-cli/src/utils/axios-instance.js @@ -0,0 +1,42 @@ +const axios = require('axios'); + +/** + * Function that configures axios with timing interceptors + * Important to note here that the timings are not completely accurate. + * @see https://github.com/axios/axios/issues/695 + * @returns {import('axios').AxiosStatic} + */ +function makeAxiosInstance() { + /** @type {import('axios').AxiosStatic} */ + const instance = axios.create(); + + instance.interceptors.request.use((config) => { + config.headers['request-start-time'] = Date.now(); + return config; + }); + + instance.interceptors.response.use( + (response) => { + const end = Date.now(); + const start = response.config.headers['request-start-time']; + response.headers['request-duration'] = end - start; + return response; + }, + (error) => { + if (error.response) { + const end = Date.now(); + const start = error.config.headers['request-start-time']; + if (error.response) { + error.response.headers['request-duration'] = end - start; + } + } + return Promise.reject(error); + } + ); + + return instance; +} + +module.exports = { + makeAxiosInstance +}; diff --git a/packages/bruno-cli/src/utils/bru.js b/packages/bruno-cli/src/utils/bru.js index 1ba6f016..68410639 100644 --- a/packages/bruno-cli/src/utils/bru.js +++ b/packages/bruno-cli/src/utils/bru.js @@ -38,6 +38,7 @@ const bruToJson = (bru) => { request: { method: _.upperCase(_.get(json, 'http.method')), url: _.get(json, 'http.url'), + auth: _.get(json, 'auth', {}), params: _.get(json, 'query', []), headers: _.get(json, 'headers', []), body: _.get(json, 'body', {}), @@ -49,6 +50,7 @@ const bruToJson = (bru) => { }; transformedJson.request.body.mode = _.get(json, 'http.body', 'none'); + transformedJson.request.auth.mode = _.get(json, 'http.auth', 'none'); return transformedJson; } catch (err) { diff --git a/packages/bruno-cli/tests/commands/run.spec.js b/packages/bruno-cli/tests/commands/run.spec.js new file mode 100644 index 00000000..10cdf42b --- /dev/null +++ b/packages/bruno-cli/tests/commands/run.spec.js @@ -0,0 +1,67 @@ +const { describe, it, expect } = require('@jest/globals'); + +const { printRunSummary } = require('../../src/commands/run'); + +describe('printRunSummary', () => { + // Suppress console.log output + jest.spyOn(console, 'log').mockImplementation(() => {}); + + it('should produce the correct summary for a successful run', () => { + const results = [ + { + testResults: [{ status: 'pass' }, { status: 'pass' }, { status: 'pass' }], + assertionResults: [{ status: 'pass' }, { status: 'pass' }], + error: null + }, + { + testResults: [{ status: 'pass' }, { status: 'pass' }], + assertionResults: [{ status: 'pass' }, { status: 'pass' }, { status: 'pass' }], + error: null + } + ]; + + const summary = printRunSummary(results); + + expect(summary.totalRequests).toBe(2); + expect(summary.passedRequests).toBe(2); + expect(summary.failedRequests).toBe(0); + expect(summary.totalAssertions).toBe(5); + expect(summary.passedAssertions).toBe(5); + expect(summary.failedAssertions).toBe(0); + expect(summary.totalTests).toBe(5); + expect(summary.passedTests).toBe(5); + expect(summary.failedTests).toBe(0); + }); + + it('should produce the correct summary for a failed run', () => { + const results = [ + { + testResults: [{ status: 'fail' }, { status: 'pass' }, { status: 'pass' }], + assertionResults: [{ status: 'pass' }, { status: 'fail' }], + error: null + }, + { + testResults: [{ status: 'pass' }, { status: 'fail' }], + assertionResults: [{ status: 'pass' }, { status: 'fail' }, { status: 'fail' }], + error: null + }, + { + testResults: [], + assertionResults: [], + error: new Error('Request failed') + } + ]; + + const summary = printRunSummary(results); + + expect(summary.totalRequests).toBe(3); + expect(summary.passedRequests).toBe(2); + expect(summary.failedRequests).toBe(1); + expect(summary.totalAssertions).toBe(5); + expect(summary.passedAssertions).toBe(2); + expect(summary.failedAssertions).toBe(3); + expect(summary.totalTests).toBe(5); + expect(summary.passedTests).toBe(3); + expect(summary.failedTests).toBe(2); + }); +}); diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json index ce46ee3c..033d4b69 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -1,5 +1,5 @@ { - "version": "v0.16.5", + "version": "v0.21.1", "name": "bruno", "description": "Opensource API Client for Exploring and Testing APIs", "homepage": "https://www.usebruno.com", @@ -14,13 +14,14 @@ "test": "jest" }, "dependencies": { - "@usebruno/js": "0.6.0", - "@usebruno/lang": "0.4.0", + "@usebruno/js": "0.8.0", + "@usebruno/lang": "0.5.0", "@usebruno/schema": "0.5.0", "about-window": "^1.15.2", "axios": "^1.5.1", "chai": "^4.3.7", "chokidar": "^3.5.3", + "decomment": "^0.9.5", "dotenv": "^16.0.3", "electron-is-dev": "^2.0.0", "electron-notarize": "^1.2.2", @@ -30,6 +31,8 @@ "fs-extra": "^10.1.0", "graphql": "^16.6.0", "handlebars": "^4.7.8", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", "is-valid-path": "^0.1.1", "lodash": "^4.17.21", "mustache": "^4.2.0", diff --git a/packages/bruno-electron/src/app/menu-template.js b/packages/bruno-electron/src/app/menu-template.js index 2efd93cd..6b470772 100644 --- a/packages/bruno-electron/src/app/menu-template.js +++ b/packages/bruno-electron/src/app/menu-template.js @@ -24,7 +24,10 @@ const template = [ { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, - { role: 'selectAll' } + { role: 'selectAll' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' } ] }, { diff --git a/packages/bruno-electron/src/app/watcher.js b/packages/bruno-electron/src/app/watcher.js index c5973e79..6b5d5738 100644 --- a/packages/bruno-electron/src/app/watcher.js +++ b/packages/bruno-electron/src/app/watcher.js @@ -2,12 +2,10 @@ const _ = require('lodash'); const fs = require('fs'); const path = require('path'); const chokidar = require('chokidar'); -const { hasJsonExtension, hasBruExtension, writeFile } = require('../utils/filesystem'); -const { bruToEnvJson, envJsonToBru, bruToJson, jsonToBru } = require('../bru'); +const { hasBruExtension } = require('../utils/filesystem'); +const { bruToEnvJson, bruToJson } = require('../bru'); const { dotenvToJson } = require('@usebruno/lang'); -const { isLegacyEnvFile, migrateLegacyEnvFile, isLegacyBruFile, migrateLegacyBruFile } = require('../bru/migrate'); -const { itemSchema } = require('@usebruno/schema'); const { uuid } = require('../utils/common'); const { getRequestUid } = require('../cache/requestUids'); const { decryptString } = require('../utils/encryption'); @@ -17,13 +15,6 @@ const EnvironmentSecretsStore = require('../store/env-secrets'); const environmentSecretsStore = new EnvironmentSecretsStore(); -const isJsonEnvironmentConfig = (pathname, collectionPath) => { - const dirname = path.dirname(pathname); - const basename = path.basename(pathname); - - return dirname === collectionPath && basename === 'environments.json'; -}; - const isDotEnvFile = (pathname, collectionPath) => { const dirname = path.dirname(pathname); const basename = path.basename(pathname); @@ -87,11 +78,6 @@ const addEnvironmentFile = async (win, pathname, collectionUid, collectionPath) let bruContent = fs.readFileSync(pathname, 'utf8'); - // migrate old env json to bru file - if (isLegacyEnvFile(bruContent)) { - bruContent = await migrateLegacyEnvFile(bruContent, pathname); - } - file.data = bruToEnvJson(bruContent); file.data.name = basename.substring(0, basename.length - 4); file.data.uid = getRequestUid(pathname); @@ -103,7 +89,7 @@ const addEnvironmentFile = async (win, pathname, collectionUid, collectionPath) const envSecrets = environmentSecretsStore.getEnvSecrets(collectionPath, file.data); _.each(envSecrets, (secret) => { const variable = _.find(file.data.variables, (v) => v.name === secret.name); - if (variable) { + if (variable && secret.value) { variable.value = decryptString(secret.value); } }); @@ -137,7 +123,7 @@ const changeEnvironmentFile = async (win, pathname, collectionUid, collectionPat const envSecrets = environmentSecretsStore.getEnvSecrets(collectionPath, file.data); _.each(envSecrets, (secret) => { const variable = _.find(file.data.variables, (v) => v.name === secret.name); - if (variable) { + if (variable && secret.value) { variable.value = decryptString(secret.value); } }); @@ -205,57 +191,10 @@ const add = async (win, pathname, collectionUid, collectionPath) => { } } - if (isJsonEnvironmentConfig(pathname, collectionPath)) { - try { - const dirname = path.dirname(pathname); - const bruContent = fs.readFileSync(pathname, 'utf8'); - - const jsonData = JSON.parse(bruContent); - - const envDirectory = path.join(dirname, 'environments'); - if (!fs.existsSync(envDirectory)) { - fs.mkdirSync(envDirectory); - } - - for (const env of jsonData) { - const bruEnvFilename = path.join(envDirectory, `${env.name}.bru`); - const bruContent = envJsonToBru(env); - await writeFile(bruEnvFilename, bruContent); - } - - await fs.unlinkSync(pathname); - } catch (err) { - // do nothing - } - - return; - } - if (isBruEnvironmentConfig(pathname, collectionPath)) { return addEnvironmentFile(win, pathname, collectionUid, collectionPath); } - // migrate old json files to bru - if (hasJsonExtension(pathname)) { - try { - const json = fs.readFileSync(pathname, 'utf8'); - const jsonData = JSON.parse(json); - - await itemSchema.validate(jsonData); - - const content = jsonToBru(jsonData); - - const re = /(.*)\.json$/; - const subst = `$1.bru`; - const bruFilename = pathname.replace(re, subst); - - await writeFile(bruFilename, content); - await fs.unlinkSync(pathname); - } catch (err) { - // do nothing - } - } - if (hasBruExtension(pathname)) { const file = { meta: { @@ -268,11 +207,6 @@ const add = async (win, pathname, collectionUid, collectionPath) => { try { let bruContent = fs.readFileSync(pathname, 'utf8'); - // migrate old bru format to new bru format - if (isLegacyBruFile(bruContent)) { - bruContent = await migrateLegacyBruFile(bruContent, pathname); - } - file.data = bruToJson(bruContent); hydrateRequestWithUuid(file.data, pathname); win.webContents.send('main:collection-tree-updated', 'addFile', file); @@ -404,11 +338,6 @@ class Watcher { this.watchers[watchPath].close(); } - // todo - // enable this in a future release - // once we can confirm all older json based files have been auto migrated to .bru format - // watchPath = path.join(watchPath, '**/*.bru'); - const self = this; setTimeout(() => { const watcher = chokidar.watch(watchPath, { diff --git a/packages/bruno-electron/src/bru/index.js b/packages/bruno-electron/src/bru/index.js index 45b10004..ad429959 100644 --- a/packages/bruno-electron/src/bru/index.js +++ b/packages/bruno-electron/src/bru/index.js @@ -1,6 +1,5 @@ const _ = require('lodash'); const { bruToJsonV2, jsonToBruV2, bruToEnvJsonV2, envJsonToBruV2 } = require('@usebruno/lang'); -const { each } = require('lodash'); const bruToEnvJson = (bru) => { try { @@ -10,7 +9,7 @@ const bruToEnvJson = (bru) => { // this need to be evaluated and safely removed // i don't see it being used in schema validation if (json && json.variables && json.variables.length) { - each(json.variables, (v) => (v.type = 'text')); + _.each(json.variables, (v) => (v.type = 'text')); } return json; @@ -61,6 +60,7 @@ const bruToJson = (bru) => { url: _.get(json, 'http.url'), params: _.get(json, 'query', []), headers: _.get(json, 'headers', []), + auth: _.get(json, 'auth', {}), body: _.get(json, 'body', {}), script: _.get(json, 'script', {}), vars: _.get(json, 'vars', {}), @@ -69,6 +69,7 @@ const bruToJson = (bru) => { } }; + transformedJson.request.auth.mode = _.get(json, 'http.auth', 'none'); transformedJson.request.body.mode = _.get(json, 'http.body', 'none'); return transformedJson; @@ -104,10 +105,12 @@ const jsonToBru = (json) => { http: { method: _.lowerCase(_.get(json, 'request.method')), url: _.get(json, 'request.url'), + auth: _.get(json, 'request.auth.mode', 'none'), body: _.get(json, 'request.body.mode', 'none') }, query: _.get(json, 'request.params', []), headers: _.get(json, 'request.headers', []), + auth: _.get(json, 'request.auth', {}), body: _.get(json, 'request.body', {}), script: _.get(json, 'request.script', {}), vars: { diff --git a/packages/bruno-electron/src/bru/migrate.js b/packages/bruno-electron/src/bru/migrate.js deleted file mode 100644 index a74dc2fd..00000000 --- a/packages/bruno-electron/src/bru/migrate.js +++ /dev/null @@ -1,99 +0,0 @@ -const { - bruToEnvJson: bruToEnvJsonV1, - bruToJson: bruToJsonV1, - - jsonToBruV2, - envJsonToBruV2 -} = require('@usebruno/lang'); -const _ = require('lodash'); - -const { writeFile } = require('../utils/filesystem'); - -const isLegacyEnvFile = (bruContent = '') => { - bruContent = bruContent.trim(); - let regex = /^vars[\s\S]*\/vars$/; - - return regex.test(bruContent); -}; - -const migrateLegacyEnvFile = async (bruContent, pathname) => { - const envJson = bruToEnvJsonV1(bruContent); - const newBruContent = envJsonToBruV2(envJson); - - await writeFile(pathname, newBruContent); - - return newBruContent; -}; - -const isLegacyBruFile = (bruContent = '') => { - bruContent = bruContent.trim(); - let lines = bruContent.split(/\r?\n/); - let hasName = false; - let hasMethod = false; - let hasUrl = false; - - for (let line of lines) { - line = line.trim(); - if (line.startsWith('name')) { - hasName = true; - } else if (line.startsWith('method')) { - hasMethod = true; - } else if (line.startsWith('url')) { - hasUrl = true; - } - } - - return hasName && hasMethod && hasUrl; -}; - -const migrateLegacyBruFile = async (bruContent, pathname) => { - const json = bruToJsonV1(bruContent); - - let type = _.get(json, 'type'); - if (type === 'http-request') { - type = 'http'; - } else if (type === 'graphql-request') { - type = 'graphql'; - } else { - type = 'http'; - } - - let script = {}; - let legacyScript = _.get(json, 'request.script'); - if (legacyScript && legacyScript.trim().length > 0) { - script = { - res: legacyScript - }; - } - - const bruJson = { - meta: { - name: _.get(json, 'name'), - type: type, - seq: _.get(json, 'seq') - }, - http: { - method: _.lowerCase(_.get(json, 'request.method')), - url: _.get(json, 'request.url'), - body: _.get(json, 'request.body.mode', 'none') - }, - query: _.get(json, 'request.params', []), - headers: _.get(json, 'request.headers', []), - body: _.get(json, 'request.body', {}), - script: script, - tests: _.get(json, 'request.tests', '') - }; - - const newBruContent = jsonToBruV2(bruJson); - - await writeFile(pathname, newBruContent); - - return newBruContent; -}; - -module.exports = { - isLegacyEnvFile, - migrateLegacyEnvFile, - isLegacyBruFile, - migrateLegacyBruFile -}; diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 2a62dd96..5e9916ef 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -9,6 +9,7 @@ const LastOpenedCollections = require('./store/last-opened-collections'); const registerNetworkIpc = require('./ipc/network'); const registerCollectionsIpc = require('./ipc/collection'); const Watcher = require('./app/watcher'); +const { loadWindowState, saveWindowState } = require('./utils/window'); const lastOpenedCollections = new LastOpenedCollections(); @@ -16,9 +17,7 @@ setContentSecurityPolicy(` default-src * 'unsafe-inline' 'unsafe-eval'; script-src * 'unsafe-inline' 'unsafe-eval'; connect-src * 'unsafe-inline'; - base-uri 'none'; form-action 'none'; - img-src 'self' data:image/svg+xml; `); const menu = Menu.buildFromTemplate(menuTemplate); @@ -29,14 +28,24 @@ let watcher; // Prepare the renderer once the app is ready app.on('ready', async () => { + const { x, y, width, height } = loadWindowState(); + mainWindow = new BrowserWindow({ - width: 1280, - height: 768, + x, + y, + width, + height, webPreferences: { nodeIntegration: true, contextIsolation: true, - preload: path.join(__dirname, 'preload.js') - } + preload: path.join(__dirname, 'preload.js'), + webviewTag: true + }, + title: 'Bruno', + icon: path.join(__dirname, 'about/256x256.png') + // we will bring this back + // see https://github.com/usebruno/bruno/issues/440 + // autoHideMenuBar: true }); const url = isDev @@ -50,6 +59,9 @@ app.on('ready', async () => { mainWindow.loadURL(url); watcher = new Watcher(); + mainWindow.on('resize', () => saveWindowState(mainWindow)); + mainWindow.on('move', () => saveWindowState(mainWindow)); + mainWindow.webContents.on('new-window', function (e, url) { e.preventDefault(); require('electron').shell.openExternal(url); diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index ae85558a..03a15305 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -151,6 +151,28 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection } }); + // copy environment + ipcMain.handle('renderer:copy-environment', async (event, collectionPathname, name, baseVariables) => { + try { + const envDirPath = path.join(collectionPathname, 'environments'); + if (!fs.existsSync(envDirPath)) { + await createDirectory(envDirPath); + } + + const envFilePath = path.join(envDirPath, `${name}.bru`); + if (fs.existsSync(envFilePath)) { + throw new Error(`environment: ${envFilePath} already exists`); + } + + const content = envJsonToBru({ + variables: baseVariables + }); + await writeFile(envFilePath, content); + } catch (error) { + return Promise.reject(error); + } + }); + // save environment ipcMain.handle('renderer:save-environment', async (event, collectionPathname, environment) => { try { @@ -464,6 +486,10 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection return Promise.reject(error); } }); + + ipcMain.handle('renderer:open-devtools', async () => { + mainWindow.webContents.openDevTools(); + }); }; const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) => { diff --git a/packages/bruno-electron/src/ipc/network/axios-instance.js b/packages/bruno-electron/src/ipc/network/axios-instance.js new file mode 100644 index 00000000..90ca1afe --- /dev/null +++ b/packages/bruno-electron/src/ipc/network/axios-instance.js @@ -0,0 +1,40 @@ +const axios = require('axios'); + +/** + * Function that configures axios with timing interceptors + * Important to note here that the timings are not completely accurate. + * @see https://github.com/axios/axios/issues/695 + * @returns {import('axios').AxiosStatic} + */ +function makeAxiosInstance() { + /** @type {import('axios').AxiosStatic} */ + const instance = axios.create(); + + instance.interceptors.request.use((config) => { + config.headers['request-start-time'] = Date.now(); + return config; + }); + + instance.interceptors.response.use( + (response) => { + const end = Date.now(); + const start = response.config.headers['request-start-time']; + response.headers['request-duration'] = end - start; + return response; + }, + (error) => { + const end = Date.now(); + const start = error.config.headers['request-start-time']; + if (error.response) { + error.response.headers['request-duration'] = end - start; + } + return Promise.reject(error); + } + ); + + return instance; +} + +module.exports = { + makeAxiosInstance +}; diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index d4cfa48b..a0b66099 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -1,6 +1,7 @@ const qs = require('qs'); const https = require('https'); const axios = require('axios'); +const decomment = require('decomment'); const Mustache = require('mustache'); const FormData = require('form-data'); const { ipcMain } = require('electron'); @@ -11,10 +12,14 @@ const prepareGqlIntrospectionRequest = require('./prepare-gql-introspection-requ const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../utils/cancel-token'); const { uuid } = require('../../utils/common'); const interpolateVars = require('./interpolate-vars'); +const { interpolateString } = require('./interpolate-string'); const { sortFolder, getAllRequestsInFolderRecursively } = require('./helper'); const { getPreferences } = require('../../store/preferences'); const { getProcessEnvVars } = require('../../store/process-env'); const { getBrunoConfig } = require('../../store/bruno-config'); +const { HttpsProxyAgent } = require('https-proxy-agent'); +const { HttpProxyAgent } = require('http-proxy-agent'); +const { makeAxiosInstance } = require('./axios-instance'); // override the default escape function to prevent escaping Mustache.escape = function (value) { @@ -103,6 +108,8 @@ const registerNetworkIpc = (mainWindow) => { const request = prepareRequest(_request); const envVars = getEnvVars(environment); const processEnvVars = getProcessEnvVars(collectionUid); + const brunoConfig = getBrunoConfig(collectionUid); + const scriptingConfig = get(brunoConfig, 'scripts', {}); try { // make axios work in node using form data @@ -148,13 +155,14 @@ const registerNetworkIpc = (mainWindow) => { if (requestScript && requestScript.length) { const scriptRuntime = new ScriptRuntime(); const result = await scriptRuntime.runRequestScript( - requestScript, + decomment(requestScript), request, envVars, collectionVariables, collectionPath, onConsoleLog, - processEnvVars + processEnvVars, + scriptingConfig ); mainWindow.webContents.send('main:script-environment-update', { @@ -165,32 +173,6 @@ const registerNetworkIpc = (mainWindow) => { }); } - // proxy configuration - const brunoConfig = getBrunoConfig(collectionUid); - const proxyEnabled = get(brunoConfig, 'proxy.enabled', false); - if (proxyEnabled) { - const proxyProtocol = get(brunoConfig, 'proxy.protocol'); - const proxyHostname = get(brunoConfig, 'proxy.hostname'); - const proxyPort = get(brunoConfig, 'proxy.port'); - const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false); - - const proxyConfig = { - protocol: proxyProtocol, - hostname: proxyHostname, - port: proxyPort - }; - if (proxyAuthEnabled) { - const proxyAuthUsername = get(brunoConfig, 'proxy.auth.username'); - const proxyAuthPassword = get(brunoConfig, 'proxy.auth.password'); - proxyConfig.auth = { - username: proxyAuthUsername, - password: proxyAuthPassword - }; - } - - request.proxy = proxyConfig; - } - interpolateVars(request, envVars, collectionVariables, processEnvVars); // stringify the request url encoded params @@ -234,13 +216,48 @@ const registerNetworkIpc = (mainWindow) => { } } - if (Object.keys(httpsAgentRequestFields).length > 0) { + // proxy configuration + const brunoConfig = getBrunoConfig(collectionUid); + const proxyEnabled = get(brunoConfig, 'proxy.enabled', false); + if (proxyEnabled) { + let proxy; + + const interpolationOptions = { + envVars, + collectionVariables, + processEnvVars + }; + + const proxyProtocol = interpolateString(get(brunoConfig, 'proxy.protocol'), interpolationOptions); + const proxyHostname = interpolateString(get(brunoConfig, 'proxy.hostname'), interpolationOptions); + const proxyPort = interpolateString(get(brunoConfig, 'proxy.port'), interpolationOptions); + const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false); + + if (proxyAuthEnabled) { + const proxyAuthUsername = interpolateString(get(brunoConfig, 'proxy.auth.username'), interpolationOptions); + const proxyAuthPassword = interpolateString(get(brunoConfig, 'proxy.auth.password'), interpolationOptions); + + proxy = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}:${proxyPort}`; + } else { + proxy = `${proxyProtocol}://${proxyHostname}:${proxyPort}`; + } + + request.httpsAgent = new HttpsProxyAgent( + proxy, + Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined + ); + + request.httpAgent = new HttpProxyAgent(proxy); + } else if (Object.keys(httpsAgentRequestFields).length > 0) { request.httpsAgent = new https.Agent({ ...httpsAgentRequestFields }); } - const response = await axios(request); + const axiosInstance = makeAxiosInstance(); + + /** @type {import('axios').AxiosResponse} */ + const response = await axiosInstance(request); // run post-response vars const postResponseVars = get(request, 'vars.res', []); @@ -271,14 +288,15 @@ const registerNetworkIpc = (mainWindow) => { if (responseScript && responseScript.length) { const scriptRuntime = new ScriptRuntime(); const result = await scriptRuntime.runResponseScript( - responseScript, + decomment(responseScript), request, response, envVars, collectionVariables, collectionPath, onConsoleLog, - processEnvVars + processEnvVars, + scriptingConfig ); mainWindow.webContents.send('main:script-environment-update', { @@ -291,7 +309,7 @@ const registerNetworkIpc = (mainWindow) => { // run assertions const assertions = get(request, 'assertions'); - if (assertions && assertions.length) { + if (assertions) { const assertRuntime = new AssertRuntime(); const results = assertRuntime.runAssertions( assertions, @@ -313,17 +331,18 @@ const registerNetworkIpc = (mainWindow) => { // run tests const testFile = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests'); - if (testFile && testFile.length) { + if (typeof testFile === 'string') { const testRuntime = new TestRuntime(); const testResults = await testRuntime.runTests( - testFile, + decomment(testFile), request, response, envVars, collectionVariables, collectionPath, onConsoleLog, - processEnvVars + processEnvVars, + scriptingConfig ); mainWindow.webContents.send('main:run-request-event', { @@ -343,12 +362,16 @@ const registerNetworkIpc = (mainWindow) => { } deleteCancelToken(cancelTokenUid); + // Prevents the duration on leaking to the actual result + const requestDuration = response.headers.get('request-duration'); + response.headers.delete('request-duration'); return { status: response.status, statusText: response.statusText, headers: response.headers, - data: response.data + data: response.data, + duration: requestDuration }; } catch (error) { // todo: better error handling @@ -365,7 +388,7 @@ const registerNetworkIpc = (mainWindow) => { if (error && error.response) { // run assertions const assertions = get(request, 'assertions'); - if (assertions && assertions.length) { + if (assertions) { const assertRuntime = new AssertRuntime(); const results = assertRuntime.runAssertions( assertions, @@ -387,17 +410,18 @@ const registerNetworkIpc = (mainWindow) => { // run tests const testFile = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests'); - if (testFile && testFile.length) { + if (typeof testFile === 'string') { const testRuntime = new TestRuntime(); const testResults = await testRuntime.runTests( - testFile, + decomment(testFile), request, error.response, envVars, collectionVariables, collectionPath, onConsoleLog, - processEnvVars + processEnvVars, + scriptingConfig ); mainWindow.webContents.send('main:run-request-event', { @@ -416,11 +440,15 @@ const registerNetworkIpc = (mainWindow) => { }); } + // Prevents the duration from leaking to the actual result + const requestDuration = error.response.headers.get('request-duration'); + error.response.headers.delete('request-duration'); return { status: error.response.status, statusText: error.response.statusText, headers: error.response.headers, - data: error.response.data + data: error.response.data, + duration: requestDuration ?? 0 }; } @@ -441,10 +469,10 @@ const registerNetworkIpc = (mainWindow) => { }); }); - ipcMain.handle('fetch-gql-schema', async (event, endpoint, environment) => { + ipcMain.handle('fetch-gql-schema', async (event, endpoint, environment, request, collection) => { try { const envVars = getEnvVars(environment); - const request = prepareGqlIntrospectionRequest(endpoint, envVars); + const preparedRequest = prepareGqlIntrospectionRequest(endpoint, envVars, request); const preferences = getPreferences(); const sslVerification = get(preferences, 'request.sslVerification', true); @@ -455,7 +483,10 @@ const registerNetworkIpc = (mainWindow) => { }); } - const response = await axios(request); + const processEnvVars = getProcessEnvVars(collection.uid); + interpolateVars(preparedRequest, envVars, collection.collectionVariables, processEnvVars); + + const response = await axios(preparedRequest); return { status: response.status, @@ -483,6 +514,8 @@ const registerNetworkIpc = (mainWindow) => { const collectionUid = collection.uid; const collectionPath = collection.pathname; const folderUid = folder ? folder.uid : null; + const brunoConfig = getBrunoConfig(collectionUid); + const scriptingConfig = get(brunoConfig, 'scripts', {}); const onConsoleLog = (type, args) => { console[type](...args); @@ -582,13 +615,14 @@ const registerNetworkIpc = (mainWindow) => { if (requestScript && requestScript.length) { const scriptRuntime = new ScriptRuntime(); const result = await scriptRuntime.runRequestScript( - requestScript, + decomment(requestScript), request, envVars, collectionVariables, collectionPath, onConsoleLog, - processEnvVars + processEnvVars, + scriptingConfig ); mainWindow.webContents.send('main:script-environment-update', { @@ -598,32 +632,6 @@ const registerNetworkIpc = (mainWindow) => { }); } - // proxy configuration - const brunoConfig = getBrunoConfig(collectionUid); - const proxyEnabled = get(brunoConfig, 'proxy.enabled', false); - if (proxyEnabled) { - const proxyProtocol = get(brunoConfig, 'proxy.protocol'); - const proxyHostname = get(brunoConfig, 'proxy.hostname'); - const proxyPort = get(brunoConfig, 'proxy.port'); - const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false); - - const proxyConfig = { - protocol: proxyProtocol, - hostname: proxyHostname, - port: proxyPort - }; - if (proxyAuthEnabled) { - const proxyAuthUsername = get(brunoConfig, 'proxy.auth.username'); - const proxyAuthPassword = get(brunoConfig, 'proxy.auth.password'); - proxyConfig.auth = { - username: proxyAuthUsername, - password: proxyAuthPassword - }; - } - - request.proxy = proxyConfig; - } - // interpolate variables inside request interpolateVars(request, envVars, collectionVariables, processEnvVars); @@ -644,7 +652,44 @@ const registerNetworkIpc = (mainWindow) => { const preferences = getPreferences(); const sslVerification = get(preferences, 'request.sslVerification', true); - if (!sslVerification) { + // proxy configuration + const brunoConfig = getBrunoConfig(collectionUid); + const proxyEnabled = get(brunoConfig, 'proxy.enabled', false); + if (proxyEnabled) { + let proxy; + const interpolationOptions = { + envVars, + collectionVariables, + processEnvVars + }; + + const proxyProtocol = interpolateString(get(brunoConfig, 'proxy.protocol'), interpolationOptions); + const proxyHostname = interpolateString(get(brunoConfig, 'proxy.hostname'), interpolationOptions); + const proxyPort = interpolateString(get(brunoConfig, 'proxy.port'), interpolationOptions); + const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false); + + if (proxyAuthEnabled) { + const proxyAuthUsername = interpolateString( + get(brunoConfig, 'proxy.auth.username'), + interpolationOptions + ); + + const proxyAuthPassword = interpolateString( + get(brunoConfig, 'proxy.auth.password'), + interpolationOptions + ); + + proxy = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}:${proxyPort}`; + } else { + proxy = `${proxyProtocol}://${proxyHostname}:${proxyPort}`; + } + + request.httpsAgent = new HttpsProxyAgent(proxy, { + rejectUnauthorized: sslVerification + }); + + request.httpAgent = new HttpProxyAgent(proxy); + } else if (!sslVerification) { request.httpsAgent = new https.Agent({ rejectUnauthorized: false }); @@ -683,14 +728,15 @@ const registerNetworkIpc = (mainWindow) => { if (responseScript && responseScript.length) { const scriptRuntime = new ScriptRuntime(); const result = await scriptRuntime.runResponseScript( - responseScript, + decomment(responseScript), request, response, envVars, collectionVariables, collectionPath, onConsoleLog, - processEnvVars + processEnvVars, + scriptingConfig ); mainWindow.webContents.send('main:script-environment-update', { @@ -702,7 +748,7 @@ const registerNetworkIpc = (mainWindow) => { // run assertions const assertions = get(item, 'request.assertions'); - if (assertions && assertions.length) { + if (assertions) { const assertRuntime = new AssertRuntime(); const results = assertRuntime.runAssertions( assertions, @@ -723,17 +769,18 @@ const registerNetworkIpc = (mainWindow) => { // run tests const testFile = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests'); - if (testFile && testFile.length) { + if (typeof testFile === 'string') { const testRuntime = new TestRuntime(); const testResults = await testRuntime.runTests( - testFile, + decomment(testFile), request, response, envVars, collectionVariables, collectionPath, onConsoleLog, - processEnvVars + processEnvVars, + scriptingConfig ); mainWindow.webContents.send('main:run-folder-event', { @@ -781,7 +828,7 @@ const registerNetworkIpc = (mainWindow) => { // run assertions const assertions = get(item, 'request.assertions'); - if (assertions && assertions.length) { + if (assertions) { const assertRuntime = new AssertRuntime(); const results = assertRuntime.runAssertions( assertions, @@ -802,17 +849,18 @@ const registerNetworkIpc = (mainWindow) => { // run tests const testFile = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests'); - if (testFile && testFile.length) { + if (typeof testFile === 'string') { const testRuntime = new TestRuntime(); const testResults = await testRuntime.runTests( - testFile, + decomment(testFile), request, error.response, envVars, collectionVariables, collectionPath, onConsoleLog, - processEnvVars + processEnvVars, + scriptingConfig ); mainWindow.webContents.send('main:run-folder-event', { diff --git a/packages/bruno-electron/src/ipc/network/interpolate-string.js b/packages/bruno-electron/src/ipc/network/interpolate-string.js new file mode 100644 index 00000000..33701dd0 --- /dev/null +++ b/packages/bruno-electron/src/ipc/network/interpolate-string.js @@ -0,0 +1,55 @@ +const Handlebars = require('handlebars'); +const { forOwn, cloneDeep } = require('lodash'); + +const interpolateEnvVars = (str, processEnvVars) => { + if (!str || !str.length || typeof str !== 'string') { + return str; + } + + const template = Handlebars.compile(str, { noEscape: true }); + + return template({ + process: { + env: { + ...processEnvVars + } + } + }); +}; + +const interpolateString = (str, { envVars, collectionVariables, processEnvVars }) => { + if (!str || !str.length || typeof str !== 'string') { + return str; + } + + processEnvVars = processEnvVars || {}; + collectionVariables = collectionVariables || {}; + + // we clone envVars because we don't want to modify the original object + envVars = envVars ? cloneDeep(envVars) : {}; + + // envVars can inturn have values as {{process.env.VAR_NAME}} + // so we need to interpolate envVars first with processEnvVars + forOwn(envVars, (value, key) => { + envVars[key] = interpolateEnvVars(value, processEnvVars); + }); + + const template = Handlebars.compile(str, { noEscape: true }); + + // collectionVariables take precedence over envVars + const combinedVars = { + ...envVars, + ...collectionVariables, + process: { + env: { + ...processEnvVars + } + } + }; + + return template(combinedVars); +}; + +module.exports = { + interpolateString +}; diff --git a/packages/bruno-electron/src/ipc/network/interpolate-vars.js b/packages/bruno-electron/src/ipc/network/interpolate-vars.js index f4072dbe..b9261647 100644 --- a/packages/bruno-electron/src/ipc/network/interpolate-vars.js +++ b/packages/bruno-electron/src/ipc/network/interpolate-vars.js @@ -109,6 +109,18 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces } } + // todo: we have things happening in two places w.r.t basic auth + // need to refactor this in the future + // the request.auth (basic auth) object gets set inside the prepare-request.js file + if (request.auth) { + const username = interpolate(request.auth.username) || ''; + const password = interpolate(request.auth.password) || ''; + + // use auth header based approach and delete the request.auth object + request.headers['authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + delete request.auth; + } + return request; }; diff --git a/packages/bruno-electron/src/ipc/network/prepare-gql-introspection-request.js b/packages/bruno-electron/src/ipc/network/prepare-gql-introspection-request.js index a36666e3..4a1e41c8 100644 --- a/packages/bruno-electron/src/ipc/network/prepare-gql-introspection-request.js +++ b/packages/bruno-electron/src/ipc/network/prepare-gql-introspection-request.js @@ -1,31 +1,48 @@ -const Mustache = require('mustache'); +const Handlebars = require('handlebars'); const { getIntrospectionQuery } = require('graphql'); +const { get } = require('lodash'); -// override the default escape function to prevent escaping -Mustache.escape = function (value) { - return value; -}; - -const prepareGqlIntrospectionRequest = (endpoint, envVars) => { +const prepareGqlIntrospectionRequest = (endpoint, envVars, request) => { if (endpoint && endpoint.length) { - endpoint = Mustache.render(endpoint, envVars); + endpoint = Handlebars.compile(endpoint, { noEscape: true })(envVars); } + const introspectionQuery = getIntrospectionQuery(); const queryParams = { query: introspectionQuery }; - const request = { + let axiosRequest = { method: 'POST', url: endpoint, headers: { + ...mapHeaders(request.headers), Accept: 'application/json', 'Content-Type': 'application/json' }, data: JSON.stringify(queryParams) }; - return request; + if (request.auth) { + if (request.auth.mode === 'basic') { + axiosRequest.auth = { + username: get(request, 'auth.basic.username'), + password: get(request, 'auth.basic.password') + }; + } + + if (request.auth.mode === 'bearer') { + axiosRequest.headers.authorization = `Bearer ${get(request, 'auth.bearer.token')}`; + } + } + + return axiosRequest; +}; + +const mapHeaders = (headers) => { + const entries = headers.filter((header) => header.enabled).map(({ name, value }) => [name, value]); + + return Object.fromEntries(entries); }; module.exports = prepareGqlIntrospectionRequest; diff --git a/packages/bruno-electron/src/ipc/network/prepare-request.js b/packages/bruno-electron/src/ipc/network/prepare-request.js index f07331c5..922c9929 100644 --- a/packages/bruno-electron/src/ipc/network/prepare-request.js +++ b/packages/bruno-electron/src/ipc/network/prepare-request.js @@ -1,4 +1,5 @@ const { get, each, filter } = require('lodash'); +const decomment = require('decomment'); const prepareRequest = (request) => { const headers = {}; @@ -18,12 +19,27 @@ const prepareRequest = (request) => { headers: headers }; + // Authentication + if (request.auth) { + if (request.auth.mode === 'basic') { + axiosRequest.auth = { + username: get(request, 'auth.basic.username'), + password: get(request, 'auth.basic.password') + }; + } + + if (request.auth.mode === 'bearer') { + axiosRequest.headers['authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`; + } + } + if (request.body.mode === 'json') { if (!contentTypeDefined) { axiosRequest.headers['content-type'] = 'application/json'; } try { - axiosRequest.data = JSON.parse(request.body.json); + // axiosRequest.data = JSON.parse(request.body.json); + axiosRequest.data = JSON.parse(decomment(request.body.json)); } catch (ex) { axiosRequest.data = request.body.json; } @@ -62,7 +78,7 @@ const prepareRequest = (request) => { if (request.body.mode === 'graphql') { const graphqlQuery = { query: get(request, 'body.graphql.query'), - variables: JSON.parse(get(request, 'body.graphql.variables') || '{}') + variables: JSON.parse(decomment(get(request, 'body.graphql.variables') || '{}')) }; if (!contentTypeDefined) { axiosRequest.headers['content-type'] = 'application/json'; diff --git a/packages/bruno-electron/src/store/window-state.js b/packages/bruno-electron/src/store/window-state.js new file mode 100644 index 00000000..bb0a61b6 --- /dev/null +++ b/packages/bruno-electron/src/store/window-state.js @@ -0,0 +1,31 @@ +const _ = require('lodash'); +const Store = require('electron-store'); + +const DEFAULT_WINDOW_WIDTH = 1280; +const DEFAULT_WINDOW_HEIGHT = 768; + +class WindowStateStore { + constructor() { + this.store = new Store({ + name: 'preferences', + clearInvalidConfig: true + }); + } + + getBounds() { + return ( + this.store.get('window-bounds') || { + x: 0, + y: 0, + width: DEFAULT_WINDOW_WIDTH, + height: DEFAULT_WINDOW_HEIGHT + } + ); + } + + setBounds(bounds) { + this.store.set('window-bounds', bounds); + } +} + +module.exports = WindowStateStore; diff --git a/packages/bruno-electron/src/utils/window.js b/packages/bruno-electron/src/utils/window.js new file mode 100644 index 00000000..d824141d --- /dev/null +++ b/packages/bruno-electron/src/utils/window.js @@ -0,0 +1,53 @@ +const { screen } = require('electron'); +const WindowStateStore = require('../store/window-state'); + +const windowStateStore = new WindowStateStore(); + +const DEFAULT_WINDOW_WIDTH = 1280; +const DEFAULT_WINDOW_HEIGHT = 768; + +const loadWindowState = () => { + const bounds = windowStateStore.getBounds(); + + const positionValid = isPositionValid(bounds); + const sizeValid = isSizeValid(bounds); + + return { + x: bounds.x && positionValid ? bounds.x : undefined, + y: bounds.y && positionValid ? bounds.y : undefined, + width: bounds.width && sizeValid ? bounds.width : DEFAULT_WINDOW_WIDTH, + height: bounds.height && sizeValid ? bounds.height : DEFAULT_WINDOW_HEIGHT + }; +}; + +const saveWindowState = (window) => { + const bounds = window.getBounds(); + + windowStateStore.setBounds(bounds); +}; + +const isPositionValid = (bounds) => { + const area = getArea(bounds); + + return ( + bounds.x >= area.x && + bounds.y >= area.y && + bounds.x + bounds.width <= area.x + area.width && + bounds.y + bounds.height <= area.y + area.height + ); +}; + +const isSizeValid = (bounds) => { + const area = getArea(bounds); + + return bounds.width <= area.width && bounds.height <= area.height; +}; + +const getArea = (bounds) => { + return screen.getDisplayMatching(bounds).workArea; +}; + +module.exports = { + loadWindowState, + saveWindowState +}; diff --git a/packages/bruno-js/package.json b/packages/bruno-js/package.json index 00c66c25..00ccdcb1 100644 --- a/packages/bruno-js/package.json +++ b/packages/bruno-js/package.json @@ -1,6 +1,6 @@ { "name": "@usebruno/js", - "version": "0.6.0", + "version": "0.8.0", "license": "MIT", "main": "src/index.js", "files": [ diff --git a/packages/bruno-js/src/bru.js b/packages/bruno-js/src/bru.js index 6eec791f..3cd9e8f5 100644 --- a/packages/bruno-js/src/bru.js +++ b/packages/bruno-js/src/bru.js @@ -2,10 +2,11 @@ const Handlebars = require('handlebars'); const { cloneDeep } = require('lodash'); class Bru { - constructor(envVariables, collectionVariables, processEnvVars) { + constructor(envVariables, collectionVariables, processEnvVars, collectionPath) { this.envVariables = envVariables; this.collectionVariables = collectionVariables; this.processEnvVars = cloneDeep(processEnvVars || {}); + this.collectionPath = collectionPath; } _interpolateEnvVar = (str) => { @@ -24,6 +25,10 @@ class Bru { }); }; + cwd() { + return this.collectionPath; + } + getEnvName() { return this.envVariables.__name__; } diff --git a/packages/bruno-js/src/runtime/assert-runtime.js b/packages/bruno-js/src/runtime/assert-runtime.js index f0f84fbc..a9588263 100644 --- a/packages/bruno-js/src/runtime/assert-runtime.js +++ b/packages/bruno-js/src/runtime/assert-runtime.js @@ -268,7 +268,7 @@ class AssertRuntime { expect(lhs).to.endWith(rhs); break; case 'between': - const [min, max] = value.split(','); + const [min, max] = rhs; expect(lhs).to.be.within(min, max); break; case 'isEmpty': diff --git a/packages/bruno-js/src/runtime/script-runtime.js b/packages/bruno-js/src/runtime/script-runtime.js index 41af51ee..0d9d246f 100644 --- a/packages/bruno-js/src/runtime/script-runtime.js +++ b/packages/bruno-js/src/runtime/script-runtime.js @@ -7,6 +7,8 @@ const util = require('util'); const zlib = require('zlib'); const url = require('url'); const punycode = require('punycode'); +const fs = require('fs'); +const { get } = require('lodash'); const Bru = require('../bru'); const BrunoRequest = require('../bruno-request'); const BrunoResponse = require('../bruno-response'); @@ -27,6 +29,8 @@ const CryptoJS = require('crypto-js'); class ScriptRuntime { constructor() {} + // This approach is getting out of hand + // Need to refactor this to use a single arg (object) instead of 7 async runRequestScript( script, request, @@ -34,10 +38,24 @@ class ScriptRuntime { collectionVariables, collectionPath, onConsoleLog, - processEnvVars + processEnvVars, + scriptingConfig ) { - const bru = new Bru(envVariables, collectionVariables, processEnvVars); + const bru = new Bru(envVariables, collectionVariables, processEnvVars, collectionPath); const req = new BrunoRequest(request); + const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false); + const moduleWhitelist = get(scriptingConfig, 'moduleWhitelist', []); + + const whitelistedModules = {}; + + for (let module of moduleWhitelist) { + try { + whitelistedModules[module] = require(module); + } catch (e) { + // Ignore + console.warn(e); + } + } const context = { bru, @@ -84,7 +102,9 @@ class ScriptRuntime { axios, chai, 'node-fetch': fetch, - 'crypto-js': CryptoJS + 'crypto-js': CryptoJS, + ...whitelistedModules, + fs: allowScriptFilesystemAccess ? fs : undefined } } }); @@ -105,11 +125,25 @@ class ScriptRuntime { collectionVariables, collectionPath, onConsoleLog, - processEnvVars + processEnvVars, + scriptingConfig ) { - const bru = new Bru(envVariables, collectionVariables, processEnvVars); + const bru = new Bru(envVariables, collectionVariables, processEnvVars, collectionPath); const req = new BrunoRequest(request); const res = new BrunoResponse(response); + const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false); + const moduleWhitelist = get(scriptingConfig, 'moduleWhitelist', []); + + const whitelistedModules = {}; + + for (let module of moduleWhitelist) { + try { + whitelistedModules[module] = require(module); + } catch (e) { + // Ignore + console.warn(e); + } + } const context = { bru, @@ -138,6 +172,16 @@ class ScriptRuntime { external: true, root: [collectionPath], mock: { + // node libs + path, + stream, + util, + url, + http, + https, + punycode, + zlib, + // 3rd party libs atob, btoa, lodash, @@ -146,7 +190,9 @@ class ScriptRuntime { nanoid, axios, 'node-fetch': fetch, - 'crypto-js': CryptoJS + 'crypto-js': CryptoJS, + ...whitelistedModules, + fs: allowScriptFilesystemAccess ? fs : undefined } } }); diff --git a/packages/bruno-js/src/runtime/test-runtime.js b/packages/bruno-js/src/runtime/test-runtime.js index 47daccd6..b4ad3f04 100644 --- a/packages/bruno-js/src/runtime/test-runtime.js +++ b/packages/bruno-js/src/runtime/test-runtime.js @@ -1,6 +1,15 @@ const { NodeVM } = require('vm2'); const chai = require('chai'); const path = require('path'); +const http = require('http'); +const https = require('https'); +const stream = require('stream'); +const util = require('util'); +const zlib = require('zlib'); +const url = require('url'); +const punycode = require('punycode'); +const fs = require('fs'); +const { get } = require('lodash'); const Bru = require('../bru'); const BrunoRequest = require('../bruno-request'); const BrunoResponse = require('../bruno-response'); @@ -29,11 +38,25 @@ class TestRuntime { collectionVariables, collectionPath, onConsoleLog, - processEnvVars + processEnvVars, + scriptingConfig ) { - const bru = new Bru(envVariables, collectionVariables, processEnvVars); + const bru = new Bru(envVariables, collectionVariables, processEnvVars, collectionPath); const req = new BrunoRequest(request); const res = new BrunoResponse(response); + const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false); + const moduleWhitelist = get(scriptingConfig, 'moduleWhitelist', []); + + const whitelistedModules = {}; + + for (let module of moduleWhitelist) { + try { + whitelistedModules[module] = require(module); + } catch (e) { + // Ignore + console.warn(e); + } + } const __brunoTestResults = new TestResults(); const test = Test(__brunoTestResults, chai); @@ -78,6 +101,16 @@ class TestRuntime { external: true, root: [collectionPath], mock: { + // node libs + path, + stream, + util, + url, + http, + https, + punycode, + zlib, + // 3rd party libs atob, axios, btoa, @@ -86,7 +119,9 @@ class TestRuntime { uuid, nanoid, chai, - 'crypto-js': CryptoJS + 'crypto-js': CryptoJS, + ...whitelistedModules, + fs: allowScriptFilesystemAccess ? fs : undefined } } }); diff --git a/packages/bruno-lang/package.json b/packages/bruno-lang/package.json index 91ad7688..fffde571 100644 --- a/packages/bruno-lang/package.json +++ b/packages/bruno-lang/package.json @@ -1,7 +1,7 @@ { "name": "@usebruno/lang", - "version": "0.4.0", - "license" : "MIT", + "version": "0.5.0", + "license": "MIT", "main": "src/index.js", "files": [ "src", diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index 2241ca55..576c58c2 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -22,7 +22,8 @@ const { outdentString } = require('../../v1/src/utils'); * */ const grammar = ohm.grammar(`Bru { - BruFile = (meta | http | query | headers | bodies | varsandassert | script | tests | docs)* + BruFile = (meta | http | query | headers | auths | bodies | varsandassert | script | tests | docs)* + auths = authbasic | authbearer bodies = bodyjson | bodytext | bodyxml | bodygraphql | bodygraphqlvars | bodyforms | body bodyforms = bodyformurlencoded | bodymultipart @@ -75,6 +76,9 @@ const grammar = ohm.grammar(`Bru { varsres = "vars:post-response" dictionary assert = "assert" assertdictionary + authbasic = "auth:basic" dictionary + authbearer = "auth:bearer" dictionary + body = "body" st* "{" nl* textblock tagend bodyjson = "body:json" st* "{" nl* textblock tagend bodytext = "body:text" st* "{" nl* textblock tagend @@ -92,13 +96,21 @@ const grammar = ohm.grammar(`Bru { docs = "docs" st* "{" nl* textblock tagend }`); -const mapPairListToKeyValPairs = (pairList = []) => { +const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => { if (!pairList.length) { return []; } return _.map(pairList[0], (pair) => { let name = _.keys(pair)[0]; let value = pair[name]; + + if (!parseEnabled) { + return { + name, + value + }; + } + let enabled = true; if (name && name.length && name.charAt(0) === '~') { name = name.slice(1); @@ -282,6 +294,33 @@ const sem = grammar.createSemantics().addAttribute('ast', { headers: mapPairListToKeyValPairs(dictionary.ast) }; }, + authbasic(_1, dictionary) { + const auth = mapPairListToKeyValPairs(dictionary.ast, false); + const usernameKey = _.find(auth, { name: 'username' }); + const passwordKey = _.find(auth, { name: 'password' }); + const username = usernameKey ? usernameKey.value : ''; + const password = passwordKey ? passwordKey.value : ''; + return { + auth: { + basic: { + username, + password + } + } + }; + }, + authbearer(_1, dictionary) { + const auth = mapPairListToKeyValPairs(dictionary.ast, false); + const tokenKey = _.find(auth, { name: 'token' }); + const token = tokenKey ? tokenKey.value : ''; + return { + auth: { + bearer: { + token + } + } + }; + }, bodyformurlencoded(_1, dictionary) { return { body: { diff --git a/packages/bruno-lang/v2/src/collectionBruToJson.js b/packages/bruno-lang/v2/src/collectionBruToJson.js new file mode 100644 index 00000000..d78f752c --- /dev/null +++ b/packages/bruno-lang/v2/src/collectionBruToJson.js @@ -0,0 +1,273 @@ +const ohm = require('ohm-js'); +const _ = require('lodash'); +const { outdentString } = require('../../v1/src/utils'); + +const grammar = ohm.grammar(`Bru { + BruFile = (meta | query | headers | auth | auths | vars | script | tests | docs)* + auths = authbasic | authbearer + + nl = "\\r"? "\\n" + st = " " | "\\t" + stnl = st | nl + tagend = nl "}" + optionalnl = ~tagend nl + keychar = ~(tagend | st | nl | ":") any + valuechar = ~(nl | tagend) any + + // Dictionary Blocks + dictionary = st* "{" pairlist? tagend + pairlist = optionalnl* pair (~tagend stnl* pair)* (~tagend space)* + pair = st* key st* ":" st* value st* + key = keychar* + value = valuechar* + + // Text Blocks + textblock = textline (~tagend nl textline)* + textline = textchar* + textchar = ~nl any + + meta = "meta" dictionary + + auth = "auth" dictionary + + headers = "headers" dictionary + + query = "query" dictionary + + vars = varsreq | varsres + varsreq = "vars:pre-request" dictionary + varsres = "vars:post-response" dictionary + + authbasic = "auth:basic" dictionary + authbearer = "auth:bearer" dictionary + + script = scriptreq | scriptres + scriptreq = "script:pre-request" st* "{" nl* textblock tagend + scriptres = "script:post-response" st* "{" nl* textblock tagend + tests = "tests" st* "{" nl* textblock tagend + docs = "docs" st* "{" nl* textblock tagend +}`); + +const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => { + if (!pairList.length) { + return []; + } + return _.map(pairList[0], (pair) => { + let name = _.keys(pair)[0]; + let value = pair[name]; + + if (!parseEnabled) { + return { + name, + value + }; + } + + let enabled = true; + if (name && name.length && name.charAt(0) === '~') { + name = name.slice(1); + enabled = false; + } + + return { + name, + value, + enabled + }; + }); +}; + +const concatArrays = (objValue, srcValue) => { + if (_.isArray(objValue) && _.isArray(srcValue)) { + return objValue.concat(srcValue); + } +}; + +const mapPairListToKeyValPair = (pairList = []) => { + if (!pairList || !pairList.length) { + return {}; + } + + return _.merge({}, ...pairList[0]); +}; + +const sem = grammar.createSemantics().addAttribute('ast', { + BruFile(tags) { + if (!tags || !tags.ast || !tags.ast.length) { + return {}; + } + + return _.reduce( + tags.ast, + (result, item) => { + return _.mergeWith(result, item, concatArrays); + }, + {} + ); + }, + dictionary(_1, _2, pairlist, _3) { + return pairlist.ast; + }, + pairlist(_1, pair, _2, rest, _3) { + return [pair.ast, ...rest.ast]; + }, + pair(_1, key, _2, _3, _4, value, _5) { + let res = {}; + res[key.ast] = value.ast ? value.ast.trim() : ''; + return res; + }, + key(chars) { + return chars.sourceString ? chars.sourceString.trim() : ''; + }, + value(chars) { + return chars.sourceString ? chars.sourceString.trim() : ''; + }, + textblock(line, _1, rest) { + return [line.ast, ...rest.ast].join('\n'); + }, + textline(chars) { + return chars.sourceString; + }, + textchar(char) { + return char.sourceString; + }, + nl(_1, _2) { + return ''; + }, + st(_) { + return ''; + }, + tagend(_1, _2) { + return ''; + }, + _iter(...elements) { + return elements.map((e) => e.ast); + }, + meta(_1, dictionary) { + let meta = mapPairListToKeyValPair(dictionary.ast) || {}; + + meta.type = 'collection'; + + return { + meta + }; + }, + auth(_1, dictionary) { + let auth = mapPairListToKeyValPair(dictionary.ast) || {}; + + return { + auth: { + mode: auth ? auth.mode : 'none' + } + }; + }, + query(_1, dictionary) { + return { + query: mapPairListToKeyValPairs(dictionary.ast) + }; + }, + headers(_1, dictionary) { + return { + headers: mapPairListToKeyValPairs(dictionary.ast) + }; + }, + authbasic(_1, dictionary) { + const auth = mapPairListToKeyValPairs(dictionary.ast, false); + const usernameKey = _.find(auth, { name: 'username' }); + const passwordKey = _.find(auth, { name: 'password' }); + const username = usernameKey ? usernameKey.value : ''; + const password = passwordKey ? passwordKey.value : ''; + return { + auth: { + basic: { + username, + password + } + } + }; + }, + authbearer(_1, dictionary) { + const auth = mapPairListToKeyValPairs(dictionary.ast, false); + const tokenKey = _.find(auth, { name: 'token' }); + const token = tokenKey ? tokenKey.value : ''; + return { + auth: { + bearer: { + token + } + } + }; + }, + varsreq(_1, dictionary) { + const vars = mapPairListToKeyValPairs(dictionary.ast); + _.each(vars, (v) => { + let name = v.name; + if (name && name.length && name.charAt(0) === '@') { + v.name = name.slice(1); + v.local = true; + } else { + v.local = false; + } + }); + + return { + vars: { + req: vars + } + }; + }, + varsres(_1, dictionary) { + const vars = mapPairListToKeyValPairs(dictionary.ast); + _.each(vars, (v) => { + let name = v.name; + if (name && name.length && name.charAt(0) === '@') { + v.name = name.slice(1); + v.local = true; + } else { + v.local = false; + } + }); + + return { + vars: { + res: vars + } + }; + }, + scriptreq(_1, _2, _3, _4, textblock, _5) { + return { + script: { + req: outdentString(textblock.sourceString) + } + }; + }, + scriptres(_1, _2, _3, _4, textblock, _5) { + return { + script: { + res: outdentString(textblock.sourceString) + } + }; + }, + tests(_1, _2, _3, _4, textblock, _5) { + return { + tests: outdentString(textblock.sourceString) + }; + }, + docs(_1, _2, _3, _4, textblock, _5) { + return { + docs: outdentString(textblock.sourceString) + }; + } +}); + +const parser = (input) => { + const match = grammar.match(input); + + if (match.succeeded()) { + return sem(match).ast; + } else { + throw new Error(match.message); + } +}; + +module.exports = parser; diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js index 818d7c9c..8ef44d7a 100644 --- a/packages/bruno-lang/v2/src/jsonToBru.js +++ b/packages/bruno-lang/v2/src/jsonToBru.js @@ -13,7 +13,7 @@ const stripLastLine = (text) => { }; const jsonToBru = (json) => { - const { meta, http, query, headers, body, script, tests, vars, assertions, docs } = json; + const { meta, http, query, headers, auth, body, script, tests, vars, assertions, docs } = json; let bru = ''; @@ -34,6 +34,11 @@ const jsonToBru = (json) => { body: ${http.body}`; } + if (http.auth && http.auth.length) { + bru += ` + auth: ${http.auth}`; + } + bru += ` } @@ -82,6 +87,23 @@ const jsonToBru = (json) => { bru += '\n}\n\n'; } + if (auth && auth.basic) { + bru += `auth:basic { +${indentString(`username: ${auth.basic.username}`)} +${indentString(`password: ${auth.basic.password}`)} +} + +`; + } + + if (auth && auth.bearer) { + bru += `auth:bearer { +${indentString(`token: ${auth.bearer.token}`)} +} + +`; + } + if (body && body.json && body.json.length) { bru += `body:json { ${indentString(body.json)} diff --git a/packages/bruno-lang/v2/src/jsonToCollectionBru.js b/packages/bruno-lang/v2/src/jsonToCollectionBru.js new file mode 100644 index 00000000..f20fbc68 --- /dev/null +++ b/packages/bruno-lang/v2/src/jsonToCollectionBru.js @@ -0,0 +1,185 @@ +const _ = require('lodash'); + +const { indentString } = require('../../v1/src/utils'); + +const enabled = (items = []) => items.filter((item) => item.enabled); +const disabled = (items = []) => items.filter((item) => !item.enabled); + +// remove the last line if two new lines are found +const stripLastLine = (text) => { + if (!text || !text.length) return text; + + return text.replace(/(\r?\n)$/, ''); +}; + +const jsonToBru = (json) => { + const { meta, query, headers, auth, script, tests, vars, docs } = json; + + let bru = ''; + + if (meta) { + bru += 'meta {\n'; + for (const key in meta) { + bru += ` ${key}: ${meta[key]}\n`; + } + bru += '}\n\n'; + } + + if (query && query.length) { + bru += 'query {'; + if (enabled(query).length) { + bru += `\n${indentString( + enabled(query) + .map((item) => `${item.name}: ${item.value}`) + .join('\n') + )}`; + } + + if (disabled(query).length) { + bru += `\n${indentString( + disabled(query) + .map((item) => `~${item.name}: ${item.value}`) + .join('\n') + )}`; + } + + bru += '\n}\n\n'; + } + + if (headers && headers.length) { + bru += 'headers {'; + if (enabled(headers).length) { + bru += `\n${indentString( + enabled(headers) + .map((item) => `${item.name}: ${item.value}`) + .join('\n') + )}`; + } + + if (disabled(headers).length) { + bru += `\n${indentString( + disabled(headers) + .map((item) => `~${item.name}: ${item.value}`) + .join('\n') + )}`; + } + + bru += '\n}\n\n'; + } + + if (auth && auth.mode) { + bru += `auth { +${indentString(`mode: ${auth.mode}`)} +} + +`; + } + + if (auth && auth.basic) { + bru += `auth:basic { +${indentString(`username: ${auth.basic.username}`)} +${indentString(`password: ${auth.basic.password}`)} +} + +`; + } + + if (auth && auth.bearer) { + bru += `auth:bearer { +${indentString(`token: ${auth.bearer.token}`)} +} + +`; + } + + let reqvars = _.get(vars, 'req'); + let resvars = _.get(vars, 'res'); + if (reqvars && reqvars.length) { + const varsEnabled = _.filter(reqvars, (v) => v.enabled && !v.local); + const varsDisabled = _.filter(reqvars, (v) => !v.enabled && !v.local); + const varsLocalEnabled = _.filter(reqvars, (v) => v.enabled && v.local); + const varsLocalDisabled = _.filter(reqvars, (v) => !v.enabled && v.local); + + bru += `vars:pre-request {`; + + if (varsEnabled.length) { + bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${item.value}`).join('\n'))}`; + } + + if (varsLocalEnabled.length) { + bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${item.value}`).join('\n'))}`; + } + + if (varsDisabled.length) { + bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${item.value}`).join('\n'))}`; + } + + if (varsLocalDisabled.length) { + bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${item.value}`).join('\n'))}`; + } + + bru += '\n}\n\n'; + } + if (resvars && resvars.length) { + const varsEnabled = _.filter(resvars, (v) => v.enabled && !v.local); + const varsDisabled = _.filter(resvars, (v) => !v.enabled && !v.local); + const varsLocalEnabled = _.filter(resvars, (v) => v.enabled && v.local); + const varsLocalDisabled = _.filter(resvars, (v) => !v.enabled && v.local); + + bru += `vars:post-response {`; + + if (varsEnabled.length) { + bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${item.value}`).join('\n'))}`; + } + + if (varsLocalEnabled.length) { + bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${item.value}`).join('\n'))}`; + } + + if (varsDisabled.length) { + bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${item.value}`).join('\n'))}`; + } + + if (varsLocalDisabled.length) { + bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${item.value}`).join('\n'))}`; + } + + bru += '\n}\n\n'; + } + + if (script && script.req && script.req.length) { + bru += `script:pre-request { +${indentString(script.req)} +} + +`; + } + + if (script && script.res && script.res.length) { + bru += `script:post-response { +${indentString(script.res)} +} + +`; + } + + if (tests && tests.length) { + bru += `tests { +${indentString(tests)} +} + +`; + } + + if (docs && docs.length) { + bru += `docs { +${indentString(docs)} +} + +`; + } + + return stripLastLine(bru); +}; + +module.exports = jsonToBru; diff --git a/packages/bruno-lang/v2/tests/collection.spec.js b/packages/bruno-lang/v2/tests/collection.spec.js new file mode 100644 index 00000000..4bdb7f9d --- /dev/null +++ b/packages/bruno-lang/v2/tests/collection.spec.js @@ -0,0 +1,24 @@ +const fs = require('fs'); +const path = require('path'); +const collectionBruToJson = require('../src/collectionBruToJson'); +const jsonToCollectionBru = require('../src/jsonToCollectionBru'); + +describe('collectionBruToJson', () => { + it('should parse the collection bru file', () => { + const input = fs.readFileSync(path.join(__dirname, 'fixtures', 'collection.bru'), 'utf8'); + const expected = require('./fixtures/collection.json'); + const output = collectionBruToJson(input); + + expect(output).toEqual(expected); + }); +}); + +describe('jsonToCollectionBru', () => { + it('should convert the collection json to bru', () => { + const input = require('./fixtures/collection.json'); + const expected = fs.readFileSync(path.join(__dirname, 'fixtures', 'collection.bru'), 'utf8'); + const output = jsonToCollectionBru(input); + + expect(output).toEqual(expected); + }); +}); diff --git a/packages/bruno-lang/v2/tests/fixtures/collection.bru b/packages/bruno-lang/v2/tests/fixtures/collection.bru new file mode 100644 index 00000000..a02be30c --- /dev/null +++ b/packages/bruno-lang/v2/tests/fixtures/collection.bru @@ -0,0 +1,43 @@ +meta { + type: collection +} + +headers { + content-type: application/json + Authorization: Bearer 123 + ~transaction-id: {{transactionId}} +} + +auth { + mode: none +} + +auth:basic { + username: john + password: secret +} + +auth:bearer { + token: 123 +} + +vars:pre-request { + departingDate: 2020-01-01 + ~returningDate: 2020-01-02 +} + +vars:post-response { + ~transactionId: $res.body.transactionId +} + +script:pre-request { + console.log("In Collection pre Request Script"); +} + +script:post-response { + console.log("In Collection post Request Script"); +} + +docs { + This request needs auth token to be set in the headers. +} diff --git a/packages/bruno-lang/v2/tests/fixtures/collection.json b/packages/bruno-lang/v2/tests/fixtures/collection.json new file mode 100644 index 00000000..de827d11 --- /dev/null +++ b/packages/bruno-lang/v2/tests/fixtures/collection.json @@ -0,0 +1,61 @@ +{ + "meta": { + "type": "collection" + }, + "headers": [ + { + "name": "content-type", + "value": "application/json", + "enabled": true + }, + { + "name": "Authorization", + "value": "Bearer 123", + "enabled": true + }, + { + "name": "transaction-id", + "value": "{{transactionId}}", + "enabled": false + } + ], + "auth": { + "mode": "none", + "basic": { + "username": "john", + "password": "secret" + }, + "bearer": { + "token": "123" + } + }, + "vars": { + "req": [ + { + "name": "departingDate", + "value": "2020-01-01", + "enabled": true, + "local": false + }, + { + "name": "returningDate", + "value": "2020-01-02", + "enabled": false, + "local": false + } + ], + "res": [ + { + "name": "transactionId", + "value": "$res.body.transactionId", + "enabled": false, + "local": false + } + ] + }, + "script": { + "req": "console.log(\"In Collection pre Request Script\");", + "res": "console.log(\"In Collection post Request Script\");" + }, + "docs": "This request needs auth token to be set in the headers." +} diff --git a/packages/bruno-lang/v2/tests/fixtures/request.bru b/packages/bruno-lang/v2/tests/fixtures/request.bru index ae7318da..c4ae4b05 100644 --- a/packages/bruno-lang/v2/tests/fixtures/request.bru +++ b/packages/bruno-lang/v2/tests/fixtures/request.bru @@ -7,6 +7,7 @@ meta { get { url: https://api.textlocal.in/send body: json + auth: bearer } query { @@ -21,6 +22,15 @@ headers { ~transaction-id: {{transactionId}} } +auth:basic { + username: john + password: secret +} + +auth:bearer { + token: 123 +} + body:json { { "hello": "world" diff --git a/packages/bruno-lang/v2/tests/fixtures/request.json b/packages/bruno-lang/v2/tests/fixtures/request.json index 867229de..7a00f5bb 100644 --- a/packages/bruno-lang/v2/tests/fixtures/request.json +++ b/packages/bruno-lang/v2/tests/fixtures/request.json @@ -7,7 +7,8 @@ "http": { "method": "get", "url": "https://api.textlocal.in/send", - "body": "json" + "body": "json", + "auth": "bearer" }, "query": [ { @@ -43,6 +44,15 @@ "enabled": false } ], + "auth": { + "basic": { + "username": "john", + "password": "secret" + }, + "bearer": { + "token": "123" + } + }, "body": { "json": "{\n \"hello\": \"world\"\n}", "text": "This is a text body", diff --git a/packages/bruno-lang/v2/tests/index.spec.js b/packages/bruno-lang/v2/tests/index.spec.js index ecc97067..e753ee96 100644 --- a/packages/bruno-lang/v2/tests/index.spec.js +++ b/packages/bruno-lang/v2/tests/index.spec.js @@ -14,7 +14,7 @@ describe('bruToJson', () => { }); describe('jsonToBru', () => { - it('should parse the bru file', () => { + it('should parse the json file', () => { const input = require('./fixtures/request.json'); const expected = fs.readFileSync(path.join(__dirname, 'fixtures', 'request.bru'), 'utf8'); const output = jsonToBru(input); diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index 7076175b..337f0147 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -69,6 +69,27 @@ const requestBodySchema = Yup.object({ .noUnknown(true) .strict(); +const authBasicSchema = Yup.object({ + username: Yup.string().nullable(), + password: Yup.string().nullable() +}) + .noUnknown(true) + .strict(); + +const authBearerSchema = Yup.object({ + token: Yup.string().nullable() +}) + .noUnknown(true) + .strict(); + +const authSchema = Yup.object({ + mode: Yup.string().oneOf(['none', 'basic', 'bearer']).required('mode is required'), + basic: authBasicSchema.nullable(), + bearer: authBearerSchema.nullable() +}) + .noUnknown(true) + .strict(); + // Right now, the request schema is very tightly coupled with http request // As we introduce more request types in the future, we will improve the definition to support // schema structure based on other request type @@ -77,6 +98,7 @@ const requestSchema = Yup.object({ method: requestMethodSchema, headers: Yup.array().of(keyValueSchema).required('headers are required'), params: Yup.array().of(keyValueSchema).required('params are required'), + auth: authSchema, body: requestBodySchema, script: Yup.object({ req: Yup.string().nullable(), @@ -101,7 +123,7 @@ const itemSchema = Yup.object({ uid: uidSchema, type: Yup.string().oneOf(['http-request', 'graphql-request', 'folder']).required('type is required'), seq: Yup.number().min(1), - name: Yup.string().min(1, 'name must be atleast 1 characters').required('name is required'), + name: Yup.string().min(1, 'name must be at least 1 character').required('name is required'), request: requestSchema.when('type', { is: (type) => ['http-request', 'graphql-request'].includes(type), then: (schema) => schema.required('request is required when item-type is request') @@ -116,7 +138,7 @@ const itemSchema = Yup.object({ const collectionSchema = Yup.object({ version: Yup.string().oneOf(['1']).required('version is required'), uid: uidSchema, - name: Yup.string().min(1, 'name must be atleast 1 characters').required('name is required'), + name: Yup.string().min(1, 'name must be at least 1 character').required('name is required'), items: Yup.array().of(itemSchema), activeEnvironmentUid: Yup.string() .length(21, 'activeEnvironmentUid must be 21 characters in length') diff --git a/packages/bruno-schema/src/collections/itemSchema.spec.js b/packages/bruno-schema/src/collections/itemSchema.spec.js index a949f3c2..8c46bed2 100644 --- a/packages/bruno-schema/src/collections/itemSchema.spec.js +++ b/packages/bruno-schema/src/collections/itemSchema.spec.js @@ -34,7 +34,7 @@ describe('Item Schema Validation', () => { return Promise.all([ expect(itemSchema.validate(item)).rejects.toEqual( - validationErrorWithMessages('name must be atleast 1 characters') + validationErrorWithMessages('name must be at least 1 character') ) ]); }); diff --git a/readme.md b/readme.md index 6f902b14..29cb5ad9 100644 --- a/readme.md +++ b/readme.md @@ -1,47 +1,65 @@