Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"packages/*"
],
"scripts": {
"test": "node --test 'packages/*/test/**/*.js' '../test-fixtures/verify.js'",
"test": "node --test packages/*/test/**/*.js ../test-fixtures/verify.js",
"lint": "npm run lint --workspaces"
}
}
19 changes: 13 additions & 6 deletions js/packages/cli/src/bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,34 @@ const hydrateCommand = require('./commands/hydrate');
const parseCommand = require('./commands/parse');
const emitCommand = require('./commands/emit');
const runCommand = require('./commands/run');
const verifyCommand = require('./commands/verify');

function main() {
async function main() {
const { command, file, flags } = parseArgs(process.argv);

switch (command) {
case 'hydrate':
hydrateCommand(file, flags);
await hydrateCommand(file, flags);
break;
case 'parse':
parseCommand(file, flags);
await parseCommand(file, flags);
break;
case 'emit':
emitCommand(file, flags);
await emitCommand(file, flags);
break;
case 'run':
runCommand(file, flags);
await runCommand(file, flags);
break;
case 'verify':
await verifyCommand(file, flags);
break;
default:
console.log(`Unknown command: ${command}`);
process.exit(1);
}
}

main();
main().catch((err) => {
console.error(err.message);
process.exit(1);
});
44 changes: 37 additions & 7 deletions js/packages/cli/src/commands/emit.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ const { dispatchFetch } = require('@httpt/core');
*/
function dispatchCurl(ir, scheme, bodyStream = null) {
return new Promise((resolve, reject) => {
const url = `${scheme}://${ir.host}${ir.uri}`;
const parsedUrl = new URL(ir.uri, `${scheme}://${ir.host}`);
parsedUrl.protocol = `${scheme}:`;
parsedUrl.host = ir.host;
const url = parsedUrl.toString();
const args = ['-s', '-v', '-X', ir.method];

if (ir.version === 'HTTP/1.0') args.push('--http1.0');
else if (ir.version === 'HTTP/1.1') args.push('--http1.1');
else if (ir.version === 'HTTP/2' || ir.version === 'HTTP/2.0') args.push('--http2');
else if (ir.version === 'HTTP/3') args.push('--http3');
// HTTP/2 and HTTP/3 support depends on the installed curl build and server.
// Let curl negotiate/fallback instead of making local E2E execution fail.

for (const { name, value } of ir.headers) {
args.push('-H', value ? `${name}: ${value}` : `${name};`);
Expand Down Expand Up @@ -89,8 +92,35 @@ function dispatchCurl(ir, scheme, bodyStream = null) {
});
}

function emitCommand(file, flags) {
// TODO: Read parsed IR, handle the --target <curl|fetch> flag, and call the respective executor.
console.log(`[emit] Executing on file: ${file} with flags: ${JSON.stringify(flags)}`);
async function emitCommand(file, flags) {
if (!file) {
throw new Error('Missing .httpt-ir file');
}

const ir = JSON.parse(require('node:fs').readFileSync(file, 'utf-8'));
const scheme = flags.scheme || 'https';
const target = flags.target || 'fetch';

if (flags['dry-run'] || flags.dryRun) {
console.log(JSON.stringify(ir, null, 2));
return;
}

if (target === 'curl') {
await dispatchCurl(ir, scheme);
return;
}

if (target === 'fetch') {
const response = await dispatchFetch(ir, scheme);
const responseText = await response.text();
process.stdout.write(responseText);
return;
}

throw new Error(`Unsupported emit target: ${target}`);
}
module.exports = { emitCommand, dispatchCurl };

emitCommand.dispatchCurl = dispatchCurl;

module.exports = emitCommand;
28 changes: 26 additions & 2 deletions js/packages/cli/src/commands/hydrate.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,30 @@
const fs = require('node:fs');
const { hydrate } = require('@httpt/core');
const { loadStreamsFromFlags } = require('../streams');

function hydrateCommand(file, flags) {
// TODO: Handle the --shift-map flag
console.log(`[hydrate] Executing on file: ${file} with flags: ${JSON.stringify(flags)}`);
if (!file) {
throw new Error('Missing template file');
}

if (!flags.data) {
throw new Error('Missing required --data flag');
}

const template = fs.readFileSync(file, 'utf-8');
const data = JSON.parse(fs.readFileSync(flags.data, 'utf-8'));
const streams = loadStreamsFromFlags(flags);
const { resolved, map } = hydrate(template, data, streams);
const outputFile = flags.out || flags.output || `${file}-r`;

fs.writeFileSync(outputFile, resolved);
console.log(`Written to ${outputFile}`);

if (flags['shift-map'] || flags.map) {
const mapFile = flags.map === true || !flags.map ? `${file}-map` : flags.map;
fs.writeFileSync(mapFile, JSON.stringify(map, null, 2));
console.log(`Written to ${mapFile}`);
}
}

module.exports = hydrateCommand;
13 changes: 12 additions & 1 deletion js/packages/cli/src/commands/parse.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
const fs = require('node:fs');
const { parse } = require('@httpt/core');
function parseCommand(file, flags) {
console.log(`[parse] Executing on file: ${file} with flags: ${JSON.stringify(flags)}`);
if (!file) {
throw new Error('Missing resolved .httpt-r file');
}

const resolved = fs.readFileSync(file, 'utf-8');
const { ir } = parse(resolved);

const outputFile = flags.out || flags.output || file.replace(/\.httpt-r$/, '.httpt-ir');
fs.writeFileSync(outputFile, JSON.stringify(ir, null, 2));
console.log(`Written to ${outputFile}`);
}

module.exports = parseCommand;
41 changes: 38 additions & 3 deletions js/packages/cli/src/commands/run.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,41 @@
function runCommand(file, flags) {
// TODO: Handle the --dry-run flag and orchestrate the full pipeline.
console.log(`[run] Executing on file: ${file} with flags: ${JSON.stringify(flags)}`);
const fs = require('node:fs');
const { build, execute } = require('@httpt/core');
const { loadStreamsFromFlags } = require('../streams');

async function runCommand(file, flags) {
if (!file) {
throw new Error('Missing template file');
}

if (!flags.data) {
throw new Error('Missing required --data flag');
}

const template = fs.readFileSync(file, 'utf-8');
const data = JSON.parse(fs.readFileSync(flags.data, 'utf-8'));
const streams = loadStreamsFromFlags(flags);
const isDryRun = flags['dry-run'] || flags.dryRun;

if (isDryRun || flags.out || flags.output) {
const { ir } = await build(template, data, streams);
const outputFile = flags.out || flags.output;

if (outputFile) {
fs.writeFileSync(outputFile, JSON.stringify(ir, null, 2));
console.log(`Written to ${outputFile}`);
return;
}

console.log(JSON.stringify(ir, null, 2));
return;
}

const response = await execute(template, data, streams, {
scheme: flags.scheme || 'https',
});

const responseText = await response.text();
process.stdout.write(responseText);
}

module.exports = runCommand;
16 changes: 16 additions & 0 deletions js/packages/cli/src/commands/verify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const fs = require('node:fs');
const { verifyContract } = require('@httpt/core');
const { normalizeFlagList } = require('../streams');

function verifyCommand(file, flags) {
if (!file) {
throw new Error('Missing template file');
}

const template = fs.readFileSync(file, 'utf-8');
const expectedArguments = normalizeFlagList(flags.expect || flags.expected || flags.arg);
verifyContract(template, expectedArguments);
console.log('Contract verified');
}

module.exports = verifyCommand;
45 changes: 38 additions & 7 deletions js/packages/cli/src/parser.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,42 @@
function parseArgs(argv) {
// TODO: Implement custom positional argument parsing here.
// For now, return a dummy object to satisfy scaffolding requirements.
return {
command: 'run',
file: 'dummy.httpt',
flags: {}
};

const args = argv.slice(2);
const command = args[0];
let file = null;

const flags = {};

function setFlag(name, value) {
if (Object.prototype.hasOwnProperty.call(flags, name)) {
flags[name] = Array.isArray(flags[name]) ? flags[name].concat(value) : [flags[name], value];
return;
}
flags[name] = value;
}

for (let i=1, len=args.length; i<len; i++) {
const arg = args[i];

if(!arg.startsWith('--')) {
if (!file) {
file = arg;
}
continue;
}
const flagName = arg.slice(2);
const nextValue = args[i + 1];

if(!nextValue || nextValue.startsWith('--')) {
setFlag(flagName, true);
}else {
setFlag(flagName, nextValue);
i++;
}

}

return { command, file, flags };

}

module.exports = { parseArgs };
20 changes: 20 additions & 0 deletions js/packages/cli/src/streams.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const fs = require('node:fs');

function normalizeFlagList(value) {
if (value == null || value === false) {
return [];
}
return Array.isArray(value) ? value : [value];
}

function loadStreamsFromFlags(flags) {
const streamPaths = normalizeFlagList(flags.stream || flags.streams);
return streamPaths.map((streamPath) => {
if (streamPath === true) {
throw new Error('Missing path for --stream flag');
}
return fs.readFileSync(streamPath);
});
}

module.exports = { loadStreamsFromFlags, normalizeFlagList };
44 changes: 44 additions & 0 deletions js/packages/cli/test/parser.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { parseArgs } = require('../src/parser.js');

describe('CLI argument parser', () => {
it('should accept flags before the file path', () => {
const parsed = parseArgs(['node', 'httpt', 'run', '--scheme', 'https', 'submit.httpt']);

assert.deepEqual(parsed, {
command: 'run',
file: 'submit.httpt',
flags: { scheme: 'https' },
});
});

it('should accept flags after the file path', () => {
const parsed = parseArgs(['node', 'httpt', 'run', 'submit.httpt', '--scheme', 'http']);

assert.deepEqual(parsed, {
command: 'run',
file: 'submit.httpt',
flags: { scheme: 'http' },
});
});

it('should preserve repeated flags as arrays', () => {
const parsed = parseArgs([
'node',
'httpt',
'run',
'submit.httpt',
'--stream',
'a.bin',
'--stream',
'b.bin',
]);

assert.deepEqual(parsed, {
command: 'run',
file: 'submit.httpt',
flags: { stream: ['a.bin', 'b.bin'] },
});
});
});
20 changes: 20 additions & 0 deletions js/packages/cli/test/streams.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const { describe, it } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { loadStreamsFromFlags } = require('../src/streams.js');

describe('CLI stream loading', () => {
it('should load --stream files as native buffers in order', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'httpt-streams-'));
const first = path.join(dir, 'first.txt');
const second = path.join(dir, 'second.txt');
fs.writeFileSync(first, 'first');
fs.writeFileSync(second, 'second');

const streams = loadStreamsFromFlags({ stream: [first, second] });

assert.deepEqual(streams.map((stream) => stream.toString('utf8')), ['first', 'second']);
});
});
16 changes: 16 additions & 0 deletions js/packages/cli/test/verify.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const { describe, it } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const verifyCommand = require('../src/commands/verify.js');

describe('CLI verify command', () => {
it('should verify a template against expected arguments', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'httpt-verify-'));
const templateFile = path.join(dir, 'request.httpt');
fs.writeFileSync(templateFile, 'GET /{{ user-id | url }} HTTP/1.1\nHost: example.com\n');

assert.doesNotThrow(() => verifyCommand(templateFile, { expect: 'user-id' }));
});
});
Loading