Skip to content
Open
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
12 changes: 12 additions & 0 deletions src/es/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<string, unknown>)) return input
return { [rootKey]: input }
}
}
return defineCommand(config)
}

Expand Down
4 changes: 4 additions & 0 deletions src/es/request-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ const BODY_ROOT_FIELDS: Record<string, Set<string> | '*'> = {
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.
Expand Down
2 changes: 2 additions & 0 deletions src/factory-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export interface CommandConfig<T extends z.ZodType = z.ZodType> {
* 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). */
Expand Down
3 changes: 3 additions & 0 deletions src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,9 @@ export function defineCommand<T extends z.ZodType> (config: CommandConfig<T>): 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<string, unknown> = {}
Expand Down
52 changes: 51 additions & 1 deletion test/es/register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}` }
Expand Down Expand Up @@ -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<string> {
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}`)
})
})
56 changes: 56 additions & 0 deletions test/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() })
Expand Down
Loading