mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
Feat: add openapi to bruno import in cli
This commit is contained in:
2
package-lock.json
generated
2
package-lock.json
generated
@@ -25222,6 +25222,7 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.750.0",
|
||||
"@usebruno/common": "0.1.0",
|
||||
"@usebruno/converters": "^0.1.0",
|
||||
"@usebruno/js": "0.12.0",
|
||||
"@usebruno/lang": "0.12.0",
|
||||
"@usebruno/requests": "^0.1.0",
|
||||
@@ -25237,6 +25238,7 @@
|
||||
"http-proxy-agent": "^7.0.0",
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"qs": "^6.11.0",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@usebruno/lang": "0.12.0",
|
||||
"@usebruno/vm2": "^3.9.13",
|
||||
"@usebruno/requests": "^0.1.0",
|
||||
"@usebruno/converters": "^0.1.0",
|
||||
"aws4-axios": "^3.3.0",
|
||||
"axios": "^1.8.3",
|
||||
"axios-ntlm": "^1.4.2",
|
||||
@@ -63,6 +64,7 @@
|
||||
"http-proxy-agent": "^7.0.0",
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"qs": "^6.11.0",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
|
||||
@@ -58,6 +58,44 @@ If you need to limit the trusted CA to a specified set when validating the reque
|
||||
bru run request.bru --cacert myCustomCA.pem --ignore-truststore
|
||||
```
|
||||
|
||||
## Importing Collections
|
||||
|
||||
You can import collections from other formats, such as OpenAPI, using the import command:
|
||||
|
||||
```bash
|
||||
bru import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"
|
||||
```
|
||||
|
||||
You can also use the shorter form with aliases:
|
||||
|
||||
```bash
|
||||
bru import openapi -s api.yml -o ~/Desktop/my-collection -n "My API"
|
||||
```
|
||||
|
||||
This creates a Bruno collection directory that can be opened in Bruno.
|
||||
|
||||
You can also import directly from a URL:
|
||||
|
||||
```bash
|
||||
bru import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"
|
||||
```
|
||||
|
||||
You can also export the collection as a JSON file:
|
||||
|
||||
```bash
|
||||
bru import openapi --source api.yml --output-file ~/Desktop/my-collection.json --collection-name "My API"
|
||||
```
|
||||
|
||||
Import Options:
|
||||
|
||||
| Option | Details |
|
||||
| ------------------------- | -------------------------------------------------- |
|
||||
| --source, -s | Path to the source file or URL (required) |
|
||||
| --output, -o | Path to the output directory |
|
||||
| --output-file, -f | Path to the output JSON file |
|
||||
| --collection-name, -n | Name for the imported collection |
|
||||
| --insecure | Skip SSL certificate validation when fetching from URLs |
|
||||
|
||||
## Command Line Options
|
||||
|
||||
| Option | Details |
|
||||
|
||||
230
packages/bruno-cli/src/commands/import.js
Normal file
230
packages/bruno-cli/src/commands/import.js
Normal file
@@ -0,0 +1,230 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const chalk = require('chalk');
|
||||
const jsyaml = require('js-yaml');
|
||||
const axios = require('axios');
|
||||
const { openApiToBruno } = require('@usebruno/converters');
|
||||
const { exists, isDirectory, sanitizeName } = require('../utils/filesystem');
|
||||
const { createCollectionFromBrunoObject } = require('../utils/collection');
|
||||
|
||||
const command = 'import <type>';
|
||||
const desc = 'Import a collection from other formats';
|
||||
|
||||
const builder = (yargs) => {
|
||||
yargs
|
||||
.positional('type', {
|
||||
describe: 'Type of collection to import',
|
||||
type: 'string',
|
||||
choices: ['openapi']
|
||||
})
|
||||
.option('source', {
|
||||
alias: 's',
|
||||
describe: 'Path to the source file or URL',
|
||||
type: 'string',
|
||||
demandOption: true
|
||||
})
|
||||
.option('output', {
|
||||
alias: 'o',
|
||||
describe: 'Path to the output directory',
|
||||
type: 'string',
|
||||
conflicts: 'output-file'
|
||||
})
|
||||
.option('output-file', {
|
||||
alias: 'f',
|
||||
describe: 'Path to the output JSON file',
|
||||
type: 'string',
|
||||
conflicts: 'output'
|
||||
})
|
||||
.option('collection-name', {
|
||||
alias: 'n',
|
||||
describe: 'Name for the imported collection',
|
||||
type: 'string'
|
||||
})
|
||||
.option('insecure', {
|
||||
type: 'boolean',
|
||||
describe: 'Skip SSL certificate verification when fetching from URLs',
|
||||
default: false
|
||||
})
|
||||
.example('$0 import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"')
|
||||
.example('$0 import openapi -s api.yml -o ~/Desktop/my-collection -n "My API"')
|
||||
.example('$0 import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"')
|
||||
.example('$0 import openapi --source https://self-signed.example.com/api.json --insecure --output ~/Desktop')
|
||||
.example('$0 import openapi --source api.yml --output-file ~/Desktop/my-collection.json --collection-name "My API"')
|
||||
.example('$0 import openapi -s api.yml -f ~/Desktop/my-collection.json -n "My API"');
|
||||
};
|
||||
|
||||
const isUrl = (str) => {
|
||||
try {
|
||||
return Boolean(new URL(str));
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const readOpenApiFile = async (source, options = {}) => {
|
||||
try {
|
||||
let content;
|
||||
|
||||
if (isUrl(source)) {
|
||||
// Handle URL input
|
||||
console.log(chalk.yellow(`Fetching specification from URL: ${source}`));
|
||||
try {
|
||||
const axiosOptions = {
|
||||
timeout: 30000, // 30 second timeout
|
||||
maxContentLength: 10 * 1024 * 1024,
|
||||
validateStatus: status => status >= 200 && status < 300
|
||||
};
|
||||
|
||||
// Skip SSL certificate validation if insecure flag is set
|
||||
if (options.insecure) {
|
||||
console.log(chalk.yellow('Warning: SSL certificate verification is disabled. Use with caution.'));
|
||||
axiosOptions.httpsAgent = new (require('https')).Agent({ rejectUnauthorized: false });
|
||||
}
|
||||
|
||||
const response = await axios.get(source, axiosOptions);
|
||||
content = response.data;
|
||||
} catch (error) {
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
throw new Error('Request timed out. The server took too long to respond.');
|
||||
} else if (error.code === 'CERT_HAS_EXPIRED' || error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' ||
|
||||
error.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
|
||||
throw new Error(`SSL Certificate error: ${error.code}. Try using --insecure if you trust this source.`);
|
||||
} else if (error.response) {
|
||||
throw new Error(`Failed to fetch from URL: ${error.response.status} ${error.response.statusText}`);
|
||||
} else if (error.request) {
|
||||
throw new Error(`No response received from server. Check the URL and your network connection.`);
|
||||
} else {
|
||||
throw new Error(`Error fetching URL: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If response is already an object, return it directly
|
||||
if (typeof content === 'object' && content !== null) {
|
||||
return content;
|
||||
}
|
||||
} else {
|
||||
// Handle file input
|
||||
if (!await exists(source)) {
|
||||
throw new Error(`File does not exist: ${source}`);
|
||||
}
|
||||
content = fs.readFileSync(source, 'utf8');
|
||||
}
|
||||
|
||||
// If content is a string, try to parse as JSON or YAML
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (jsonError) {
|
||||
try {
|
||||
return jsyaml.load(content);
|
||||
} catch (yamlError) {
|
||||
throw new Error('Failed to parse content as JSON or YAML');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
} catch (error) {
|
||||
// Let the specific error handling from above propagate
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handler = async (argv) => {
|
||||
try {
|
||||
const { type, source, output, outputFile, collectionName, insecure } = argv;
|
||||
|
||||
if (!type || type !== 'openapi') {
|
||||
console.error(chalk.red('Only OpenAPI import is supported currently'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!source) {
|
||||
console.error(chalk.red('Source file or URL is required'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!output && !outputFile) {
|
||||
console.error(chalk.red('Either --output or --output-file is required'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.yellow(`Reading OpenAPI specification from ${source}...`));
|
||||
|
||||
const openApiSpec = await readOpenApiFile(source, { insecure });
|
||||
|
||||
if (!openApiSpec) {
|
||||
console.error(chalk.red('Failed to parse OpenAPI specification'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.yellow('Converting OpenAPI specification to Bruno format...'));
|
||||
|
||||
// Convert OpenAPI to Bruno format
|
||||
let brunoCollection = openApiToBruno(openApiSpec);
|
||||
|
||||
// Override collection name if provided
|
||||
if (collectionName) {
|
||||
brunoCollection.name = collectionName;
|
||||
}
|
||||
|
||||
if (outputFile) {
|
||||
// Save as JSON file
|
||||
const outputPath = path.resolve(outputFile);
|
||||
fs.writeFileSync(outputPath, JSON.stringify(brunoCollection, null, 2));
|
||||
console.log(chalk.green(`Bruno collection saved as JSON to ${outputPath}`));
|
||||
} else if (output) {
|
||||
const resolvedOutput = path.resolve(output);
|
||||
|
||||
// Check if output is an existing directory
|
||||
const isOutputDirectory = await exists(resolvedOutput) && isDirectory(resolvedOutput);
|
||||
|
||||
// Determine the final output directory
|
||||
let outputDir;
|
||||
if (isOutputDirectory) {
|
||||
// If output is an existing directory, use collection name to create a subdirectory
|
||||
const dirName = sanitizeName(brunoCollection.name);
|
||||
outputDir = path.join(resolvedOutput, dirName);
|
||||
|
||||
// Check if this subfolder already exists
|
||||
if (await exists(outputDir)) {
|
||||
const dirContents = fs.readdirSync(outputDir);
|
||||
if (dirContents.length > 0) {
|
||||
console.error(chalk.red(`Output directory is not empty: ${outputDir}`));
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Create the subfolder
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
} else {
|
||||
// If output doesn't exist or is not a directory, use it directly
|
||||
outputDir = resolvedOutput;
|
||||
|
||||
// Check if parent directory exists
|
||||
const parentDir = path.dirname(outputDir);
|
||||
if (!await exists(parentDir)) {
|
||||
console.error(chalk.red(`Parent directory does not exist: ${parentDir}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
await createCollectionFromBrunoObject(brunoCollection, outputDir);
|
||||
console.log(chalk.green(`Bruno collection created at ${outputDir}`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error: ${error.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
command,
|
||||
desc,
|
||||
builder,
|
||||
handler,
|
||||
isUrl,
|
||||
readOpenApiFile
|
||||
};
|
||||
@@ -1,5 +1,9 @@
|
||||
const { get, each, find, compact } = require('lodash');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { jsonToBruV2, envJsonToBruV2, jsonToCollectionBru } = require('@usebruno/lang');
|
||||
const { sanitizeName } = require('./filesystem');
|
||||
|
||||
const mergeHeaders = (collection, request, requestTreePath) => {
|
||||
let headers = new Map();
|
||||
@@ -200,10 +204,141 @@ const getTreePathFromCollectionToItem = (collection, _item) => {
|
||||
return path;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safe write file implementation to handle errors
|
||||
* @param {string} filePath - Path to write file
|
||||
* @param {string} content - Content to write
|
||||
*/
|
||||
const safeWriteFileSync = (filePath, content) => {
|
||||
try {
|
||||
fs.writeFileSync(filePath, content, { encoding: 'utf8' });
|
||||
} catch (error) {
|
||||
console.error(`Error writing file ${filePath}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a Bruno collection directory structure from a Bruno collection object
|
||||
*
|
||||
* @param {Object} collection - The Bruno collection object
|
||||
* @param {string} dirPath - The output directory path
|
||||
*/
|
||||
const createCollectionFromBrunoObject = async (collection, dirPath) => {
|
||||
// Create bruno.json
|
||||
const brunoConfig = {
|
||||
version: '1',
|
||||
name: collection.name,
|
||||
type: 'collection',
|
||||
ignore: ['node_modules', '.git']
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(dirPath, 'bruno.json'),
|
||||
JSON.stringify(brunoConfig, null, 2)
|
||||
);
|
||||
|
||||
// Create collection.bru if root exists
|
||||
if (collection.root) {
|
||||
const collectionContent = await jsonToCollectionBru(collection.root);
|
||||
fs.writeFileSync(path.join(dirPath, 'collection.bru'), collectionContent);
|
||||
}
|
||||
|
||||
// Process environments
|
||||
if (collection.environments && collection.environments.length) {
|
||||
const envDirPath = path.join(dirPath, 'environments');
|
||||
fs.mkdirSync(envDirPath, { recursive: true });
|
||||
|
||||
for (const env of collection.environments) {
|
||||
const content = await envJsonToBruV2(env);
|
||||
const filename = sanitizeName(`${env.name}.bru`);
|
||||
fs.writeFileSync(path.join(envDirPath, filename), content);
|
||||
}
|
||||
}
|
||||
|
||||
// Process collection items
|
||||
await processCollectionItems(collection.items, dirPath);
|
||||
|
||||
return dirPath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively processes collection items to create files and folders
|
||||
*
|
||||
* @param {Array} items - Collection items
|
||||
* @param {string} currentPath - Current directory path
|
||||
*/
|
||||
const processCollectionItems = async (items = [], currentPath) => {
|
||||
for (const item of items) {
|
||||
if (item.type === 'folder') {
|
||||
// Create folder
|
||||
let sanitizedFolderName = sanitizeName(item?.filename || item?.name);
|
||||
const folderPath = path.join(currentPath, sanitizedFolderName);
|
||||
fs.mkdirSync(folderPath, { recursive: true });
|
||||
|
||||
// Create folder.bru file if root exists
|
||||
if (item?.root?.meta?.name) {
|
||||
const folderBruFilePath = path.join(folderPath, 'folder.bru');
|
||||
if (item.seq) {
|
||||
item.root.meta.seq = item.seq;
|
||||
}
|
||||
const folderContent = await jsonToCollectionBru(
|
||||
item.root,
|
||||
true
|
||||
);
|
||||
safeWriteFileSync(folderBruFilePath, folderContent);
|
||||
}
|
||||
|
||||
// Process folder items recursively
|
||||
if (item.items && item.items.length) {
|
||||
await processCollectionItems(item.items, folderPath);
|
||||
}
|
||||
} else if (['http-request', 'graphql-request'].includes(item.type)) {
|
||||
// Create request file
|
||||
let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.bru`);
|
||||
if (!sanitizedFilename.endsWith('.bru')) {
|
||||
sanitizedFilename += '.bru';
|
||||
}
|
||||
|
||||
// Convert JSON to BRU format based on the item type
|
||||
let type = item.type === 'http-request' ? 'http' : 'graphql';
|
||||
const bruJson = {
|
||||
meta: {
|
||||
name: item.name,
|
||||
type: type,
|
||||
seq: typeof item.seq === 'number' ? item.seq : 1
|
||||
},
|
||||
http: {
|
||||
method: (item.request?.method || 'GET').toLowerCase(),
|
||||
url: item.request?.url || '',
|
||||
auth: item.request?.auth?.mode || 'none',
|
||||
body: item.request?.body?.mode || 'none'
|
||||
},
|
||||
params: item.request?.params || [],
|
||||
headers: item.request?.headers || [],
|
||||
auth: item.request?.auth || {},
|
||||
body: item.request?.body || {},
|
||||
script: item.request?.script || {},
|
||||
vars: {
|
||||
req: item.request?.vars?.req || [],
|
||||
res: item.request?.vars?.res || []
|
||||
},
|
||||
assertions: item.request?.assertions || [],
|
||||
tests: item.request?.tests || '',
|
||||
docs: item.request?.docs || ''
|
||||
};
|
||||
|
||||
// Convert to BRU format and write to file
|
||||
const content = await jsonToBruV2(bruJson);
|
||||
safeWriteFileSync(path.join(currentPath, sanitizedFilename), content);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
mergeHeaders,
|
||||
mergeVars,
|
||||
mergeScripts,
|
||||
findItemInCollection,
|
||||
getTreePathFromCollectionToItem
|
||||
getTreePathFromCollectionToItem,
|
||||
createCollectionFromBrunoObject
|
||||
}
|
||||
@@ -118,6 +118,46 @@ const getSubDirectories = (dir) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes a filename to make it safe for filesystem operations
|
||||
*
|
||||
* @param {string} name - The name to sanitize
|
||||
* @returns {string} - The sanitized name
|
||||
*/
|
||||
const sanitizeName = (name) => {
|
||||
if (!name) return '';
|
||||
|
||||
const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g;
|
||||
return name
|
||||
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
|
||||
.replace(/^[.\s-]+/, '') // remove leading dots, hyphens and spaces
|
||||
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces (keep trailing hyphens)
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates if a name is valid for the filesystem
|
||||
*
|
||||
* @param {string} name - The name to validate
|
||||
* @returns {boolean} - True if the name is valid, false otherwise
|
||||
*/
|
||||
const validateName = (name) => {
|
||||
if (!name) return false;
|
||||
|
||||
const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
|
||||
const firstCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot, space, or hyphen at start
|
||||
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no invalid characters
|
||||
const lastCharacter = /[^.\s]$/; // no dot or space at end, hyphen allowed
|
||||
|
||||
if (name.length > 255) return false; // max name length
|
||||
if (reservedDeviceNames.test(name)) return false; // windows reserved names
|
||||
|
||||
return (
|
||||
firstCharacter.test(name) &&
|
||||
middleCharacters.test(name) &&
|
||||
lastCharacter.test(name)
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
exists,
|
||||
isSymbolicLink,
|
||||
@@ -131,5 +171,7 @@ module.exports = {
|
||||
searchForFiles,
|
||||
searchForBruFiles,
|
||||
stripExtension,
|
||||
getSubDirectories
|
||||
getSubDirectories,
|
||||
sanitizeName,
|
||||
validateName
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user