- add YAML module and remove python child process
- add collection headers to each request
- fix authorization header emission
- create .env.template file with environment variables
This commit is contained in:
2026-06-30 12:24:10 +02:00
parent aa272dfc55
commit 8851717532
3 changed files with 519 additions and 255 deletions
+487 -255
View File
@@ -1,325 +1,557 @@
#!/usr/bin/env node #!/usr/bin/env node
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const YAML = require('yaml');
const interpolationVariableRegex = /^{{(.*?)}}$/
function findWorkspaceRoot(startDir) { function findWorkspaceRoot(startDir) {
let current = startDir; let current = startDir;
while (true) { while (true) {
if (fs.existsSync(path.join(current, 'workspace.yml'))) { if (fs.existsSync(path.join(current, 'workspace.yml'))) {
return current; return current;
}
const parent = path.dirname(current);
if (parent === current) {
throw new Error('workspace.yml not found from the provided start directory');
}
current = parent;
} }
const parent = path.dirname(current); }
if (parent === current) {
throw new Error('workspace.yml not found from the provided start directory'); function parseYaml(filePath) {
try {
const text = fs.readFileSync(filePath, 'utf8');
const parsed = YAML.parse(text);
return parsed || {};
} catch (error) {
throw new Error(`Failed to parse YAML file ${filePath}: ${error.message}`);
} }
current = parent;
}
} }
function readText(filePath) { function readText(filePath) {
return fs.readFileSync(filePath, 'utf8'); return fs.readFileSync(filePath, 'utf8');
} }
function stripQuotes(value) { function stripQuotes(value) {
const trimmed = String(value).trim(); const trimmed = String(value).trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1); return trimmed.slice(1, -1);
} }
return trimmed; return trimmed;
} }
function parseWorkspace(workspacePath) { function parseWorkspace(workspacePath) {
const text = readText(workspacePath); const text = readText(workspacePath);
const lines = text.split(/\r?\n/); const lines = text.split(/\r?\n/);
const collections = []; const collections = [];
let inCollections = false; let inCollections = false;
let currentCollection = null; let currentCollection = null;
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!inCollections && trimmed === 'collections:') { if (!inCollections && trimmed === 'collections:') {
inCollections = true; inCollections = true;
continue; continue;
}
if (!inCollections) {
continue;
}
if (!line.startsWith(' ') && !line.startsWith('\t') && trimmed) {
break;
}
const nameMatch = line.match(/^\s*-\s+name:\s*(.+)$/);
if (nameMatch) {
currentCollection = { name: stripQuotes(nameMatch[1]) };
collections.push(currentCollection);
continue;
}
const pathMatch = line.match(/^\s*path:\s*(.+)$/);
if (pathMatch && currentCollection) {
currentCollection.path = stripQuotes(pathMatch[1]);
}
} }
if (!inCollections) { return { collections };
continue;
}
if (!line.startsWith(' ') && !line.startsWith('\t') && trimmed) {
break;
}
const nameMatch = line.match(/^\s*-\s+name:\s*(.+)$/);
if (nameMatch) {
currentCollection = { name: stripQuotes(nameMatch[1]) };
collections.push(currentCollection);
continue;
}
const pathMatch = line.match(/^\s*path:\s*(.+)$/);
if (pathMatch && currentCollection) {
currentCollection.path = stripQuotes(pathMatch[1]);
}
}
return { collections };
} }
function sanitizeVarName(value) { function sanitizeVarName(value) {
return String(value) return String(value)
.trim() .trim()
.replace(/[{}]/g, '') .replace(/[{}]/g, '')
.replace(/[^A-Za-z0-9_]/g, '_') .replace(/[^A-Za-z0-9_]/g, '_')
.replace(/^([0-9])/, '_$1') || 'value'; .replace(/^([0-9])/, '_$1') || 'value';
} }
function collectPlaceholders(value) { function collectPlaceholders(value) {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return []; return [];
} }
const placeholders = []; const placeholders = [];
const regex = /\{\{([^{}]+)\}\}/g; const regex = /\{\{([^{}]+)\}\}/g;
let match; let match;
while ((match = regex.exec(value)) !== null) { while ((match = regex.exec(value)) !== null) {
placeholders.push(match[1]); placeholders.push(match[1]);
} }
return placeholders; return placeholders;
} }
function findBlock(lines, keyName) { function findBlock(lines, keyName) {
for (let i = 0; i < lines.length; i += 1) { for (let i = 0; i < lines.length; i += 1) {
const trimmed = lines[i].trim(); const trimmed = lines[i].trim();
if (trimmed !== keyName && !trimmed.startsWith(`${keyName}:`)) { if (trimmed !== keyName && !trimmed.startsWith(`${keyName}:`)) {
continue; continue;
} }
const lineIndent = lines[i].match(/^\s*/)[0].length; const lineIndent = lines[i].match(/^\s*/)[0].length;
const block = []; const block = [];
for (let j = i + 1; j < lines.length; j += 1) { for (let j = i + 1; j < lines.length; j += 1) {
const currentLine = lines[j]; const currentLine = lines[j];
const currentTrimmed = currentLine.trim(); const currentTrimmed = currentLine.trim();
if (!currentTrimmed) { if (!currentTrimmed) {
block.push(currentLine); block.push(currentLine);
continue; continue;
} }
const currentIndent = currentLine.match(/^\s*/)[0].length; const currentIndent = currentLine.match(/^\s*/)[0].length;
if (currentIndent <= lineIndent && !currentLine.startsWith(' ')) { if (currentIndent <= lineIndent && !currentLine.startsWith(' ')) {
break; break;
} }
if (currentIndent <= lineIndent && currentTrimmed.startsWith('#')) { if (currentIndent <= lineIndent && currentTrimmed.startsWith('#')) {
block.push(currentLine); block.push(currentLine);
continue; continue;
} }
if (currentIndent <= lineIndent) { if (currentIndent <= lineIndent) {
break; break;
} }
block.push(currentLine); block.push(currentLine);
}
return block;
} }
return block; return [];
}
return [];
} }
function parseRequestInfo(text) { function parseRequestInfo(text) {
const lines = text.split(/\r?\n/); const lines = text.split(/\r?\n/);
const infoLines = findBlock(lines, 'info'); const infoLines = findBlock(lines, 'info');
const nameMatch = infoLines.join('\n').match(/^\s*name:\s*(.+)$/m); const nameMatch = infoLines.join('\n').match(/^\s*name:\s*(.+)$/m);
return nameMatch ? stripQuotes(nameMatch[1]) : ''; return nameMatch ? stripQuotes(nameMatch[1]) : '';
} }
function parseHttpBlock(text) { function parseHttpBlock(text) {
const lines = text.split(/\r?\n/); const lines = text.split(/\r?\n/);
const blockLines = findBlock(lines, 'http'); const blockLines = findBlock(lines, 'http');
const blockText = blockLines.join('\n'); const blockText = blockLines.join('\n');
const methodMatch = blockText.match(/^\s*method:\s*(.+)$/m); const methodMatch = blockText.match(/^\s*method:\s*(.+)$/m);
const urlMatch = blockText.match(/^\s*url:\s*(.+)$/m); const urlMatch = blockText.match(/^\s*url:\s*(.+)$/m);
const params = []; const params = [];
const paramLines = blockLines.join('\n').split(/\r?\n/); const paramLines = blockLines.join('\n').split(/\r?\n/);
let inParamsBlock = false; let inParamsBlock = false;
let paramsIndent = 0; let paramsIndent = 0;
let currentParam = null; let currentParam = null;
for (const line of paramLines) { for (const line of paramLines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!inParamsBlock) { if (!inParamsBlock) {
if (trimmed === 'params:') { if (trimmed === 'params:') {
inParamsBlock = true; inParamsBlock = true;
paramsIndent = line.match(/^\s*/)[0].length; paramsIndent = line.match(/^\s*/)[0].length;
} }
continue; continue;
}
if (!trimmed) {
continue;
}
const indent = line.match(/^\s*/)[0].length;
if (indent <= paramsIndent) {
break;
}
const listItemMatch = line.match(/^\s*-\s+name:\s*(.+)$/);
if (listItemMatch) {
currentParam = { name: stripQuotes(listItemMatch[1]) };
params.push(currentParam);
continue;
}
if (!currentParam) {
continue;
}
const propertyMatch = line.match(/^\s*([A-Za-z0-9_]+):\s*(.+)$/);
if (propertyMatch) {
const [, key, value] = propertyMatch;
currentParam[key] = stripQuotes(value);
}
} }
if (!trimmed) { return {
continue; method: methodMatch ? stripQuotes(methodMatch[1]) : 'GET',
} url: urlMatch ? stripQuotes(urlMatch[1]) : '',
params,
const indent = line.match(/^\s*/)[0].length; };
if (indent <= paramsIndent) {
break;
}
const listItemMatch = line.match(/^\s*-\s+name:\s*(.+)$/);
if (listItemMatch) {
currentParam = { name: stripQuotes(listItemMatch[1]) };
params.push(currentParam);
continue;
}
if (!currentParam) {
continue;
}
const propertyMatch = line.match(/^\s*([A-Za-z0-9_]+):\s*(.+)$/);
if (propertyMatch) {
const [, key, value] = propertyMatch;
currentParam[key] = stripQuotes(value);
}
}
return {
method: methodMatch ? stripQuotes(methodMatch[1]) : 'GET',
url: urlMatch ? stripQuotes(urlMatch[1]) : '',
params,
};
} }
function buildRequestContent(request, requestName) { function formatVariableValue(value) {
const lines = []; if (value === null || value === undefined) {
const placeholders = new Set(); return '""';
const addPlaceholders = (value) => {
for (const placeholder of collectPlaceholders(value)) {
placeholders.add(placeholder);
}
};
let url = request.url || '';
if (url) {
addPlaceholders(url);
url = url.replace(/:([A-Za-z0-9_]+)/g, (_, name) => `{{${name}}}`);
}
const queryParams = [];
const headers = [];
for (const param of request.params || []) {
const name = param.name || '';
const value = param.value || '';
const type = (param.type || 'query').toLowerCase();
const disabled = String(param.disabled).toLowerCase() === 'true';
if (disabled) {
continue;
} }
addPlaceholders(value); if (typeof value === 'string') {
if (type === 'header') { if (value.trim() === '') {
headers.push({ name, value }); return '""';
} else { }
queryParams.push({ name, value }); if (/\s/.test(value)) {
return `"${value.replace(/"/g, '\\"')}"`;
}
return value;
} }
}
if (placeholders.size > 0) { return JSON.stringify(value);
lines.push(`# Variables for ${requestName}`); }
for (const placeholder of [...placeholders].sort()) {
lines.push(`@${sanitizeVarName(placeholder)} = YOUR_VALUE_HERE`); function mergeRequestConfig(base, updates) {
if (!updates || typeof updates !== 'object') {
return base;
} }
lines.push('');
}
const method = (request.method || 'GET').toUpperCase(); const merged = { ...(base || {}) };
let requestUrl = url; if (updates.auth) {
for (const param of queryParams) { if (updates.auth === 'inherit' && merged.auth && typeof merged.auth === 'object') {
if (!param.name) { merged.auth = merged.auth;
continue; } else if (typeof updates.auth === 'object') {
merged.auth = updates.auth;
}
} }
const separator = requestUrl.includes('?') ? '&' : '?'; if (Array.isArray(updates.variables)) {
requestUrl = `${requestUrl}${separator}${param.name}=${param.value}`; const variables = [...(Array.isArray(base.variables) ? base.variables : [])];
} const byName = new Map();
for (const variable of variables) {
if (variable && variable.name) {
byName.set(String(variable.name), variable);
}
}
for (const variable of updates.variables) {
if (variable && variable.name) {
byName.set(String(variable.name), variable);
}
}
merged.variables = [...byName.values()];
}
return merged;
}
lines.push(`${method} ${requestUrl}`); function getRequestConfigForFile(yamlFile, sourceDir) {
for (const header of headers) { const resolved = [];
lines.push(`${header.name}: ${header.value}`); const seenFiles = new Set();
}
return lines.join('\n'); const addFile = (filePath) => {
if (!filePath || seenFiles.has(filePath)) {
return;
}
seenFiles.add(filePath);
if (!fs.existsSync(filePath)) {
return;
}
try {
const parsed = parseYaml(filePath);
if (parsed && parsed.request && typeof parsed.request === 'object') {
resolved.push(parsed.request);
}
} catch (error) {
// Ignore files that cannot be parsed as YAML for request inheritance.
}
};
addFile(yamlFile);
const dirChain = [];
let currentDir = path.dirname(yamlFile);
while (true) {
dirChain.unshift(currentDir);
if (currentDir === sourceDir) {
break;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
break;
}
currentDir = parentDir;
}
for (const dir of dirChain) {
addFile(path.join(dir, 'opencollection.yml'));
addFile(path.join(dir, 'folder.yml'));
}
return resolved.reduce((result, config) => mergeRequestConfig(result, config), {});
}
function buildRequestContent(request, requestName, requestConfig = {}) {
const lines = [];
const variableDefinitions = [];
const seenVariables = new Set();
const addVariable = (name, value) => {
if (!name) {
return;
}
const normalized = String(name).trim();
if (!normalized || seenVariables.has(normalized)) {
return;
}
seenVariables.add(normalized);
variableDefinitions.push({ name: normalized, value });
};
const configVariables = Array.isArray(requestConfig.variables) ? requestConfig.variables : [];
for (const variable of configVariables) {
if (variable && variable.name) {
addVariable(variable.name, variable.value);
}
}
let url = request.url || '';
if (url) {
for (const placeholder of collectPlaceholders(url)) {
addVariable(placeholder, 'YOUR_VALUE_HERE');
}
url = url.replace(/:([A-Za-z0-9_]+)/g, (_, name) => `{{${name}}}`);
}
const queryParams = [];
const headers = [];
for (const header of Array.isArray(requestConfig.headers) ? requestConfig.headers : []) {
if (!header || !header.name) {
continue;
}
for (const placeholder of collectPlaceholders(String(header.value ?? ''))) {
addVariable(placeholder, 'YOUR_VALUE_HERE');
}
headers.push({ name: header.name, value: header.value ?? '' });
}
for (const param of request.params || []) {
const name = param.name || '';
const value = param.value || '';
const type = (param.type || 'query').toLowerCase();
const disabled = String(param.disabled).toLowerCase() === 'true';
if (disabled) {
continue;
}
for (const placeholder of collectPlaceholders(String(value))) {
addVariable(placeholder, 'YOUR_VALUE_HERE');
}
if (type === 'header') {
headers.push({ name, value });
} else {
queryParams.push({ name, value });
}
}
if (requestConfig.auth)
{
if (requestConfig.auth.type === 'bearer') {
for (const placeholder of collectPlaceholders(String(requestConfig.auth.token ?? ''))) {
addVariable(placeholder, 'YOUR_VALUE_HERE');
}
headers.push({
name: 'Authorization',
value: `Bearer ${requestConfig.auth.token ?? ''}`,
});
} else if (requestConfig.auth.type === 'basic') {
const username = requestConfig.auth.username ?? '';
const password = requestConfig.auth.password ?? '';
for (const placeholder of collectPlaceholders(String(username))) {
addVariable(placeholder, 'YOUR_VALUE_HERE');
}
for (const placeholder of collectPlaceholders(String(password))) {
addVariable(placeholder, 'YOUR_VALUE_HERE');
}
// VSCode REST Client can manage username:password format directly!
headers.push({
name: 'Authorization',
value: `Basic ${username}:${password}`,
});
} else {
headers.push({
name: `UNKNOWN_${requestConfig.auth.type}`,
value: `Basic ${requestConfig.auth.token}`,
});
}
}
if (variableDefinitions.length > 0) {
lines.push(`# Variables for ${requestName}`);
for (const variable of variableDefinitions) {
lines.push(`@${sanitizeVarName(variable.name)} = ${formatVariableValue(variable.value)}`);
}
lines.push('');
}
const method = (request.method || 'GET').toUpperCase();
let requestUrl = url;
for (const param of queryParams) {
if (!param.name) {
continue;
}
const separator = requestUrl.includes('?') ? '&' : '?';
requestUrl = `${requestUrl}${separator}${param.name}=${param.value}`;
}
lines.push(`${method} ${requestUrl}`);
for (const header of headers) {
lines.push(`${header.name}: ${header.value}`);
}
return lines.join('\n');
} }
function ensureDir(dirPath) { function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true }); fs.mkdirSync(dirPath, { recursive: true });
} }
function walkYamlFiles(rootDir) { function walkYamlFiles(rootDir) {
const results = []; const results = [];
const entries = fs.readdirSync(rootDir, { withFileTypes: true }); const entries = fs.readdirSync(rootDir, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
if (entry.name.startsWith('.') || entry.name === 'node_modules') { if (entry.name.startsWith('.') || entry.name === 'node_modules') {
continue; continue;
}
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
results.push(...walkYamlFiles(fullPath));
} else if (entry.isFile() && /\.ya?ml$/i.test(entry.name)) {
results.push(fullPath);
}
} }
const fullPath = path.join(rootDir, entry.name); return results;
if (entry.isDirectory()) { }
results.push(...walkYamlFiles(fullPath));
} else if (entry.isFile() && /\.ya?ml$/i.test(entry.name)) { function writeEnvironmentTemplates(sourceDir, outputRoot) {
results.push(fullPath); const targets = [];
const visit = (currentDir) => {
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
const hasEnvironmentsDir = entries.some((entry) => entry.isDirectory() && entry.name === 'environments');
if (hasEnvironmentsDir) {
targets.push(currentDir);
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') {
continue;
}
visit(path.join(currentDir, entry.name));
}
};
visit(sourceDir);
for (const dir of targets) {
const relativeDir = path.relative(sourceDir, dir);
const targetDir = relativeDir && relativeDir !== '.' ? path.join(outputRoot, relativeDir) : outputRoot;
ensureDir(targetDir);
const environmentsDir = path.join(dir, 'environments');
if (!fs.existsSync(environmentsDir)) {
continue;
}
const envFiles = fs.readdirSync(environmentsDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && /\.ya?ml$/i.test(entry.name))
.map((entry) => path.join(environmentsDir, entry.name));
const variableNames = [];
const seenNames = new Set();
for (const envFile of envFiles) {
const parsed = parseYaml(envFile);
const variables = Array.isArray(parsed.variables) ? parsed.variables : [];
for (const variable of variables) {
if (!variable || !variable.name) {
continue;
}
const name = String(variable.name).trim();
if (!name || seenNames.has(name)) {
continue;
}
seenNames.add(name);
variableNames.push(name);
}
}
const templateContent = variableNames.length > 0 ? `${variableNames.join('\n')}\n` : '';
fs.writeFileSync(path.join(targetDir, '.env.template'), templateContent, 'utf8');
} }
}
return results;
} }
function main() { function main() {
const workspaceRoot = findWorkspaceRoot(__dirname); const workspaceRoot = findWorkspaceRoot(__dirname);
const workspaceFile = path.join(workspaceRoot, 'workspace.yml'); const workspaceFile = path.join(workspaceRoot, 'workspace.yml');
const workspace = parseWorkspace(workspaceFile); const workspace = parseWorkspace(workspaceFile);
const collections = Array.isArray(workspace.collections) ? workspace.collections : []; const collections = Array.isArray(workspace.collections) ? workspace.collections : [];
if (collections.length === 0) { if (collections.length === 0) {
throw new Error('No collections found in workspace.yml'); throw new Error('No collections found in workspace.yml');
}
for (const collection of collections) {
if (!collection || !collection.name || !collection.path) {
continue;
} }
const sourceDir = path.join(workspaceRoot, collection.path); for (const collection of collections) {
if (!fs.existsSync(sourceDir)) { if (!collection || !collection.name || !collection.path) {
console.warn(`Skipping missing collection path: ${collection.path}`); continue;
continue; }
const sourceDir = path.join(workspaceRoot, collection.path);
if (!fs.existsSync(sourceDir)) {
console.warn(`Skipping missing collection path: ${collection.path}`);
continue;
}
const outputRoot = path.join(workspaceRoot, 'autodocs', 'http', collection.name);
ensureDir(outputRoot);
writeEnvironmentTemplates(sourceDir, outputRoot);
const yamlFiles = walkYamlFiles(sourceDir);
let processed = 0;
for (const yamlFile of yamlFiles) {
const content = readText(yamlFile);
const requestName = parseRequestInfo(content) || path.basename(yamlFile, path.extname(yamlFile));
const httpBlock = parseHttpBlock(content);
if (!httpBlock || !httpBlock.url) {
continue;
}
const relativePath = path.relative(sourceDir, yamlFile);
const parsedPath = path.parse(relativePath);
const targetDir = path.join(outputRoot, parsedPath.dir);
ensureDir(targetDir);
const requestConfig = getRequestConfigForFile(yamlFile, sourceDir);
const outputFile = path.join(targetDir, `${parsedPath.name}.http`);
const requestContent = buildRequestContent(httpBlock, requestName, requestConfig);
fs.writeFileSync(outputFile, `${requestContent}\n`, 'utf8');
processed += 1;
}
console.log(`Generated ${processed} .http file(s) for ${collection.name}`);
} }
const outputRoot = path.join(workspaceRoot, 'autodocs', 'http', collection.name);
ensureDir(outputRoot);
const yamlFiles = walkYamlFiles(sourceDir);
let processed = 0;
for (const yamlFile of yamlFiles) {
const content = readText(yamlFile);
const requestName = parseRequestInfo(content) || path.basename(yamlFile, path.extname(yamlFile));
const httpBlock = parseHttpBlock(content);
if (!httpBlock || !httpBlock.url) {
continue;
}
const relativePath = path.relative(sourceDir, yamlFile);
const parsedPath = path.parse(relativePath);
const targetDir = path.join(outputRoot, parsedPath.dir);
ensureDir(targetDir);
const outputFile = path.join(targetDir, `${parsedPath.name}.http`);
const requestContent = buildRequestContent(httpBlock, requestName);
fs.writeFileSync(outputFile, `${requestContent}\n`, 'utf8');
processed += 1;
}
console.log(`Generated ${processed} .http file(s) for ${collection.name}`);
}
} }
try { try {
main(); main();
} catch (error) { } catch (error) {
console.error(error.message); console.error(error.message);
process.exit(1); process.exit(1);
} }
+27
View File
@@ -0,0 +1,27 @@
{
"name": "scripts",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"yaml": "^2.9.0"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"yaml": "^2.9.0"
}
}