From 288cc349fc1f5f0eb092d6ae482065774fdc7673 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 30 Jul 2026 13:30:32 -0400 Subject: [PATCH 1/3] feat: add inputTransform hook to CommandConfig --- src/es/request-builder.ts | 4 ++++ src/factory-core.ts | 2 ++ src/factory.ts | 3 +++ 3 files changed, 9 insertions(+) diff --git a/src/es/request-builder.ts b/src/es/request-builder.ts index 07f0e410..3852b0df 100644 --- a/src/es/request-builder.ts +++ b/src/es/request-builder.ts @@ -142,6 +142,10 @@ const BODY_ROOT_FIELDS: Record | '*'> = { pipeline: new Set(['/_logstash/pipeline/{id}']) } +export const BODY_ROOT_STAR_FIELDS = new Set( + Object.entries(BODY_ROOT_FIELDS).filter(([, v]) => v === '*').map(([k]) => k) +) + /** * Collects request body fields from entries with `foundIn === "body"` or no `foundIn`. * Returns `undefined` when no body fields are present in the input. diff --git a/src/factory-core.ts b/src/factory-core.ts index 0232bbd2..19740910 100644 --- a/src/factory-core.ts +++ b/src/factory-core.ts @@ -78,6 +78,8 @@ export interface CommandConfig { * empty, --input-file and --dry-run are hidden from help (#378). */ readOnly?: boolean + /** Applied to JSON input (stdin or --input-file) before schema validation. */ + inputTransform?: (input: unknown) => unknown } /** Configuration for a command group (namespace). */ diff --git a/src/factory.ts b/src/factory.ts index ea728c22..c04ee9e3 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -459,6 +459,9 @@ export function defineCommand (config: CommandConfig): O inputValue = parseJsonContent(raw, 'stdin', cmd) } } + if (config.inputTransform != null && inputValue !== undefined) { + inputValue = config.inputTransform(inputValue) + } // collect explicitly-provided schema-derived CLI arguments and merge over JSON input const cliInput: Record = {} From 59b3da11b1c1e346d774e64159f665e14db0195e Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 30 Jul 2026 13:30:36 -0400 Subject: [PATCH 2/3] fix: wrap REST-style stdin body under document for index/create --- src/es/register.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/es/register.ts b/src/es/register.ts index 9fb7be88..1db67ac0 100644 --- a/src/es/register.ts +++ b/src/es/register.ts @@ -14,6 +14,7 @@ import type { EsApiDefinition } from './types.ts' import type { SchemaArgDefinition } from '../lib/schema-args.ts' import { apiManifest } from './api-manifest.ts' import type { EsApiMeta } from './api-manifest.ts' +import { BODY_ROOT_STAR_FIELDS } from './request-builder.ts' // Lazy-loaded modules (deferred to keep `es --help` fast) const _reqEs = createRequire(import.meta.url) @@ -89,6 +90,17 @@ function buildLeafHandle ( if (def.responseType === 'text') { config.formatOutput = (result) => String(result) } + const bodyRootArg = schemaArgs.find( + (a) => (a.foundIn === 'body' || a.foundIn === undefined) && a.required && BODY_ROOT_STAR_FIELDS.has(a.schemaKey) + ) + if (bodyRootArg != null) { + const rootKey = bodyRootArg.schemaKey + config.inputTransform = (input: unknown) => { + if (input == null || typeof input !== 'object' || Array.isArray(input)) return input + if (rootKey in (input as Record)) return input + return { [rootKey]: input } + } + } return defineCommand(config) } From 7a9209c138a6a61fde2125cdf650702260b204e1 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 30 Jul 2026 13:30:38 -0400 Subject: [PATCH 3/3] test: inputTransform and REST-style body wrapping --- test/es/register.test.ts | 52 ++++++++++++++++++++++++++++++++++++- test/factory.test.ts | 56 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/test/es/register.test.ts b/test/es/register.test.ts index 6f139f97..b4781afa 100644 --- a/test/es/register.test.ts +++ b/test/es/register.test.ts @@ -3,11 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it } from 'node:test' +import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' import { z } from 'zod' import type { EsApiDefinition } from '../../src/es/types.ts' import { registerEsCommands, registerEsCommandsLazy } from '../../src/es/register.ts' +import { _testSetStdinReader } from '../../src/factory.ts' function makeDef(name: string, namespace: string, description = `${name} description`): EsApiDefinition { return { name, namespace, description, method: 'GET', path: `/_${namespace}/${name}` } @@ -509,3 +510,52 @@ describe('registerEsCommands - responseType and intent', () => { assert.ok(cmd != null, 'command should be registered with intent') }) }) + +describe('registerEsCommands - REST-style body (#360)', () => { + let origIsTTY: boolean | undefined + beforeEach(() => { + origIsTTY = process.stdin.isTTY + Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true, writable: true }) + }) + afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true, writable: true }) + }) + + const indexDef: EsApiDefinition = { + name: 'index', + description: 'Index a document', + method: 'PUT', + path: '/{index}/_doc/{id}', + input: z.object({ + index: z.string().meta({ found_in: 'path' }), + id: z.string().meta({ found_in: 'path' }), + document: z.any().meta({ found_in: 'body' }), + }), + } + + async function getErr(defs: EsApiDefinition[], stdinJson: string, argv: string[]): Promise { + const handle = await registerEsCommands(defs) + const cmd = handle.commands.find((c) => c.name() === defs[0]!.name) + assert.ok(cmd != null) + cmd.exitOverride() + let err = '' + cmd.configureOutput({ writeErr: (s) => { err += s } }) + const restore = _testSetStdinReader(() => stdinJson) + try { + await cmd.parseAsync(argv, { from: 'user' }) + } catch { /* exitOverride throws, swallow */ } finally { + restore() + } + return err + } + + it('REST-style body does not cause input validation error', async () => { + const err = await getErr([indexDef], JSON.stringify({ name: 'test' }), ['--index', 'my-index', '--id', '1', '--dry-run']) + assert.ok(!err.includes('input validation failed'), `unexpected validation error: ${err}`) + }) + + it('wrapped body passes validation unchanged', async () => { + const err = await getErr([indexDef], JSON.stringify({ document: { name: 'test' } }), ['--index', 'my-index', '--id', '1', '--dry-run']) + assert.ok(!err.includes('input validation failed'), `unexpected validation error: ${err}`) + }) +}) diff --git a/test/factory.test.ts b/test/factory.test.ts index 1762ccaf..0a57162d 100644 --- a/test/factory.test.ts +++ b/test/factory.test.ts @@ -1279,6 +1279,62 @@ describe('defineCommand', () => { }) }) + describe('inputTransform', () => { + let origIsTTY: boolean | undefined + beforeEach(() => { + origIsTTY = process.stdin.isTTY + Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true, writable: true }) + }) + afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true, writable: true }) + }) + + it('wraps stdin input before validation when transform is provided', async () => { + const restore = _testSetStdinReader(() => JSON.stringify({ name: 'test' })) + try { + const received: ParsedResult[] = [] + const cmd = defineCommand({ + name: 'index', + description: 'Index a document', + input: z.object({ document: z.any() }), + inputTransform: (input) => { + if (input != null && typeof input === 'object' && !Array.isArray(input) && !('document' in (input as object))) { + return { document: input } + } + return input + }, + handler: (parsed) => { received.push(parsed); return {} }, + }) + await invokeAsync(cmd, []) + assert.deepEqual(received[0]!.input, { document: { name: 'test' } }) + } finally { + restore() + } + }) + + it('does not wrap when the root key is already present', async () => { + const restore = _testSetStdinReader(() => JSON.stringify({ document: { name: 'test' } })) + try { + const received: ParsedResult[] = [] + const cmd = defineCommand({ + name: 'index', + description: 'Index a document', + input: z.object({ document: z.any() }), + inputTransform: (input) => { + if (input != null && typeof input === 'object' && !Array.isArray(input) && !('document' in (input as object))) { + return { document: input } + } + return input + }, + handler: (parsed) => { received.push(parsed); return {} }, + }) + await invokeAsync(cmd, []) + assert.deepEqual(received[0]!.input, { document: { name: 'test' } }) + } finally { + restore() + } + }) + }) describe('schema input - type acceptance', () => { it('accepts a Zod object schema as input without throwing', () => { const schema = z.object({ index: z.string() })