feat: Tagging requests and filtering collection runs using tags

This commit is contained in:
Antti Sonkeri
2024-07-20 11:41:56 +03:00
committed by lohit-bruno
parent ecc6c1604c
commit 3803576aa4
27 changed files with 434 additions and 20 deletions

View File

@@ -19,6 +19,7 @@ import StyledWrapper from './StyledWrapper';
import Documentation from 'components/Documentation/index';
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import Tags from 'components/RequestPane/Tags/index';
const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
const dispatch = useDispatch();
@@ -101,6 +102,9 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
case 'docs': {
return <Documentation item={item} collection={collection} />;
}
case 'tags': {
return <Tags item={item} collection={collection} />;
}
default: {
return <div className="mt-4">404 | Not found</div>;
}
@@ -152,6 +156,9 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
Docs
</div>
<div className={getTabClassname('tags')} role="tab" onClick={() => selectTab('tags')}>
Tags
</div>
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
</div>
<section className="flex w-full mt-5 flex-1 relative">

View File

@@ -18,6 +18,7 @@ import HeightBoundContainer from 'ui/HeightBoundContainer';
import { useEffect } from 'react';
import StatusDot from 'components/StatusDot';
import Settings from 'components/RequestPane/Settings';
import Tags from 'components/RequestPane/Tags/index';
const HttpRequestPane = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -65,6 +66,9 @@ const HttpRequestPane = ({ item, collection }) => {
case 'settings': {
return <Settings item={item} collection={collection} />;
}
case 'tags': {
return <Tags item={item} collection={collection} />;
}
default: {
return <div className="mt-4">404 | Not found</div>;
}
@@ -165,6 +169,9 @@ const HttpRequestPane = ({ item, collection }) => {
<div className={getTabClassname('settings')} role="tab" onClick={() => selectTab('settings')}>
Settings
</div>
<div className={getTabClassname('tags')} role="tab" onClick={() => selectTab('tags')}>
Tags
</div>
{focusedTab.requestPaneTab === 'body' ? (
<div className="flex flex-grow justify-end items-center">
<RequestBodyMode item={item} collection={collection} />

View File

@@ -0,0 +1,25 @@
import styled from 'styled-components';
const Wrapper = styled.div`
input[type='text'] {
border: solid 1px transparent;
outline: none !important;
background-color: inherit;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
li {
display: flex;
align-items: center;
border: 1px solid ${(props) => props.theme.text};
border-radius: 5px;
padding-inline: 5px;
background: ${(props) => props.theme.sidebar.bg};
}
`;
export default Wrapper;

View File

@@ -0,0 +1,69 @@
import { IconX } from '@tabler/icons';
import { useState } from 'react';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
const TagList = ({ tags, onTagRemove, onTagAdd }) => {
const tagNameRegex = /^[\w-]+$/;
const [isEditing, setIsEditing] = useState(false);
const [text, setText] = useState('');
const handleChange = (e) => {
setText(e.target.value);
};
const handleKeyDown = (e) => {
if (e.code == 'Escape') {
setText('');
setIsEditing(false);
return;
}
if (e.code !== 'Enter' && e.code !== 'Space') {
return;
}
if (!tagNameRegex.test(text)) {
toast.error('Tags must only contain alpha-numeric characters, "-", "_"');
return;
}
if (tags.includes(text)) {
toast.error(`Tag "${text}" already exists`);
return;
}
onTagAdd(text);
setText('');
setIsEditing(false);
};
return (
<StyledWrapper className="flex flex-wrap gap-2 mt-1">
<ul className="flex flex-wrap gap-1">
{tags && tags.length
? tags.map((_tag) => (
<li key={_tag}>
<span>{_tag}</span>
<button tabIndex={-1} onClick={() => onTagRemove(_tag)}>
<IconX strokeWidth={1.5} size={20} />
</button>
</li>
))
: null}
</ul>
{isEditing ? (
<input
type="text"
placeholder="Space or Enter to add tag"
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
autoFocus
/>
) : (
<button className="text-link select-none" onClick={() => setIsEditing(true)}>
+ Add
</button>
)}
</StyledWrapper>
);
};
export default TagList;

View File

@@ -0,0 +1,43 @@
import 'github-markdown-css/github-markdown.css';
import get from 'lodash/get';
import { addRequestTag, deleteRequestTag } from 'providers/ReduxStore/slices/collections';
import { useDispatch } from 'react-redux';
import TagList from './TagList/TagList';
const Tags = ({ item, collection }) => {
const tags = item.draft ? get(item, 'draft.request.tags') : get(item, 'request.tags');
const dispatch = useDispatch();
const handleAdd = (_tag) => {
dispatch(
addRequestTag({
tag: _tag,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handleRemove = (_tag) => {
dispatch(
deleteRequestTag({
tag: _tag,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
if (!item) {
return null;
}
return (
<div>
<TagList tags={tags} onTagRemove={handleRemove} onTagAdd={handleAdd} />
</div>
);
};
export default Tags;

View File

@@ -9,6 +9,7 @@ import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, Ic
import ResponsePane from './ResponsePane';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
import TagList from 'components/RequestPane/Tags/TagList/TagList';
const getDisplayName = (fullPath, pathname, name = '') => {
let relativePath = path.relative(fullPath, pathname);
@@ -42,6 +43,8 @@ export default function RunnerResults({ collection }) {
const dispatch = useDispatch();
const [selectedItem, setSelectedItem] = useState(null);
const [delay, setDelay] = useState(null);
const [tags, setTags] = useState({ include: [], exclude: [] });
const [tagsEnabled, setTagsEnabled] = useState(false);
// ref for the runner output body
const runnerBodyRef = useRef();
@@ -88,11 +91,19 @@ export default function RunnerResults({ collection }) {
.filter(Boolean);
const runCollection = () => {
dispatch(runCollectionFolder(collection.uid, null, true, Number(delay)));
dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags));
};
const runAgain = () => {
dispatch(runCollectionFolder(collection.uid, runnerInfo.folderUid, runnerInfo.isRecursive, Number(delay)));
dispatch(
runCollectionFolder(
collection.uid,
runnerInfo.folderUid,
runnerInfo.isRecursive,
Number(delay),
tagsEnabled && tags
)
);
};
const resetRunner = () => {
@@ -140,6 +151,37 @@ export default function RunnerResults({ collection }) {
onChange={(e) => setDelay(e.target.value)}
/>
</div>
<div className="mt-6 flex flex-col">
<div className="flex gap-2">
<label className="block font-medium">Filter requests with tags</label>
<input
className="cursor-pointer"
type="checkbox"
checked={tagsEnabled}
onChange={() => setTagsEnabled(!tagsEnabled)}
/>
</div>
{tagsEnabled && (
<div className="flex p-4 gap-4 max-w-xl justify-between">
<div className="w-1/2">
<span>Included tags:</span>
<TagList
tags={tags.include}
onTagAdd={(tag) => setTags({ ...tags, include: [...tags.include, tag] })}
onTagRemove={(tag) => setTags({ ...tags, include: tags.include.filter((t) => t !== tag) })}
/>
</div>
<div className="w-1/2">
<span>Excluded tags:</span>
<TagList
tags={tags.exclude}
onTagAdd={(tag) => setTags({ ...tags, exclude: [...tags.exclude, tag] })}
onTagRemove={(tag) => setTags({ ...tags, exclude: tags.exclude.filter((t) => t !== tag) })}
/>
</div>
</div>
)}
</div>
<button type="submit" className="submit btn btn-sm btn-secondary mt-6" onClick={runCollection}>
Run Collection

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import get from 'lodash/get';
import { uuid } from 'utils/common';
import Modal from 'components/Modal';
@@ -8,12 +8,15 @@ import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/act
import { flattenItems } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
import TagList from 'components/RequestPane/Tags/TagList/TagList';
const RunCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const isCollectionRunInProgress = collection?.runnerResult?.info?.status && (collection?.runnerResult?.info?.status !== 'ended');
const [tags, setTags] = useState({ include: [], exclude: [] });
const [tagsEnabled, setTagsEnabled] = useState(false);
const onSubmit = (recursive) => {
dispatch(
@@ -24,7 +27,7 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => {
})
);
if (!isCollectionRunInProgress) {
dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive));
dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive, 0, tagsEnabled && tags));
}
onClose();
};
@@ -71,6 +74,39 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => {
<div className={isFolderLoading ? "mb-2" : "mb-8"}>This will run all the requests in this folder and all its subfolders.</div>
{isFolderLoading ? <div className='mb-8 warning'>Requests in this folder are still loading.</div> : null}
{isCollectionRunInProgress ? <div className='mb-6 warning'>A Collection Run is already in progress.</div> : null}
<div className="mb-8 flex flex-col">
<div className="flex gap-2">
<label className="block font-medium">Filter requests with tags</label>
<input
className="cursor-pointer"
type="checkbox"
checked={tagsEnabled}
onChange={() => setTagsEnabled(!tagsEnabled)}
/>
</div>
{tagsEnabled && (
<div className="flex p-4 gap-4 max-w-xl justify-between">
<div className="w-1/2">
<span>Included tags:</span>
<TagList
tags={tags.include}
onTagAdd={(tag) => setTags({ ...tags, include: [...tags.include, tag] })}
onTagRemove={(tag) => setTags({ ...tags, include: tags.include.filter((t) => t !== tag) })}
/>
</div>
<div className="w-1/2">
<span>Excluded tags:</span>
<TagList
tags={tags.exclude}
onTagAdd={(tag) => setTags({ ...tags, exclude: [...tags.exclude, tag] })}
onTagRemove={(tag) => setTags({ ...tags, exclude: tags.exclude.filter((t) => t !== tag) })}
/>
</div>
</div>
)}
</div>
<div className="flex justify-end bruno-modal-footer">
<span className="mr-3">
<button type="button" onClick={onClose} className="btn btn-md btn-close">

View File

@@ -315,7 +315,7 @@ export const cancelRunnerExecution = (cancelTokenUid) => (dispatch) => {
cancelNetworkRequest(cancelTokenUid).catch((err) => console.log(err));
};
export const runCollectionFolder = (collectionUid, folderUid, recursive, delay) => (dispatch, getState) => {
export const runCollectionFolder = (collectionUid, folderUid, recursive, delay, tags) => (dispatch, getState) => {
const state = getState();
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -354,7 +354,8 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
environment,
collectionCopy.runtimeVariables,
recursive,
delay
delay,
tags
)
.then(resolve)
.catch((err) => {

View File

@@ -2339,9 +2339,43 @@ export const collectionsSlice = createSlice({
set(folder, 'root.request.auth', {});
set(folder, 'root.request.auth.mode', action.payload.mode);
}
}
},
addRequestTag: (state, action) => {
const { tag, collectionUid, itemUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.tags = item.draft.request.tags || [];
if (!item.draft.request.tags.includes(tag.trim())) {
item.draft.request.tags.push(tag.trim());
}
}
}
},
deleteRequestTag: (state, action) => {
const { tag, collectionUid, itemUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.tags = item.draft.request.tags || [];
item.draft.request.tags = item.draft.request.tags.filter((t) => t !== tag.trim());
}
}
}
}
});
export const {
@@ -2457,6 +2491,8 @@ export const {
collectionGetOauth2CredentialsByUrl,
updateFolderAuth,
updateFolderAuthMode,
addRequestTag,
deleteRequestTag
} = collectionsSlice.actions;
export default collectionsSlice.reducer;

View File

@@ -257,7 +257,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
vars: si.request.vars,
assertions: si.request.assertions,
tests: si.request.tests,
docs: si.request.docs
docs: si.request.docs,
tags: si.request.tags
};
// Handle auth object dynamically
@@ -565,7 +566,8 @@ export const transformRequestToSaveToFilesystem = (item) => {
vars: _item.request.vars,
assertions: _item.request.assertions,
tests: _item.request.tests,
docs: _item.request.docs
docs: _item.request.docs,
tags: _item.request.tags
}
};

View File

@@ -6,6 +6,7 @@ const { getRunnerSummary } = require('@usebruno/common/runner');
const { exists, isFile, isDirectory } = require('../utils/filesystem');
const { runSingleRequest } = require('../runner/run-single-request');
const { bruToEnvJson, getEnvVars } = require('../utils/bru');
const { isRequestTagsIncluded } = require("@usebruno/common")
const makeJUnitOutput = require('../reporters/junit');
const makeHtmlOutput = require('../reporters/html');
const { rpad } = require('../utils/common');
@@ -199,6 +200,14 @@ const builder = async (yargs) => {
type:"number",
description: "Delay between each requests (in miliseconds)"
})
.option('tags', {
type: 'string',
description: 'Tags to include in the run'
})
.option('exclude-tags', {
type: 'string',
description: 'Tags to exclude from the run'
})
.example('$0 run request.bru', 'Run a request')
.example('$0 run request.bru --env local', 'Run a request with the environment set to local')
.example('$0 run request.bru --env-file env.bru', 'Run a request with the environment from env.bru file')
@@ -241,7 +250,11 @@ const builder = async (yargs) => {
)
.example('$0 run --client-cert-config client-cert-config.json', 'Run a request with Client certificate configurations')
.example('$0 run folder --delay delayInMs', 'Run a folder with given miliseconds delay between each requests.')
.example('$0 run --noproxy', 'Run requests with system proxy disabled');
.example('$0 run --noproxy', 'Run requests with system proxy disabled')
.example(
'$0 run folder --tags=hello,world --exclude-tags=skip',
'Run only requests with tags "hello" or "world" and exclude any request with tag "skip".'
);
};
const handler = async function (argv) {
@@ -268,7 +281,9 @@ const handler = async function (argv) {
reporterSkipHeaders,
clientCertConfig,
noproxy,
delay
delay,
tags: includeTags,
excludeTags
} = argv;
const collectionPath = process.cwd();
@@ -389,6 +404,9 @@ const handler = async function (argv) {
}
options['ignoreTruststore'] = ignoreTruststore;
includeTags = includeTags ? includeTags.split(',') : [];
excludeTags = excludeTags ? excludeTags.split(',') : [];
if (['json', 'junit', 'html'].indexOf(format) === -1) {
console.error(chalk.red(`Format must be one of "json", "junit or "html"`));
process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_OUTPUT_FORMAT);
@@ -444,6 +462,10 @@ const handler = async function (argv) {
console.error(chalk.red(`Path not found: ${resolvedPath}`));
process.exit(constants.EXIT_STATUS.ERROR_FILE_NOT_FOUND);
}
requestItems = requestItems.filter((item) => {
return isRequestTagsIncluded(item.tags, includeTags, excludeTags);
});
}
requestItems = getCallStack(resolvedPaths, collection, { recursive });

View File

@@ -64,6 +64,7 @@ const bruToJson = (bru) => {
name: _.get(json, 'meta.name'),
seq: !_.isNaN(sequence) ? Number(sequence) : 1,
settings: _.get(json, 'settings', {}),
tags: _.get(json, 'tags', []),
request: {
method: _.upperCase(_.get(json, 'http.method')),
url: _.get(json, 'http.url'),

View File

@@ -1,2 +1,3 @@
export { mockDataFunctions } from './utils/faker-functions';
export {default as interpolate} from './interpolate';
export {default as isRequestTagsIncluded} from './tags';

View File

@@ -0,0 +1,43 @@
import isRequestTagsIncluded from './index';
describe('isRequestTagsIncluded', () => {
it('should include request when it has an included tag', () => {
const requestTags = ['tag1', 'tag2'];
const includeTags = ['tag1'];
const excludeTags: string[] = [];
const result = isRequestTagsIncluded(requestTags, includeTags, excludeTags);
expect(result).toBe(true);
});
it('should include request when included tags is empty', () => {
const requestTags = ['tag1', 'tag2'];
const includeTags: string[] = [];
const excludeTags: string[] = [];
const result = isRequestTagsIncluded(requestTags, includeTags, excludeTags);
expect(result).toBe(true);
});
it('should exclude request when it does not have an included tag', () => {
const requestTags = ['tag1'];
const includeTags = ['tag2'];
const excludeTags: string[] = [];
const result = isRequestTagsIncluded(requestTags, includeTags, excludeTags);
expect(result).toBe(false);
});
it('should exclude request when it has an excluded tag', () => {
const requestTags = ['tag1'];
const includeTags: string[] = [];
const excludeTags = ['tag1'];
const result = isRequestTagsIncluded(requestTags, includeTags, excludeTags);
expect(result).toBe(false);
});
it('should exclude request when it has both included and excluded tag', () => {
const requestTags = ['tag1', 'tag2'];
const includeTags: string[] = ['tag2'];
const excludeTags = ['tag1'];
const result = isRequestTagsIncluded(requestTags, includeTags, excludeTags);
expect(result).toBe(false);
});
});

View File

@@ -0,0 +1,13 @@
/**
* A request should be included if it has at least one tag that is included and no tags that are excluded
* @param requestTags Tags of the request
* @param includeTags Tags to include
* @param excludeTags Tags to exclude
*/
export const isRequestTagsIncluded = (requestTags: string[], includeTags: string[], excludeTags: string[]) => {
const shouldInclude = includeTags.length === 0 || requestTags.some((tag) => includeTags.includes(tag));
const shouldExclude = excludeTags.length > 0 && requestTags.some((tag) => excludeTags.includes(tag));
return shouldInclude && !shouldExclude;
};
export default isRequestTagsIncluded;

View File

@@ -149,7 +149,8 @@ const bruToJson = (data, parsed = false) => {
vars: _.get(json, 'vars', {}),
assertions: _.get(json, 'assertions', []),
tests: _.get(json, 'tests', ''),
docs: _.get(json, 'docs', '')
docs: _.get(json, 'docs', ''),
tags: _.get(json, 'tags', [])
}
};
@@ -215,7 +216,8 @@ const jsonToBru = async (json) => {
assertions: _.get(json, 'request.assertions', []),
tests: _.get(json, 'request.tests', ''),
settings: _.get(json, 'settings', {}),
docs: _.get(json, 'request.docs', '')
docs: _.get(json, 'request.docs', ''),
tags: _.get(json, 'request.tags', [])
};
const bru = jsonToBruV2(bruJson);
@@ -257,7 +259,8 @@ const jsonToBruViaWorker = async (json) => {
assertions: _.get(json, 'request.assertions', []),
tests: _.get(json, 'request.tests', ''),
settings: _.get(json, 'settings', {}),
docs: _.get(json, 'request.docs', '')
docs: _.get(json, 'request.docs', ''),
tags: _.get(json, 'request.tags', [])
};
const bru = await bruParserWorker?.jsonToBru(bruJson)

View File

@@ -31,6 +31,7 @@ const { preferencesUtil } = require('../../store/preferences');
const { getProcessEnvVars } = require('../../store/process-env');
const { getBrunoConfig } = require('../../store/bruno-config');
const Oauth2Store = require('../../store/oauth2');
const { isRequestTagsIncluded } = require('@usebruno/common');
const saveCookies = (url, headers) => {
if (preferencesUtil.shouldStoreCookies()) {
@@ -952,7 +953,7 @@ const registerNetworkIpc = (mainWindow) => {
ipcMain.handle(
'renderer:run-collection-folder',
async (event, folder, collection, environment, runtimeVariables, recursive, delay) => {
async (event, folder, collection, environment, runtimeVariables, recursive, delay, tags) => {
const collectionUid = collection.uid;
const collectionPath = collection.pathname;
const folderUid = folder ? folder.uid : null;
@@ -1012,6 +1013,15 @@ const registerNetworkIpc = (mainWindow) => {
folderRequests = sortByNameThenSequence(folderRequests)
}
// Filter requests based on tags
if (tags && tags.include && tags.exclude) {
const includeTags = tags.include ? tags.include : [];
const excludeTags = tags.exclude ? tags.exclude : [];
folderRequests = folderRequests.filter(({ request }) => {
return isRequestTagsIncluded(request.tags, includeTags, excludeTags)
});
}
let currentRequestIndex = 0;
let nJumps = 0; // count the number of jumps to avoid infinite loops
while (currentRequestIndex < folderRequests.length) {

View File

@@ -4,7 +4,7 @@ const { safeParseJson, outdentString } = require('./utils');
/**
* A Bru file is made up of blocks.
* There are two types of blocks
* There are three types of blocks
*
* 1. Dictionary Blocks - These are blocks that have key value pairs
* ex:
@@ -19,6 +19,13 @@ const { safeParseJson, outdentString } = require('./utils');
* "username": "John Nash",
* "password": "governingdynamics
* }
* 3. List Blocks - These are blocks that have a list of items
* ex:
* tags [
* regression
* smoke-test
* ]
*
*/
const grammar = ohm.grammar(`Bru {
@@ -59,6 +66,13 @@ const grammar = ohm.grammar(`Bru {
textline = textchar*
textchar = ~nl any
// List
listend = nl "]"
list = st* "[" listitems? listend
listitems = (~listend nl)* listitem (~listend stnl* listitem)* (~listend space)*
listitem = st* textchar+ st*
tags = "tags" list
meta = "meta" dictionary
settings = "settings" dictionary
@@ -298,6 +312,15 @@ const sem = grammar.createSemantics().addAttribute('ast', {
assertkey(chars) {
return chars.sourceString ? chars.sourceString.trim() : '';
},
list(_1, _2, listitems, _3) {
return listitems.ast.flat()
},
listitems(_1, listitem, _2, rest, _3) {
return [listitem.ast, ...rest.ast]
},
listitem(_1, textchar, _2) {
return textchar.sourceString;
},
textblock(line, _1, rest) {
return [line.ast, ...rest.ast].join('\n');
},
@@ -319,6 +342,9 @@ const sem = grammar.createSemantics().addAttribute('ast', {
_iter(...elements) {
return elements.map((e) => e.ast);
},
tags(_1, list) {
return { tags: list.ast };
},
meta(_1, dictionary) {
let meta = mapPairListToKeyValPair(dictionary.ast);

View File

@@ -42,6 +42,14 @@ const jsonToBru = (json) => {
bru += '}\n\n';
}
if (tags) {
bru += 'tags [\n';
for (const tag of tags) {
bru += ` ${tag}\n`;
}
bru += ']\n\n';
}
if (http && http.method) {
bru += `${http.method} {
url: ${http.url}`;

View File

@@ -4,6 +4,11 @@ meta {
seq: 1
}
tags [
foo
bar
]
get {
url: https://api.textlocal.in/send/:id
body: json

View File

@@ -4,6 +4,7 @@
"type": "http",
"seq": "1"
},
"tags": ["foo", "bar"],
"http": {
"method": "get",
"url": "https://api.textlocal.in/send/:id",

View File

@@ -310,7 +310,8 @@ const requestSchema = Yup.object({
.nullable(),
assertions: Yup.array().of(keyValueSchema).nullable(),
tests: Yup.string().nullable(),
docs: Yup.string().nullable()
docs: Yup.string().nullable(),
tags: Yup.array().of(Yup.string().matches(/^[\w-]+$/, 'tag must be alphanumeric'))
})
.noUnknown(true)
.strict();

View File

@@ -9,6 +9,7 @@ describe('Request Schema Validation', () => {
method: 'GET',
headers: [],
params: [],
tags: ['smoke-test'],
body: {
mode: 'none'
}

View File

@@ -47,6 +47,10 @@ const jsonToToml = (json) => {
}
};
if (json.tags && json.tags.length) {
formattedJson.tags = get(json, 'tags', []);
}
if (json.headers && json.headers.length) {
const hasDuplicateHeaders = keyValPairHasDuplicateKeys(json.headers);
const hasReservedHeaders = keyValPairHasReservedKeys(json.headers);

View File

@@ -24,6 +24,10 @@ const tomlToJson = (toml) => {
}
};
if (json.tags && json.tags.length) {
formattedJson.tags = get(json, 'tags', []);
}
if (json.headers) {
formattedJson.headers = [];

View File

@@ -4,6 +4,7 @@
"type": "http",
"seq": 1
},
"tags": ["foo", "bar"],
"http": {
"method": "GET",
"url": "https://reqres.in/api/users"

View File

@@ -1,3 +1,5 @@
tags = [ 'foo', 'bar' ]
[meta]
name = 'Get users'
type = 'http'