diff --git a/package-lock.json b/package-lock.json index 08f122dc9..5e41191ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/bruno-cli/package.json b/packages/bruno-cli/package.json index 7347f78fb..e1b74e191 100644 --- a/packages/bruno-cli/package.json +++ b/packages/bruno-cli/package.json @@ -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", diff --git a/packages/bruno-cli/readme.md b/packages/bruno-cli/readme.md index 616f1b385..c1d38f457 100644 --- a/packages/bruno-cli/readme.md +++ b/packages/bruno-cli/readme.md @@ -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 | diff --git a/packages/bruno-cli/src/commands/import.js b/packages/bruno-cli/src/commands/import.js new file mode 100644 index 000000000..dd12a8bc3 --- /dev/null +++ b/packages/bruno-cli/src/commands/import.js @@ -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 '; +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 +}; \ No newline at end of file diff --git a/packages/bruno-cli/src/utils/collection.js b/packages/bruno-cli/src/utils/collection.js index 64e17cb39..ec2705af3 100644 --- a/packages/bruno-cli/src/utils/collection.js +++ b/packages/bruno-cli/src/utils/collection.js @@ -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 } \ No newline at end of file diff --git a/packages/bruno-cli/src/utils/filesystem.js b/packages/bruno-cli/src/utils/filesystem.js index c3438cebc..46aa6c797 100644 --- a/packages/bruno-cli/src/utils/filesystem.js +++ b/packages/bruno-cli/src/utils/filesystem.js @@ -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 };