diff --git a/js/package.json b/js/package.json index 4ca7b16..3f73014 100644 --- a/js/package.json +++ b/js/package.json @@ -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" } } diff --git a/js/packages/cli/src/bin.js b/js/packages/cli/src/bin.js index 5795520..5936037 100755 --- a/js/packages/cli/src/bin.js +++ b/js/packages/cli/src/bin.js @@ -5,22 +5,26 @@ 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}`); @@ -28,4 +32,7 @@ function main() { } } -main(); +main().catch((err) => { + console.error(err.message); + process.exit(1); +}); diff --git a/js/packages/cli/src/commands/emit.js b/js/packages/cli/src/commands/emit.js index fce2b77..bb17ce8 100644 --- a/js/packages/cli/src/commands/emit.js +++ b/js/packages/cli/src/commands/emit.js @@ -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};`); @@ -89,8 +92,35 @@ function dispatchCurl(ir, scheme, bodyStream = null) { }); } -function emitCommand(file, flags) { - // TODO: Read parsed IR, handle the --target 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; diff --git a/js/packages/cli/src/commands/hydrate.js b/js/packages/cli/src/commands/hydrate.js index f8e40c5..83f147d 100644 --- a/js/packages/cli/src/commands/hydrate.js +++ b/js/packages/cli/src/commands/hydrate.js @@ -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; diff --git a/js/packages/cli/src/commands/parse.js b/js/packages/cli/src/commands/parse.js index 4a9a3df..39d88e9 100644 --- a/js/packages/cli/src/commands/parse.js +++ b/js/packages/cli/src/commands/parse.js @@ -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; diff --git a/js/packages/cli/src/commands/run.js b/js/packages/cli/src/commands/run.js index 2fe0756..7f5afbf 100644 --- a/js/packages/cli/src/commands/run.js +++ b/js/packages/cli/src/commands/run.js @@ -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; diff --git a/js/packages/cli/src/commands/verify.js b/js/packages/cli/src/commands/verify.js new file mode 100644 index 0000000..fd6f753 --- /dev/null +++ b/js/packages/cli/src/commands/verify.js @@ -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; diff --git a/js/packages/cli/src/parser.js b/js/packages/cli/src/parser.js index 7e798a9..846c02b 100644 --- a/js/packages/cli/src/parser.js +++ b/js/packages/cli/src/parser.js @@ -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 { + if (streamPath === true) { + throw new Error('Missing path for --stream flag'); + } + return fs.readFileSync(streamPath); + }); +} + +module.exports = { loadStreamsFromFlags, normalizeFlagList }; diff --git a/js/packages/cli/test/parser.test.js b/js/packages/cli/test/parser.test.js new file mode 100644 index 0000000..6d13276 --- /dev/null +++ b/js/packages/cli/test/parser.test.js @@ -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'] }, + }); + }); +}); diff --git a/js/packages/cli/test/streams.test.js b/js/packages/cli/test/streams.test.js new file mode 100644 index 0000000..0b9d762 --- /dev/null +++ b/js/packages/cli/test/streams.test.js @@ -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']); + }); +}); diff --git a/js/packages/cli/test/verify.test.js b/js/packages/cli/test/verify.test.js new file mode 100644 index 0000000..092019c --- /dev/null +++ b/js/packages/cli/test/verify.test.js @@ -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' })); + }); +}); diff --git a/js/packages/core/index.js b/js/packages/core/index.js index 5f07672..fc4e712 100644 --- a/js/packages/core/index.js +++ b/js/packages/core/index.js @@ -1,9 +1,21 @@ -const { hydrate, parse } = require('./src/pipeline'); +const { + hydrate, + hydrateAsync, + parse, + parseAsync, + verifyContract, + validateStreamReferences, + prepareHydrationContext, +} = require('./src/pipeline'); const { build, execute } = require('./src/facade'); const { dispatchFetch } = require('./src/dispatch'); module.exports = { - hydrate, parse, + hydrate, hydrateAsync, + parse, parseAsync, + verifyContract, + validateStreamReferences, + prepareHydrationContext, build, execute, dispatchFetch }; diff --git a/js/packages/core/src/dispatch.js b/js/packages/core/src/dispatch.js index 52173f0..cb7228c 100644 --- a/js/packages/core/src/dispatch.js +++ b/js/packages/core/src/dispatch.js @@ -10,7 +10,10 @@ * @returns {Promise} */ async function dispatchFetch(ir, scheme, bodyStream = null) { - const url = new URL(ir.uri, `${scheme}://${ir.host}`).toString(); + const parsedUrl = new URL(ir.uri, `${scheme}://${ir.host}`); + parsedUrl.protocol = `${scheme}:`; + parsedUrl.host = ir.host; + const url = parsedUrl.toString(); const headers = new Headers(); for (const { name, value } of ir.headers) headers.append(name, value); diff --git a/js/packages/core/src/facade.js b/js/packages/core/src/facade.js index 3ee8c8c..a58b260 100644 --- a/js/packages/core/src/facade.js +++ b/js/packages/core/src/facade.js @@ -1,12 +1,22 @@ -const { hydrate, parse } = require('./pipeline'); +const { hydrateAsync, parseAsync } = require('./pipeline'); const { dispatchFetch } = require('./dispatch'); async function build(template, data, streams = []) { - return { ir: {}, map: [], bodyStream: null }; + const { resolved, map, bodyStream } = await hydrateAsync(template, data, streams); + const parsed = await parseAsync(resolved, bodyStream); + + return { + ir: parsed.ir, + map, + bodyStream: parsed.bodyStream, + }; } async function execute(template, data, streams = [], config = {}) { - return new Response(); + const scheme = config.scheme || 'https'; + const { ir, bodyStream } = await build(template, data, streams); + + return dispatchFetch(ir, scheme, bodyStream); } module.exports = { build, execute }; diff --git a/js/packages/core/src/pipeline.js b/js/packages/core/src/pipeline.js index b9f92ec..0b695cf 100644 --- a/js/packages/core/src/pipeline.js +++ b/js/packages/core/src/pipeline.js @@ -1,9 +1,728 @@ -async function hydrate(template, data, streams = []) { - return { resolved: '', map: [], bodyStream: null }; +function createNamedError(name, message, details = {}) { + const error = new Error(message); + error.name = name; + Object.assign(error, details); + return error; +} + +const BUILT_IN_FUNCTIONS = new Set([ + 'raw', + 'url', + 'json-value', + 'json-string', + 'json-key', + 'stream-as-base64', + 'stream-as-utf8', + 'stream-as-is', +]); + +function isPlainObject(value) { + return value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype; +} + +function isNativeStream(value) { + return Boolean( + value && + typeof value === 'object' && + ( + Buffer.isBuffer(value) || + value instanceof Uint8Array || + typeof value.pipe === 'function' || + typeof value.getReader === 'function' || + typeof value.arrayBuffer === 'function' + ) + ); +} + +function cloneDataWithExtractedStreams(value, streams) { + if (isNativeStream(value)) { + const content = streams.length; + streams.push(value); + return { type: 'provided', content }; + } + + if (Array.isArray(value)) { + return value.map((item) => cloneDataWithExtractedStreams(item, streams)); + } + + if (isPlainObject(value)) { + const cloned = {}; + for (const [key, childValue] of Object.entries(value)) { + cloned[key] = cloneDataWithExtractedStreams(childValue, streams); + } + return cloned; + } + + return value; +} + +function prepareHydrationContext(data, streams = []) { + const preparedStreams = Array.isArray(streams) ? streams.slice() : []; + const preparedData = cloneDataWithExtractedStreams(data || {}, preparedStreams); + + validateStreamReferences(preparedData); + + return { data: preparedData, streams: preparedStreams }; +} + +function collectProvidedReferences(value, references = []) { + if (Array.isArray(value)) { + for (const item of value) collectProvidedReferences(item, references); + return references; + } + + if (!isPlainObject(value)) { + return references; + } + + if (value.type === 'provided') { + references.push({ + explicit: Object.prototype.hasOwnProperty.call(value, 'content'), + index: value.content == null ? 0 : value.content, + }); + } + + for (const childValue of Object.values(value)) { + collectProvidedReferences(childValue, references); + } + + return references; +} + +function validateStreamReferences(data) { + const references = collectProvidedReferences(data); + + if (references.length > 1 && references.some((reference) => !reference.explicit)) { + throw createNamedError( + 'TemplateSyntaxError', + 'Ambiguous provided stream reference: content index is required when multiple streams are referenced' + ); + } + + const seen = new Set(); + for (const reference of references) { + if (!Number.isInteger(reference.index) || reference.index < 0) { + throw createNamedError('TemplateSyntaxError', `Invalid provided stream index: ${reference.index}`); + } + + if (seen.has(reference.index)) { + throw createNamedError('TemplateSyntaxError', `Duplicate provided stream index: ${reference.index}`); + } + seen.add(reference.index); + } + + return true; +} + +async function resolveToString(input) { + if (typeof input === 'string') { + return input; + } + + if (Buffer.isBuffer(input) || input instanceof Uint8Array) { + return Buffer.from(input).toString('utf8'); + } + + if (input && typeof input.getReader === 'function') { + const reader = input.getReader(); + const chunks = []; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(Buffer.from(value)); + } + return Buffer.concat(chunks).toString('utf8'); + } + + if (input && typeof input[Symbol.asyncIterator] === 'function') { + const chunks = []; + for await (const chunk of input) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); + } + + return String(input); +} + +function formatJsonValue(value) { + const json = JSON.stringify(value); + + if (value && typeof value === 'object' && !Array.isArray(value)) { + return json.replace(/^\{/, '{ ').replace(/\}$/, ' }').replace(/:/g, ': ').replace(/,/g, ', '); + } + + return json; +} + +function resolveStreamValue(value, streams) { + if (value && typeof value === 'object' && value.type === 'provided') { + const streamIndex = value.content == null ? 0 : value.content; + const stream = streams[streamIndex]; + + if (stream == null) { + throw createNamedError('MissingArgumentError', `Missing provided stream at index ${streamIndex}`, { + missing: streamIndex, + }); + } + + return stream; + } + + return value; +} + +function materializeStream(value) { + if (Buffer.isBuffer(value)) { + return value; + } + + if (value instanceof Uint8Array) { + return Buffer.from(value); + } + + if (typeof value === 'string') { + return Buffer.from(value, 'utf8'); + } + + throw createNamedError('TemplateSyntaxError', 'Only string and Buffer stream values are supported synchronously'); +} + +function isHydrateFunctionName(value) { + if (!value) { + return false; + } + + for (const char of value) { + const code = char.charCodeAt(0); + const isDigit = code >= 48 && code <= 57; + const isUpper = code >= 65 && code <= 90; + const isLower = code >= 97 && code <= 122; + if (!isDigit && !isUpper && !isLower && char !== '-') { + return false; + } + } + + return true; +} + +function parseHydrateFunction(functionSource) { + const trimmed = functionSource.trim(); + const openParen = trimmed.indexOf('('); + const closeParen = trimmed.endsWith(')') ? trimmed.length - 1 : -1; + const hasArguments = openParen !== -1; + const name = hasArguments ? trimmed.slice(0, openParen).trim() : trimmed; + + if (!isHydrateFunctionName(name) || (hasArguments && closeParen === -1)) { + throw createNamedError('TemplateSyntaxError', `Invalid hydrate function: ${trimmed}`); + } + + const argumentSource = hasArguments ? trimmed.slice(openParen + 1, closeParen) : ''; + const args = !argumentSource.trim() + ? [] + : argumentSource.split(',').map((arg) => arg.trim()).filter(Boolean); + + if (!BUILT_IN_FUNCTIONS.has(name)) { + throw createNamedError('TemplateSyntaxError', `Unsupported hydrate function: ${name}`); + } + + return { name, args }; +} + +function applyHydrateFunction(value, hydrateFunction, streams) { + switch (hydrateFunction.name) { + case 'raw': + return String(value); + case 'url': + return encodeURIComponent(String(value)); + case 'json-value': + return formatJsonValue(value); + case 'json-string': + return JSON.stringify(String(value)).slice(1, -1); + case 'json-key': + return JSON.stringify(String(value)).slice(1, -1); + case 'stream-as-base64': + return materializeStream(resolveStreamValue(value, streams)).toString('base64'); + case 'stream-as-utf8': + return materializeStream(resolveStreamValue(value, streams)).toString('utf8'); + case 'stream-as-is': + return materializeStream(resolveStreamValue(value, streams)).toString('utf8'); + default: + throw createNamedError('TemplateSyntaxError', `Unsupported hydrate function: ${hydrateFunction.name}`); + } +} + +function parseHydrateTag(inner, data, streams) { + const parts = inner.split('|').map((part) => part.trim()).filter(Boolean); + if (parts.length < 2) { + throw createNamedError('TemplateSyntaxError', 'Template tag must include at least one function'); + } + + const dataKey = parts[0]; + if (!Object.prototype.hasOwnProperty.call(data, dataKey)) { + throw createNamedError('MissingArgumentError', `Missing data key: ${dataKey}`, { + missing: dataKey, + }); + } + + let replacement = data[dataKey]; + for (const transform of parts.slice(1).map(parseHydrateFunction)) { + replacement = applyHydrateFunction(replacement, transform, streams); + } + + return String(replacement); +} + +function createHydrationState(data, streams, sink) { + const prepared = prepareHydrationContext(data, streams); + data = prepared.data; + streams = prepared.streams; + + const map = []; + const headLines = []; + let newline = '\n'; + let readCursor = 0; + let writeCursor = 0; + let bodyStream = null; + let bodyStarted = false; + let headBuffer = ''; + let pendingOpenBraceAt = null; + let tagStart = null; + let sawClosingBrace = false; + let nestedOpenBraceAt = null; + let tagContent = ''; + let previousChar = ''; + + if (Array.isArray(data.headers)) { + for (const { name, value } of data.headers) { + headLines.push(`${name}: ${value}`); + } + } + + if (data.body) { + headLines.push(`:httpt-body-type: ${data.body.type || 'text'}`); + } + + function emit(value) { + if (value) { + sink.write(value); + } + } + + function boundaryAtEnd() { + if (headBuffer.endsWith('\r\n\r\n')) return '\r\n\r\n'; + if (headBuffer.endsWith('\n\n')) return '\n\n'; + return null; + } + + function emitSafeHead() { + while (headBuffer.length > 4) { + emit(headBuffer[0]); + headBuffer = headBuffer.slice(1); + } + } + + function write(value, checkBoundary = true) { + for (const char of String(value)) { + writeCursor += char.length; + + if (checkBoundary && !bodyStarted) { + headBuffer += char; + const boundary = boundaryAtEnd(); + if (boundary) { + bodyStarted = true; + emit(headBuffer.slice(0, -boundary.length)); + headBuffer = ''; + writeCursor -= boundary.length; + writeDynamicHead(); + write(boundary, false); + attachDynamicBody(); + } else { + emitSafeHead(); + } + } else { + emit(char); + } + } + } + + function trimHeadEnd() { + while (headBuffer.endsWith('\n')) { + headBuffer = headBuffer.slice(0, -1); + writeCursor -= 1; + if (headBuffer.endsWith('\r')) { + headBuffer = headBuffer.slice(0, -1); + writeCursor -= 1; + } + } + } + + function writeDynamicHead() { + if (headLines.length === 0) return; + write(newline, false); + write(headLines.join(newline), false); + } + + function attachDynamicBody() { + if (!data.body) return; + + const bodyType = data.body.type || 'text'; + if (bodyType === 'provided') { + const streamIndex = data.body.content == null ? 0 : data.body.content; + bodyStream = streams[streamIndex] || null; + return; + } + + if (data.body.content != null) { + write(String(data.body.content), false); + } + } + + function finishHead() { + trimHeadEnd(); + emit(headBuffer); + headBuffer = ''; + writeDynamicHead(); + + if (!data.body) { + write(newline, false); + return; + } + + write(`${newline}${newline}`, false); + attachDynamicBody(); + } + + function rejectDynamicBodyConflict(char) { + const isWhitespace = char === ' ' || char === '\t' || char === '\n' || char === '\r'; + if (data.body && !isWhitespace) { + const error = new Error('Template already contains a body'); + error.name = 'BodyConflictError'; + throw error; + } + } + + function closeTag(endIndex) { + const replacement = parseHydrateTag(tagContent, data, streams); + map.push({ + 'hydrated-start': writeCursor, + 'original-start': tagStart, + 'hydrated-length': replacement.length, + 'original-length': endIndex + 1 - tagStart, + }); + + write(replacement); + tagStart = null; + tagContent = ''; + sawClosingBrace = false; + } + + function readText(char, index) { + if (bodyStarted && data.body) { + rejectDynamicBodyConflict(char); + return; + } + + if (pendingOpenBraceAt != null) { + if (char === '{') { + tagStart = pendingOpenBraceAt; + pendingOpenBraceAt = null; + return; + } + + write('{'); + pendingOpenBraceAt = null; + readText(char, index); + return; + } + + if (char === '{') { + pendingOpenBraceAt = index; + return; + } + + write(char); + } + + function readTag(char, index) { + if (nestedOpenBraceAt != null) { + if (char === '{') { + throw createNamedError('TemplateSyntaxError', 'Nested template tags are not allowed', { index: nestedOpenBraceAt }); + } + tagContent += '{'; + nestedOpenBraceAt = null; + readTag(char, index); + return; + } + + if (sawClosingBrace) { + if (char === '}') { + closeTag(index); + return; + } + + tagContent += '}'; + sawClosingBrace = false; + readTag(char, index); + return; + } + + if (char === '{') { + nestedOpenBraceAt = index; + return; + } + + if (char === '}') { + sawClosingBrace = true; + return; + } + + tagContent += char; + } + + function feedChar(char) { + const index = readCursor; + readCursor += char.length; + + if (char === '\n' && previousChar === '\r') { + newline = '\r\n'; + } + + if (tagStart == null) { + readText(char, index); + } else { + readTag(char, index); + } + + previousChar = char; + } + + function finish() { + if (tagStart != null) { + throw createNamedError('TemplateSyntaxError', 'Unclosed template tag', { index: tagStart }); + } + + if (pendingOpenBraceAt != null) { + write('{'); + pendingOpenBraceAt = null; + } + + if (!bodyStarted) { + finishHead(); + } else if (headBuffer) { + emit(headBuffer); + headBuffer = ''; + } + + return { map, bodyStream }; + } + + return { feedChar, finish }; +} + +function hydrate(template, data, streams = []) { + const output = []; + const state = createHydrationState(data, streams, { + write(value) { + output.push(value); + }, + }); + + for (const char of String(template)) { + state.feedChar(char); + } + + const { map, bodyStream } = state.finish(); + return { resolved: output.join(''), map, bodyStream }; +} + +async function hydrateAsync(template, data, streams = []) { + if (typeof template === 'string' || Buffer.isBuffer(template) || template instanceof Uint8Array) { + return hydrate(await resolveToString(template), data, streams); + } + + const output = []; + const state = createHydrationState(data, streams, { + write(value) { + output.push(value); + }, + }); + + if (template && typeof template.getReader === 'function') { + const reader = template.getReader(); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + for (const char of Buffer.from(value).toString('utf8')) { + state.feedChar(char); + } + } + } else if (template && typeof template[Symbol.asyncIterator] === 'function') { + for await (const chunk of template) { + const text = Buffer.isBuffer(chunk) || chunk instanceof Uint8Array + ? Buffer.from(chunk).toString('utf8') + : String(chunk); + for (const char of text) { + state.feedChar(char); + } + } + } else { + return hydrate(await resolveToString(template), data, streams); + } + + const { map, bodyStream } = state.finish(); + return { resolved: output.join(''), map, bodyStream }; } function parse(resolved, optionalBodyStream = null) { - return { ir: {}, bodyStream: null }; + const normalizedResolved = String(resolved).replace(/\r\n/g, '\n'); + const boundaryMatch = normalizedResolved.match(/\n\n/); + const boundaryIndex = boundaryMatch ? boundaryMatch.index : normalizedResolved.length; + const head = normalizedResolved.slice(0, boundaryIndex); + const body = normalizedResolved.slice(boundaryIndex + (boundaryMatch ? boundaryMatch[0].length : 0)); + + const lines = head.split(/\n/).filter(Boolean); + const requestLine = lines.shift(); + if (!requestLine) { + throw createNamedError('TemplateSyntaxError', 'Invalid request: missing request line'); + } + + const [method, uri, version] = requestLine.trim().split(/\s+/); + if (!method || !uri || !version) { + throw createNamedError('TemplateSyntaxError', `Invalid request line: ${requestLine}`); + } + + const headers = []; + let host = ''; + let bodyType = 'text'; + + for (const line of lines) { + const colonIndex = line.startsWith(':') ? line.indexOf(':', 1) : line.indexOf(':'); + if (colonIndex === -1) { + const index = normalizedResolved.indexOf(line); + throw createNamedError('TemplateSyntaxError', `Invalid header line: ${line}`, { index }); + } + const name = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + + if (name.toLowerCase() === 'host') { + host = value; + } else if (name.toLowerCase() === ':httpt-body-type') { + bodyType = value || 'text'; + } else { + headers.push({ name, value }); + } + } + + const ir = { + 'schema-version': '1.0', + method, + host, + uri, + version, + headers, + }; + + const normalizedBody = body.replace(/\n+$/, ''); + + if (bodyType === 'provided') { + ir.body = { + type: 'provided', + content: 0, + }; + } else if (normalizedBody) { + let content = normalizedBody; + if (bodyType === 'json') { + try { + content = JSON.parse(normalizedBody); + } catch (error) { + throw createNamedError('TemplateSyntaxError', `Invalid JSON body: ${error.message}`, { + index: normalizedResolved.indexOf(normalizedBody), + }); + } + } else if (bodyType === 'base64') { + content = normalizedBody.trim(); + } + ir.body = { + type: bodyType, + content, + }; + } + + return { ir, bodyStream: optionalBodyStream }; +} + +async function parseAsync(resolved, optionalBodyStream = null) { + return parse(await resolveToString(resolved), optionalBodyStream); +} + +function scanTemplate(template) { + const source = String(template); + const tags = []; + let cursor = 0; + + while (cursor < source.length) { + const start = source.indexOf('{{', cursor); + if (start === -1) break; + + const end = source.indexOf('}}', start + 2); + if (end === -1) { + throw createNamedError('TemplateSyntaxError', 'Unclosed template tag', { index: start }); + } + + const inner = source.slice(start + 2, end); + if (inner.includes('{{')) { + throw createNamedError('TemplateSyntaxError', 'Nested template tags are not allowed', { index: start }); + } + + const parts = inner.split('|').map((part) => part.trim()).filter(Boolean); + if (parts.length < 2) { + throw createNamedError('TemplateSyntaxError', 'Template tag must include at least one function', { index: start }); + } + + const parameter = parts[0]; + const functions = parts.slice(1).map((part) => parseHydrateFunction(part)); + tags.push({ parameter, functions, start, end: end + 2 }); + cursor = end + 2; + } + + const strayClose = source.indexOf('}}', cursor); + if (strayClose !== -1) { + throw createNamedError('TemplateSyntaxError', 'Unexpected template close tag', { index: strayClose }); + } + + return tags; +} + +function verifyContract(template, expectedArguments = []) { + const tags = scanTemplate(template); + const used = new Set(tags.map((tag) => tag.parameter)); + const expected = new Set(expectedArguments); + + const missing = [...used].filter((key) => !expected.has(key)); + if (missing.length > 0) { + throw createNamedError('MissingArgumentError', `Template requires missing contract arguments: ${missing.join(', ')}`, { + missing, + }); + } + + const extra = [...expected].filter((key) => !used.has(key)); + if (extra.length > 0) { + throw createNamedError('UnexpectedArgumentError', `Contract includes unused arguments: ${extra.join(', ')}`, { + extra, + }); + } + + return true; } -module.exports = { hydrate, parse }; +module.exports = { + hydrate, + hydrateAsync, + parse, + parseAsync, + verifyContract, + validateStreamReferences, + prepareHydrationContext, + resolveToString, +}; diff --git a/js/packages/core/test/facade.test.js b/js/packages/core/test/facade.test.js new file mode 100644 index 0000000..88555c7 --- /dev/null +++ b/js/packages/core/test/facade.test.js @@ -0,0 +1,31 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const { Readable } = require('node:stream'); +const { build, hydrateAsync, parseAsync } = require('../index.js'); + +describe('SDK facade polymorphic inputs', () => { + it('should build from a Node readable template stream', async () => { + const template = Readable.from([ + 'GET /users/{{ user-id | url }} HTTP/1.1\n', + 'Host: example.com\n', + ]); + + const { ir } = await build(template, { 'user-id': 'a b' }); + + assert.deepEqual(ir, { + 'schema-version': '1.0', + method: 'GET', + host: 'example.com', + uri: '/users/a%20b', + version: 'HTTP/1.1', + headers: [], + }); + }); + + it('should hydrate and parse asynchronously from byte inputs', async () => { + const { resolved } = await hydrateAsync(Buffer.from('GET / HTTP/1.1\nHost: example.com\n'), {}); + const { ir } = await parseAsync(Buffer.from(resolved)); + + assert.equal(ir.host, 'example.com'); + }); +}); diff --git a/js/packages/core/test/pipeline.test.js b/js/packages/core/test/pipeline.test.js index 1ae97d3..f72d32b 100644 --- a/js/packages/core/test/pipeline.test.js +++ b/js/packages/core/test/pipeline.test.js @@ -1,18 +1,22 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); -const { hydrate, parse } = require('../src/pipeline.js'); +const { hydrate, hydrateAsync, parse } = require('../src/pipeline.js'); const { loadE2eFixtures } = require('@httpt/test-utils'); +function normalizeLineEndings(value) { + return String(value).replace(/\r\n/g, '\n'); +} + describe('Pipeline: Hydrate & Parse', () => { const fixtures = loadE2eFixtures(); for (const fixture of fixtures) { const nameToLog = fixture.irFile || fixture.baseName; if (fixture.error) { - it(`should throw ${fixture.error.name} for ${nameToLog}`, { todo: true }, async () => { + it(`should throw ${fixture.error.name} for ${nameToLog}`, async () => { await assert.rejects( async () => { - const { resolved, bodyStream } = await hydrate(fixture.template, fixture.data); + const { resolved, bodyStream } = await hydrate(fixture.template, fixture.data, fixture.dataStreams); parse(resolved, bodyStream); }, (err) => err.name === fixture.error.name @@ -20,10 +24,70 @@ describe('Pipeline: Hydrate & Parse', () => { }); continue; } - it(`should process ${nameToLog} correctly`, { todo: true }, async () => { - const { resolved, bodyStream } = await hydrate(fixture.template, fixture.data); + it(`should process ${nameToLog} correctly`, async () => { + const { resolved, map, bodyStream } = await hydrate(fixture.template, fixture.data, fixture.dataStreams); + assert.equal(normalizeLineEndings(resolved), normalizeLineEndings(fixture.resolved)); + assert.deepEqual(map, fixture.map); + const { ir } = parse(resolved, bodyStream); assert.deepEqual(ir, fixture.ir); }); } + + it('should apply chained hydrate functions from left to right', () => { + const template = 'GET /search?q={{ term | json-string | url }} HTTP/1.1\r\nHost: example.com\r\n'; + const { resolved } = hydrate(template, { term: 'a "quoted" value' }); + + assert.equal( + resolved, + 'GET /search?q=a%20%5C%22quoted%5C%22%20value HTTP/1.1\r\nHost: example.com\r\n' + ); + }); + + it('should hand off dynamic provided bodies without serializing the stream index', () => { + const stream = Buffer.from('stream body'); + const template = 'POST /upload HTTP/1.1\r\nHost: example.com\r\n'; + const { resolved, bodyStream } = hydrate(template, { body: { type: 'provided', content: 0 } }, [stream]); + const { ir, bodyStream: parsedBodyStream } = parse(resolved, bodyStream); + + assert.equal(resolved.endsWith('\r\n:httpt-body-type: provided\r\n\r\n'), true); + assert.strictEqual(parsedBodyStream, stream); + assert.deepEqual(ir.body, { type: 'provided', content: 0 }); + }); + + it('should hydrate streamed template input across chunk boundaries', async () => { + async function* chunks() { + const source = 'GET /{{ path | url }} HTTP/1.1\r\nHost: {{ host | raw }}\r\n'; + for (const char of source) { + yield Buffer.from(char); + } + } + + const { resolved, map } = await hydrateAsync(chunks(), { path: 'a b', host: 'example.com' }); + + assert.equal(resolved, 'GET /a%20b HTTP/1.1\r\nHost: example.com\r\n'); + assert.deepEqual(map, [ + { + 'hydrated-start': 5, + 'original-start': 5, + 'hydrated-length': 5, + 'original-length': 16, + }, + { + 'hydrated-start': 27, + 'original-start': 38, + 'hydrated-length': 11, + 'original-length': 16, + }, + ]); + }); + + it('should reject template body content before resolving body placeholders when data.body is provided', () => { + const template = 'POST /upload HTTP/1.1\nHost: example.com\n\n{{ missing | raw }}'; + + assert.throws( + () => hydrate(template, { body: { type: 'text', content: 'dynamic body' } }), + (error) => error.name === 'BodyConflictError' + ); + }); }); diff --git a/js/packages/core/test/verification.test.js b/js/packages/core/test/verification.test.js new file mode 100644 index 0000000..ccdb5eb --- /dev/null +++ b/js/packages/core/test/verification.test.js @@ -0,0 +1,81 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const { + hydrate, + verifyContract, + validateStreamReferences, + prepareHydrationContext, +} = require('../src/pipeline.js'); + +describe('Static contract verification', () => { + it('should return true when syntax and expected arguments match', () => { + const template = [ + 'GET /users/{{ user-id | url }} HTTP/1.1', + 'Host: api.example.com', + 'Authorization: Bearer {{ auth-token | raw }}', + '', + ].join('\n'); + + assert.equal(verifyContract(template, ['user-id', 'auth-token']), true); + }); + + it('should reject missing contract arguments', () => { + assert.throws( + () => verifyContract('GET /{{ user-id | url }} HTTP/1.1\n', []), + (error) => error.name === 'MissingArgumentError' && error.missing.includes('user-id') + ); + }); + + it('should reject extra contract arguments', () => { + assert.throws( + () => verifyContract('GET /{{ user-id | url }} HTTP/1.1\n', ['user-id', 'unused']), + (error) => error.name === 'UnexpectedArgumentError' && error.extra.includes('unused') + ); + }); + + it('should reject malformed template tags and unsupported functions', () => { + assert.throws( + () => verifyContract('GET /{{ user-id | mystery }} HTTP/1.1\n', ['user-id']), + (error) => error.name === 'TemplateSyntaxError' + ); + + assert.throws( + () => verifyContract('GET /{{ user-id | url HTTP/1.1\n', ['user-id']), + (error) => error.name === 'TemplateSyntaxError' && Number.isInteger(error.index) + ); + }); +}); + +describe('Stream reference validation', () => { + it('should reject ambiguous implicit provided stream references', () => { + assert.throws( + () => validateStreamReferences({ + first: { type: 'provided' }, + second: { type: 'provided', content: 1 }, + }), + (error) => error.name === 'TemplateSyntaxError' + ); + }); + + it('should reject duplicate provided stream indexes', () => { + assert.throws( + () => validateStreamReferences({ + first: { type: 'provided', content: 0 }, + second: { type: 'provided', content: 0 }, + }), + (error) => error.name === 'TemplateSyntaxError' + ); + }); + + it('should extract native streams from data and make them addressable as provided references', () => { + const stream = Buffer.from('stream-value'); + const { data, streams } = prepareHydrationContext({ upload: stream }); + + assert.deepEqual(data.upload, { type: 'provided', content: 0 }); + assert.strictEqual(streams[0], stream); + + const template = 'POST /upload HTTP/1.1\nHost: example.com\n\n{{ upload | stream-as-utf8 }}'; + const { resolved } = hydrate(template, { upload: stream }); + assert.equal(resolved, 'POST /upload HTTP/1.1\nHost: example.com\n\nstream-value'); + }); +}); diff --git a/js/packages/test-utils/index.js b/js/packages/test-utils/index.js index 40e436f..e595a8a 100644 --- a/js/packages/test-utils/index.js +++ b/js/packages/test-utils/index.js @@ -7,6 +7,10 @@ const E2E_DIR = path.resolve(__dirname, '../../../test-fixtures/e2e'); const IGNORED_HEADERS = ['host', 'connection', 'accept', 'accept-language', 'sec-fetch-mode', 'user-agent', 'accept-encoding', 'content-length', 'content-type', 'transfer-encoding']; +function normalizeFixtureText(value) { + return String(value).replace(/\r\n/g, '\n'); +} + /** * Starts an HTTP echo server on a random port. * Returns a server object with a method `getCapturedRequest()` that waits for a request, @@ -103,6 +107,20 @@ function getFixtureBaseNames() { return files.filter(f => f.endsWith('.httpt')).map(f => f.replace('.httpt', '')); } +function loadReferencedStreams(referenceFileName) { + const streams = []; + + for (let index = 0; ; index += 1) { + const streamPath = path.join(E2E_DIR, `${referenceFileName}-provided-stream-${index}`); + if (!fs.existsSync(streamPath)) { + break; + } + streams.push(fs.readFileSync(streamPath)); + } + + return streams; +} + function loadE2eFixtures() { const baseNames = getFixtureBaseNames(); const fixtures = []; @@ -110,7 +128,7 @@ function loadE2eFixtures() { for (const baseName of baseNames) { let template = ''; try { - template = fs.readFileSync(path.join(E2E_DIR, `${baseName}.httpt`), 'utf8'); + template = normalizeFixtureText(fs.readFileSync(path.join(E2E_DIR, `${baseName}.httpt`), 'utf8')); } catch (e) { // default to '' } @@ -130,6 +148,20 @@ function loadE2eFixtures() { irFile = null; } + let resolved = null; + try { + resolved = normalizeFixtureText(fs.readFileSync(path.join(E2E_DIR, `${baseName}.httpt-r`), 'utf8')); + } catch (e) { + // default to null + } + + let map = null; + try { + map = JSON.parse(fs.readFileSync(path.join(E2E_DIR, `${baseName}.httpt-map`), 'utf8')); + } catch (e) { + // default to null + } + let error = null; try { error = JSON.parse(fs.readFileSync(path.join(E2E_DIR, `${baseName}.error.json`), 'utf8')); @@ -139,6 +171,8 @@ function loadE2eFixtures() { let streamContent = null; let streamFilePath = null; + const dataFile = `${baseName}.data.json`; + const dataStreams = loadReferencedStreams(dataFile); if (ir && ir.body && ir.body.type === 'provided') { const streamIndex = ir.body.content !== undefined ? ir.body.content : 0; @@ -151,13 +185,38 @@ function loadE2eFixtures() { } } - fixtures.push({ baseName, irFile, ir, error, streamContent, streamFilePath, template, data }); + fixtures.push({ + baseName, + irFile, + ir, + resolved, + map, + error, + streamContent, + streamFilePath, + dataStreams, + template, + data, + }); } return fixtures; } function normalizeForEchoServer(expectedIR, serverIR, adapterName) { + if (expectedIR.uri) { + try { + const parsedUri = new URL(expectedIR.uri); + expectedIR.uri = `${parsedUri.pathname}${parsedUri.search}`; + } catch (e) { + // origin-form URI; keep as-is + } + } + + if (expectedIR.version === 'HTTP/2' || expectedIR.version === 'HTTP/2.0' || expectedIR.version === 'HTTP/3') { + expectedIR.version = serverIR.version; + } + if (expectedIR.headers) { expectedIR.headers = expectedIR.headers.map(h => ({ name: h.name.toLowerCase(), value: h.value })); expectedIR.headers = expectedIR.headers.filter(h => !IGNORED_HEADERS.includes(h.name)); @@ -184,4 +243,4 @@ function normalizeForEchoServer(expectedIR, serverIR, adapterName) { } } -module.exports = { E2E_DIR, createEchoServer, binarizeIr, loadE2eFixtures, normalizeForEchoServer, getFixtureBaseNames }; +module.exports = { E2E_DIR, createEchoServer, binarizeIr, loadE2eFixtures, normalizeForEchoServer, getFixtureBaseNames, loadReferencedStreams }; diff --git a/test-fixtures/e2e/001-simple-get.httpt-map b/test-fixtures/e2e/001-simple-get.httpt-map index dcf0b74..63f3380 100644 --- a/test-fixtures/e2e/001-simple-get.httpt-map +++ b/test-fixtures/e2e/001-simple-get.httpt-map @@ -1,5 +1,5 @@ [ - { "hydrated-start": 20, "original-start": 20, "hydrated-length": 4, "original-length": 23 }, - { "hydrated-start": 40, "original-start": 58, "hydrated-length": 15, "original-length": 19 }, - { "hydrated-start": 78, "original-start": 106, "hydrated-length": 8, "original-length": 21 } + { "hydrated-start": 21, "original-start": 21, "hydrated-length": 4, "original-length": 23 }, + { "hydrated-start": 41, "original-start": 60, "hydrated-length": 15, "original-length": 20 }, + { "hydrated-start": 79, "original-start": 103, "hydrated-length": 8, "original-length": 22 } ] diff --git a/test-fixtures/e2e/002-post-json.httpt-map b/test-fixtures/e2e/002-post-json.httpt-map index 88c5bab..f913421 100644 --- a/test-fixtures/e2e/002-post-json.httpt-map +++ b/test-fixtures/e2e/002-post-json.httpt-map @@ -1,7 +1,7 @@ [ - { "hydrated-start": 33, "original-start": 33, "hydrated-length": 25, "original-length": 20 }, - { "hydrated-start": 81, "original-start": 76, "hydrated-length": 9, "original-length": 17 }, - { "hydrated-start": 125, "original-start": 125, "hydrated-length": 16, "original-length": 22 }, - { "hydrated-start": 145, "original-start": 151, "hydrated-length": 45, "original-length": 22 }, - { "hydrated-start": 204, "original-start": 187, "hydrated-length": 12, "original-length": 28 } + { "hydrated-start": 37, "original-start": 37, "hydrated-length": 20, "original-length": 20 }, + { "hydrated-start": 80, "original-start": 80, "hydrated-length": 9, "original-length": 17 }, + { "hydrated-start": 127, "original-start": 135, "hydrated-length": 16, "original-length": 22 }, + { "hydrated-start": 146, "original-start": 160, "hydrated-length": 43, "original-length": 24 }, + { "hydrated-start": 202, "original-start": 197, "hydrated-length": 12, "original-length": 29 } ] diff --git a/test-fixtures/e2e/010-dynamic-body.httpt-r b/test-fixtures/e2e/010-dynamic-body.httpt-r index c683c5d..e59338d 100644 --- a/test-fixtures/e2e/010-dynamic-body.httpt-r +++ b/test-fixtures/e2e/010-dynamic-body.httpt-r @@ -2,3 +2,4 @@ POST /api/upload HTTP/1.1 Host: api.example.com Content-Type: text/plain :httpt-body-type: provided + diff --git a/test-fixtures/e2e/011-identity-template.httpt-map b/test-fixtures/e2e/011-identity-template.httpt-map index fe51488..f1f9d21 100644 --- a/test-fixtures/e2e/011-identity-template.httpt-map +++ b/test-fixtures/e2e/011-identity-template.httpt-map @@ -1 +1,6 @@ -[] +[ + { "hydrated-start": 0, "original-start": 0, "hydrated-length": 5, "original-length": 18 }, + { "hydrated-start": 6, "original-start": 19, "hydrated-length": 13, "original-length": 15 }, + { "hydrated-start": 20, "original-start": 35, "hydrated-length": 8, "original-length": 19 }, + { "hydrated-start": 35, "original-start": 61, "hydrated-length": 20, "original-length": 16 } +] diff --git a/test-fixtures/e2e/011-identity-template.httpt-r b/test-fixtures/e2e/011-identity-template.httpt-r index 9d95729..8627a0f 100644 --- a/test-fixtures/e2e/011-identity-template.httpt-r +++ b/test-fixtures/e2e/011-identity-template.httpt-r @@ -2,3 +2,4 @@ PATCH /api/identity HTTP/1.1 Host: identity.example.com Content-Type: application/json :httpt-body-type: provided + diff --git a/test-fixtures/e2e/014-body-junk.httpt-r b/test-fixtures/e2e/014-body-junk.httpt-r index 05002a9..dea061a 100644 --- a/test-fixtures/e2e/014-body-junk.httpt-r +++ b/test-fixtures/e2e/014-body-junk.httpt-r @@ -1,6 +1,5 @@ POST /api/junk HTTP/1.1 Host: example.com -:httpt-body-type: text This body has a double-newline right here: diff --git a/test-fixtures/e2e/017-http-2-protocol.data.json b/test-fixtures/e2e/017-http-2-protocol.data.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/test-fixtures/e2e/017-http-2-protocol.data.json @@ -0,0 +1 @@ +{} diff --git a/test-fixtures/e2e/017-http-2-protocol.httpt b/test-fixtures/e2e/017-http-2-protocol.httpt new file mode 100644 index 0000000..6c86b59 --- /dev/null +++ b/test-fixtures/e2e/017-http-2-protocol.httpt @@ -0,0 +1,3 @@ +GET /h2/resource HTTP/2 +Host: example.com + diff --git a/test-fixtures/e2e/017-http-2-protocol.httpt-ir b/test-fixtures/e2e/017-http-2-protocol.httpt-ir new file mode 100644 index 0000000..cbce30b --- /dev/null +++ b/test-fixtures/e2e/017-http-2-protocol.httpt-ir @@ -0,0 +1,8 @@ +{ + "schema-version": "1.0", + "method": "GET", + "host": "example.com", + "uri": "/h2/resource", + "version": "HTTP/2", + "headers": [] +} diff --git a/test-fixtures/e2e/017-http-2-protocol.httpt-map b/test-fixtures/e2e/017-http-2-protocol.httpt-map new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/test-fixtures/e2e/017-http-2-protocol.httpt-map @@ -0,0 +1 @@ +[] diff --git a/test-fixtures/e2e/017-http-2-protocol.httpt-r b/test-fixtures/e2e/017-http-2-protocol.httpt-r new file mode 100644 index 0000000..6c86b59 --- /dev/null +++ b/test-fixtures/e2e/017-http-2-protocol.httpt-r @@ -0,0 +1,3 @@ +GET /h2/resource HTTP/2 +Host: example.com + diff --git a/test-fixtures/e2e/018-bizarre-spacing.data.json b/test-fixtures/e2e/018-bizarre-spacing.data.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/test-fixtures/e2e/018-bizarre-spacing.data.json @@ -0,0 +1 @@ +{} diff --git a/test-fixtures/e2e/018-bizarre-spacing.httpt b/test-fixtures/e2e/018-bizarre-spacing.httpt new file mode 100644 index 0000000..b1128aa --- /dev/null +++ b/test-fixtures/e2e/018-bizarre-spacing.httpt @@ -0,0 +1,3 @@ +GET /spaced?q=ok HTTP/1.1 +Host: example.com + diff --git a/test-fixtures/e2e/018-bizarre-spacing.httpt-ir b/test-fixtures/e2e/018-bizarre-spacing.httpt-ir new file mode 100644 index 0000000..a6d76a9 --- /dev/null +++ b/test-fixtures/e2e/018-bizarre-spacing.httpt-ir @@ -0,0 +1,8 @@ +{ + "schema-version": "1.0", + "method": "GET", + "host": "example.com", + "uri": "/spaced?q=ok", + "version": "HTTP/1.1", + "headers": [] +} diff --git a/test-fixtures/e2e/018-bizarre-spacing.httpt-map b/test-fixtures/e2e/018-bizarre-spacing.httpt-map new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/test-fixtures/e2e/018-bizarre-spacing.httpt-map @@ -0,0 +1 @@ +[] diff --git a/test-fixtures/e2e/018-bizarre-spacing.httpt-r b/test-fixtures/e2e/018-bizarre-spacing.httpt-r new file mode 100644 index 0000000..b1128aa --- /dev/null +++ b/test-fixtures/e2e/018-bizarre-spacing.httpt-r @@ -0,0 +1,3 @@ +GET /spaced?q=ok HTTP/1.1 +Host: example.com + diff --git a/test-fixtures/e2e/019-absolute-uri.data.json b/test-fixtures/e2e/019-absolute-uri.data.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/test-fixtures/e2e/019-absolute-uri.data.json @@ -0,0 +1 @@ +{} diff --git a/test-fixtures/e2e/019-absolute-uri.httpt b/test-fixtures/e2e/019-absolute-uri.httpt new file mode 100644 index 0000000..51ed6db --- /dev/null +++ b/test-fixtures/e2e/019-absolute-uri.httpt @@ -0,0 +1,4 @@ +GET https://api.example.com/v1/resource?q=1 HTTP/1.1 +Host: api.example.com +Accept: application/json + diff --git a/test-fixtures/e2e/019-absolute-uri.httpt-ir b/test-fixtures/e2e/019-absolute-uri.httpt-ir new file mode 100644 index 0000000..bffdd58 --- /dev/null +++ b/test-fixtures/e2e/019-absolute-uri.httpt-ir @@ -0,0 +1,10 @@ +{ + "schema-version": "1.0", + "method": "GET", + "host": "api.example.com", + "uri": "https://api.example.com/v1/resource?q=1", + "version": "HTTP/1.1", + "headers": [ + { "name": "Accept", "value": "application/json" } + ] +} diff --git a/test-fixtures/e2e/019-absolute-uri.httpt-map b/test-fixtures/e2e/019-absolute-uri.httpt-map new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/test-fixtures/e2e/019-absolute-uri.httpt-map @@ -0,0 +1 @@ +[] diff --git a/test-fixtures/e2e/019-absolute-uri.httpt-r b/test-fixtures/e2e/019-absolute-uri.httpt-r new file mode 100644 index 0000000..51ed6db --- /dev/null +++ b/test-fixtures/e2e/019-absolute-uri.httpt-r @@ -0,0 +1,4 @@ +GET https://api.example.com/v1/resource?q=1 HTTP/1.1 +Host: api.example.com +Accept: application/json + diff --git a/test-fixtures/e2e/020-malformed-json.data.json b/test-fixtures/e2e/020-malformed-json.data.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/test-fixtures/e2e/020-malformed-json.data.json @@ -0,0 +1 @@ +{} diff --git a/test-fixtures/e2e/020-malformed-json.error.json b/test-fixtures/e2e/020-malformed-json.error.json new file mode 100644 index 0000000..d837f47 --- /dev/null +++ b/test-fixtures/e2e/020-malformed-json.error.json @@ -0,0 +1,3 @@ +{ + "name": "TemplateSyntaxError" +} diff --git a/test-fixtures/e2e/020-malformed-json.httpt b/test-fixtures/e2e/020-malformed-json.httpt new file mode 100644 index 0000000..d517638 --- /dev/null +++ b/test-fixtures/e2e/020-malformed-json.httpt @@ -0,0 +1,5 @@ +POST /bad-json HTTP/1.1 +Host: example.com +:httpt-body-type: json + +{"broken": true diff --git a/test-fixtures/e2e/TEST_MATRIX.md b/test-fixtures/e2e/TEST_MATRIX.md index 5d5622a..658997a 100644 --- a/test-fixtures/e2e/TEST_MATRIX.md +++ b/test-fixtures/e2e/TEST_MATRIX.md @@ -4,26 +4,26 @@ This ledger maps our test fixtures to the 7 testing dimensions defined in the `R | Fixture ID | Target Dimension | Description | Status | | :--- | :--- | :--- | :--- | -| `001-simple-get` | 5. Source Mapping | Basic GET with URL and header injection. | ✅ Implemented | -| `002-post-json` | 4. JSON Nuances | Complex JSON payload with nested structures. | ✅ Implemented | -| `003-post-text-explicit` | 1. Body Type | Explicit `:httpt-body-type: text`. | ✅ Implemented | -| `004-post-json-native` | 1. Body Type | Explicit `:httpt-body-type: json` parsing. | ✅ Implemented | -| `005-post-base64` | 1. Body Type | Explicit `:httpt-body-type: base64` decoding. | ✅ Implemented | -| `006-post-provided` | 1. Body Type | Explicit `:httpt-body-type: provided` streaming. | ✅ Implemented | -| `007-conflict-provided-body` | 1. Body Type | Verifying shadowed body text is ignored. | ✅ Implemented | -| `008-boundary-unix-lf` | 2. Boundary | Parsing with `\n\n` instead of CRLF. | ✅ Implemented | +| `001-simple-get` | 5. Source Mapping | Basic GET with URL and header injection. | Implemented | +| `002-post-json` | 4. JSON Nuances | Complex JSON payload with nested structures. | Implemented | +| `003-post-text-explicit` | 1. Body Type | Explicit `:httpt-body-type: text`. | Implemented | +| `004-post-json-native` | 1. Body Type | Explicit `:httpt-body-type: json` parsing. | Implemented | +| `005-post-base64` | 1. Body Type | Explicit `:httpt-body-type: base64` decoding. | Implemented | +| `006-post-provided` | 1. Body Type | Explicit `:httpt-body-type: provided` streaming. | Implemented | +| `007-conflict-provided-body` | 1. Body Type | Verifying shadowed body text is ignored. | Implemented | +| `008-boundary-unix-lf` | 2. Boundary | Parsing with `\n\n` instead of CRLF. | Implemented | | **Phase 1: Dynamic Injection & Identity** | | | | -| `009-dynamic-headers` | 7. Dynamic Injection | Injecting `data.headers` before the boundary. | ✅ Implemented | -| `010-dynamic-body` | 7. Dynamic Injection | Injecting `data.body` stream and pseudo-header. | ✅ Implemented | -| `011-identity-template` | 7. Dynamic Injection | 2-line template parsing a full IR context. | ✅ Implemented | -| `012-error-body-conflict` | 7. Dynamic Injection | `BodyConflictError` when template has a body. | ✅ Implemented | +| `009-dynamic-headers` | 7. Dynamic Injection | Injecting `data.headers` before the boundary. | Implemented | +| `010-dynamic-body` | 7. Dynamic Injection | Injecting `data.body` stream and pseudo-header. | Implemented | +| `011-identity-template` | 7. Dynamic Injection | 2-line template parsing a full IR context. | Implemented | +| `012-error-body-conflict` | 7. Dynamic Injection | `BodyConflictError` when template has a body. | Implemented | | **Phase 2: Boundaries & Header Edge Cases** | | | | -| `013-minimalist-boundary` | 2. Boundary | Request line immediately followed by double-newline. | ✅ Implemented | -| `014-body-junk` | 2. Boundary | Body containing its own double-newlines. | ✅ Implemented | -| `015-multi-headers` | 3. Header Hygiene | Preserving duplicate headers (e.g., `Set-Cookie`). | ✅ Implemented | -| `016-empty-header` | 3. Header Hygiene | Headers with keys but no values. | ✅ Implemented | +| `013-minimalist-boundary` | 2. Boundary | Request line immediately followed by double-newline. | Implemented | +| `014-body-junk` | 2. Boundary | Body containing its own double-newlines. | Implemented | +| `015-multi-headers` | 3. Header Hygiene | Preserving duplicate headers (e.g., `Set-Cookie`). | Implemented | +| `016-empty-header` | 3. Header Hygiene | Headers with keys but no values. | Implemented | | **Phase 3: Protocol & JSON Quirks** | | | | -| `017-http-2-protocol` | 6. Protocol Edge Cases | Request line parsing with `HTTP/2`. | 🚧 Planned | -| `018-bizarre-spacing` | 6. Protocol Edge Cases | Handling extra spaces in the request line. | 🚧 Planned | -| `019-absolute-uri` | 6. Protocol Edge Cases | Handling absolute URIs in the request line. | 🚧 Planned | -| `020-malformed-json` | 4. JSON Nuances | Fallback/error handling for invalid JSON bodies. | 🚧 Planned | +| `017-http-2-protocol` | 6. Protocol Edge Cases | Request line parsing with `HTTP/2`. | Implemented | +| `018-bizarre-spacing` | 6. Protocol Edge Cases | Handling extra spaces in the request line. | Implemented | +| `019-absolute-uri` | 6. Protocol Edge Cases | Handling absolute URIs in the request line. | Implemented | +| `020-malformed-json` | 4. JSON Nuances | Error handling for invalid JSON bodies. | Implemented |