Merge pull request #85 from usebruno/feature/bru-lang-parser

Bru Lang Parser
This commit is contained in:
Anoop M D 2023-02-04 20:02:34 +05:30 committed by GitHub
commit 86200a8f11
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 780 additions and 1 deletions

View File

@ -10,6 +10,7 @@
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
"arcsecond": "^5.0.0" "arcsecond": "^5.0.0",
"ohm-js": "^16.6.0"
} }
} }

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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 {
<xml>
<name>John</name>
<age>30</age>
</xml>
}
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.
}

View File

@ -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": " <xml>\n <name>John</name>\n <age>30</age>\n </xml>",
"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."
}

View File

@ -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);
});
});

View File

@ -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);
});
});