diff --git a/packages/bruno-lang/package.json b/packages/bruno-lang/package.json index c243c7980..632aed4c5 100644 --- a/packages/bruno-lang/package.json +++ b/packages/bruno-lang/package.json @@ -10,6 +10,7 @@ "test": "jest" }, "dependencies": { - "arcsecond": "^5.0.0" + "arcsecond": "^5.0.0", + "ohm-js": "^16.6.0" } } diff --git a/packages/bruno-lang/v2/src/index.js b/packages/bruno-lang/v2/src/index.js new file mode 100644 index 000000000..b21459cc3 --- /dev/null +++ b/packages/bruno-lang/v2/src/index.js @@ -0,0 +1,369 @@ +const ohm = require("ohm-js"); +const _ = require('lodash'); + +/** + * A Bru file is made up of blocks. + * There are two types of blocks + * + * 1. Dictionary Blocks - These are blocks that have key value pairs + * ex: + * headers { + * content-type: application/json + * } + * + * 2. Text Blocks - These are blocks that have text + * ex: + * body:json { + * { + * "username": "John Nash", + * "password": "governingdynamics + * } + * + */ +const grammar = ohm.grammar(`Bru { + BruFile = (meta | http | querydisabled | query | headersdisabled | headers | bodies | varsandassert | script | test | docs)* + bodies = bodyjson | bodytext | bodyxml | bodygraphql | bodygraphqlvars | bodyforms + bodyforms = bodyformurlencodeddisabled | bodyformurlencoded | bodymultipartdisabled | bodymultipart + + nl = "\\r"? "\\n" + st = " " | "\\t" + tagend = nl "}" + validkey = ~(st | ":") any + validvalue = ~nl any + + // Dictionary Blocks + dictionary = st* "{" pairlist? tagend + pairlist = nl* pair (~tagend nl pair)* (~tagend space)* + pair = st* key st* ":" st* value? st* + key = ~tagend validkey* + value = ~tagend validvalue* + + // Text Blocks + textblock = textline (~tagend nl textline)* + textline = textchar* + textchar = ~nl any + + meta = "meta" dictionary + + http = get | post | put | delete | options | head | connect | trace + get = "get" dictionary + post = "post" dictionary + put = "put" dictionary + delete = "delete" dictionary + options = "options" dictionary + head = "head" dictionary + connect = "connect" dictionary + trace = "trace" dictionary + + headers = "headers" dictionary + headersdisabled = "headers:disabled" dictionary + + query = "query" dictionary + querydisabled = "query:disabled" dictionary + + varsandassert = vars | varsdisabled | varslocal | varslocaldisabled | assert | assertdisabled + vars = "vars" dictionary + varsdisabled = "vars:disabled" dictionary + varslocal = "vars:local" dictionary + varslocaldisabled = "vars:local:disabled" dictionary + assert = "assert" dictionary + assertdisabled = "assert:disabled" dictionary + + bodyjson = "body:json" st* "{" nl* textblock tagend + bodytext = "body:text" st* "{" nl* textblock tagend + bodyxml = "body:xml" st* "{" nl* textblock tagend + bodygraphql = "body:graphql" st* "{" nl* textblock tagend + bodygraphqlvars = "body:graphql:vars" st* "{" nl* textblock tagend + + bodyformurlencoded = "body:form-urlencoded" dictionary + bodyformurlencodeddisabled = "body:form-urlencoded:disabled" dictionary + bodymultipart = "body:multipart-form" dictionary + bodymultipartdisabled = "body:multipart-form:disabled" dictionary + + script = "script" st* "{" nl* textblock tagend + test = "test" st* "{" nl* textblock tagend + docs = "docs" st* "{" nl* textblock tagend +}`); + +const mapPairListToKeyValPairs = (pairList = [], enabled = true) => { + if(!pairList.length) { + return []; + } + return _.map(pairList[0], pair => { + const key = _.keys(pair)[0]; + return { + name: key, + value: pair[key], + enabled: 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] = _.get(value, 'ast[0]', ''); + return res; + }, + key(chars) { + return chars.sourceString; + }, + value(chars) { + return chars.sourceString ? chars.sourceString.trim() : ''; + }, + meta(_1, dictionary) { + return { + meta: mapPairListToKeyValPair(dictionary.ast) + }; + }, + get(_1, dictionary) { + return { + http: { + method: 'GET', + ...mapPairListToKeyValPair(dictionary.ast) + } + }; + }, + post(_1, dictionary) { + return { + http: { + method: 'POST', + ...mapPairListToKeyValPair(dictionary.ast) + } + }; + }, + put(_1, dictionary) { + return { + http: { + method: 'PUT', + ...mapPairListToKeyValPair(dictionary.ast) + } + }; + }, + delete(_1, dictionary) { + return { + http: { + method: 'DELETE', + ...mapPairListToKeyValPair(dictionary.ast) + } + }; + }, + options(_1, dictionary) { + return { + http: { + method: 'OPTIONS', + ...mapPairListToKeyValPair(dictionary.ast) + } + }; + }, + head(_1, dictionary) { + return { + http: { + method: 'HEAD', + ...mapPairListToKeyValPair(dictionary.ast) + } + }; + }, + connect(_1, dictionary) { + return { + http: { + method: 'CONNECT', + ...mapPairListToKeyValPair(dictionary.ast) + } + }; + }, + query(_1, dictionary) { + return { + query: mapPairListToKeyValPairs(dictionary.ast) + }; + }, + querydisabled(_1, dictionary) { + return { + query: mapPairListToKeyValPairs(dictionary.ast, false) + }; + }, + headers(_1, dictionary) { + return { + headers: mapPairListToKeyValPairs(dictionary.ast) + }; + }, + headersdisabled(_1, dictionary) { + return { + headers: mapPairListToKeyValPairs(dictionary.ast, false) + }; + }, + bodyformurlencoded(_1, dictionary) { + return { + body: { + formUrlEncoded: mapPairListToKeyValPairs(dictionary.ast) + } + }; + }, + bodyformurlencodeddisabled(_1, dictionary) { + return { + body: { + formUrlEncoded: mapPairListToKeyValPairs(dictionary.ast, false) + } + }; + }, + bodymultipart(_1, dictionary) { + return { + body: { + multipartForm: mapPairListToKeyValPairs(dictionary.ast) + } + }; + }, + bodymultipartdisabled(_1, dictionary) { + return { + body: { + multipartForm: mapPairListToKeyValPairs(dictionary.ast, false) + } + }; + }, + bodyjson(_1, _2, _3, _4, textblock, _5) { + return { + body: { + json: textblock.sourceString + } + }; + }, + bodytext(_1, _2, _3, _4, textblock, _5) { + return { + body: { + text: textblock.sourceString + } + }; + }, + bodyxml(_1, _2, _3, _4, textblock, _5) { + return { + body: { + xml: textblock.sourceString + } + }; + }, + bodygraphql(_1, _2, _3, _4, textblock, _5) { + return { + body: { + graphql: { + query: textblock.sourceString + } + } + }; + }, + bodygraphqlvars(_1, _2, _3, _4, textblock, _5) { + return { + body: { + graphql: { + variables: textblock.sourceString + } + } + }; + }, + vars(_1, dictionary) { + return { + vars: mapPairListToKeyValPairs(dictionary.ast) + }; + }, + varsdisabled(_1, dictionary) { + return { + vars: mapPairListToKeyValPairs(dictionary.ast, false) + }; + }, + varslocal(_1, dictionary) { + return { + varsLocal: mapPairListToKeyValPairs(dictionary.ast) + }; + }, + varslocaldisabled(_1, dictionary) { + return { + varsLocal: mapPairListToKeyValPairs(dictionary.ast, false) + }; + }, + assert(_1, dictionary) { + return { + assert: mapPairListToKeyValPairs(dictionary.ast) + }; + }, + assertdisabled(_1, dictionary) { + return { + assert: mapPairListToKeyValPairs(dictionary.ast, false) + }; + }, + script(_1, _2, _3, _4, textblock, _5) { + return { + script: textblock.sourceString + }; + }, + test(_1, _2, _3, _4, textblock, _5) { + return { + test: textblock.sourceString + };; + }, + docs(_1, _2, _3, _4, textblock, _5) { + return { + docs: textblock.sourceString + }; + }, + 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); + } +}); + +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/tests/dictionary.spec.js b/packages/bruno-lang/v2/tests/dictionary.spec.js new file mode 100644 index 000000000..46d5b31f7 --- /dev/null +++ b/packages/bruno-lang/v2/tests/dictionary.spec.js @@ -0,0 +1,138 @@ +/** + * This test file is used to test the text parser. + */ + +const parser = require("../src/index"); + +const assertSingleHeader = (input) => { + const output = parser(input); + + const expected = { + "headers": [{ + "name": "hello", + "value": "world", + "enabled": true + }] + }; + expect(output).toEqual(expected); +}; + +describe("headers parser", () => { + it("should parse empty header", () => { + const input = ` +headers { +}`; + + const output = parser(input); + const expected = { + "headers": [] + }; + expect(output).toEqual(expected); + }); + + it("should parse single header", () => { + const input = ` +headers { + hello: world +}`; + + assertSingleHeader(input); + }); + + it("should parse single header with spaces", () => { + const input = ` +headers { + hello: world +}`; + + assertSingleHeader(input); + }); + + it("should parse single header with spaces and newlines", () => { + const input = ` +headers { + + hello: world + + +}`; + + assertSingleHeader(input); + }); + + it("should parse single header with empty value", () => { + const input = ` +headers { + hello: +}`; + + const output = parser(input); + const expected = { + "headers": [{ + "name": "hello", + "value": "", + "enabled": true + }] + }; + expect(output).toEqual(expected); + }); + + it("should parse multi headers", () => { + const input = ` +headers { + content-type: application/json + Authorization: JWT secret +}`; + + const output = parser(input); + const expected = { + "headers": [{ + "name": "content-type", + "value": "application/json", + "enabled": true + }, { + "name": "Authorization", + "value": "JWT secret", + "enabled": true + }] + }; + expect(output).toEqual(expected); + }); + + it("should parse disabled headers", () => { + const input = ` +headers:disabled { + content-type: application/json +}`; + + const output = parser(input); + const expected = { + "headers": [{ + "name": "content-type", + "value": "application/json", + "enabled": false + }] + }; + expect(output).toEqual(expected); + }); + + it("should throw error on invalid header", () => { + const input = ` +headers { + hello: world + foo +}`; + + expect(() => parser(input)).toThrow(); + }); + + it("should throw error on invalid header", () => { + const input = ` +headers { + hello: world + foo: bar}`; + + expect(() => parser(input)).toThrow(); + }); +}); + diff --git a/packages/bruno-lang/v2/tests/fixtures/request.bru b/packages/bruno-lang/v2/tests/fixtures/request.bru new file mode 100644 index 000000000..9db94d4dd --- /dev/null +++ b/packages/bruno-lang/v2/tests/fixtures/request.bru @@ -0,0 +1,114 @@ +meta { + name: Send Bulk SMS + type: http + seq: 1 +} + +get { + url: https://api.textlocal.in/send/ + body: json +} + +query { + apiKey: secret + numbers: 998877665 +} + +query:disabled { + message: hello +} + +headers { + content-type: application/json + Authorization: Bearer 123 +} + +headers:disabled { + transaction-id: {{transactionId}} +} + +body:form-urlencoded { + apikey: secret + numbers: +91998877665 +} + +body:form-urlencoded:disabled { + message: hello +} + +body:multipart-form { + apikey: secret + numbers: +91998877665 +} + +body:multipart-form:disabled { + message: hello +} + +body:json { + { + "hello": "world" + } +} + +body:text { + This is a text body +} + +body:xml { + + John + 30 + +} + +body:graphql { + { + launchesPast { + launch_site { + site_name + } + launch_success + } + } +} + +body:graphql:vars { + { + "limit": 5 + } +} + +vars { + token: $res.body.token +} + +vars:disabled { + petId: $res.body.id +} + +vars:local { + orderNumber: $res.body.orderNumber +} + +vars:local:disabled { + transactionId: $res.body.transactionId +} + +assert { + $res.status: 200 +} + +assert:disabled { + $res.body.message: success +} + +test { + function onResponse(request, response) { + expect(response.status).to.equal(200); + } +} + +docs { + This request needs auth token to be set in the headers. +} \ No newline at end of file diff --git a/packages/bruno-lang/v2/tests/fixtures/request.json b/packages/bruno-lang/v2/tests/fixtures/request.json new file mode 100644 index 000000000..167af5f60 --- /dev/null +++ b/packages/bruno-lang/v2/tests/fixtures/request.json @@ -0,0 +1,123 @@ +{ + "meta": { + "name": "Send Bulk SMS", + "type": "http", + "seq": "1" + }, + "http": { + "method": "GET", + "url": "https://api.textlocal.in/send/", + "body": "json" + }, + "query": [{ + "name": "apiKey", + "value": "secret", + "enabled": true + }, { + "name": "numbers", + "value": "998877665", + "enabled": true + }, { + "name": "message", + "value": "hello", + "enabled": false + }], + "headers": [ + { + "name": "content-type", + "value": "application/json", + "enabled": true + }, + { + "name": "Authorization", + "value": "Bearer 123", + "enabled": true + }, + { + "name": "transaction-id", + "value": "{{transactionId}}", + "enabled": false + } + ], + "body": { + "json": " {\n \"hello\": \"world\"\n }", + "text": " This is a text body", + "xml": " \n John\n 30\n ", + "graphql": { + "query": " {\n launchesPast {\n launch_site {\n site_name\n }\n launch_success\n }\n }", + "variables": " {\n \"limit\": 5\n }" + }, + "formUrlEncoded": [ + { + "name": "apikey", + "value": "secret", + "enabled": true + }, + { + "name": "numbers", + "value": "+91998877665", + "enabled": true + }, + { + "name": "message", + "value": "hello", + "enabled": false + } + ], + "multipartForm": [ + { + "name": "apikey", + "value": "secret", + "enabled": true + }, + { + "name": "numbers", + "value": "+91998877665", + "enabled": true + }, + { + "name": "message", + "value": "hello", + "enabled": false + } + ] + }, + "vars": [ + { + "name": "token", + "value": "$res.body.token", + "enabled": true + }, + { + "name": "petId", + "value": "$res.body.id", + "enabled": false + } + ], + "varsLocal": [ + { + "name": "orderNumber", + "value": "$res.body.orderNumber", + "enabled": true + }, + { + "name": "transactionId", + "value": "$res.body.transactionId", + "enabled": false + } + ], + "assert": [ + { + "name": "$res.status", + "value": "200", + "enabled": true + }, + { + "name": "$res.body.message", + "value": "success", + "enabled": false + } + ], + "test": " function onResponse(request, response) {\n expect(response.status).to.equal(200);\n }", + "docs": " This request needs auth token to be set in the headers." +} \ No newline at end of file diff --git a/packages/bruno-lang/v2/tests/index.spec.js b/packages/bruno-lang/v2/tests/index.spec.js new file mode 100644 index 000000000..c33cd7630 --- /dev/null +++ b/packages/bruno-lang/v2/tests/index.spec.js @@ -0,0 +1,14 @@ +const fs = require("fs"); +const path = require("path"); +const parser = require("../src/index"); + +describe("parser", () => { + it("should parse the bru file", () => { + const input = fs.readFileSync(path.join(__dirname, 'fixtures', 'request.bru'), 'utf8'); + const expected = require("./fixtures/request.json"); + const output = parser(input); + + // console.log(JSON.stringify(output, null, 2)); + expect(output).toEqual(expected); + }); +}); diff --git a/packages/bruno-lang/v2/tests/text.spec.js b/packages/bruno-lang/v2/tests/text.spec.js new file mode 100644 index 000000000..caefa0874 --- /dev/null +++ b/packages/bruno-lang/v2/tests/text.spec.js @@ -0,0 +1,20 @@ +/** + * This test file is used to test the text parser. + */ +const parser = require("../src/index"); + +describe("script parser", () => { + it("should parse script body", () => { + const input = ` +script { + function onResponse(request, response) { + expect(response.status).to.equal(200); + } +} +`; + + const output = parser(input); + const expected = " function onResponse(request, response) {\n expect(response.status).to.equal(200);\n }"; + expect(output.script).toEqual(expected); + }); +});