Skip to content
Merged
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
21 changes: 13 additions & 8 deletions codegen/functional/kb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,12 +385,16 @@ const skippedFilesServerless = new Set<string>([
"fleet_uninstall_tokens_post_fleet_uninstall_tokens_agentpolicyid_rotate.yml",
"message_signing_service_post_fleet_message_signing_service_rotate_key_pair.yml",

// CLI (de)serialization defects: empty body sent as null, array param not
// serialized as an array, or a non-JSON response the client cannot parse.
"agent_builder_consumption.yml",
"agent_builder_mcp_post.yml",
// CLI (de)serialization defects: array param not serialized as an array,
// or a non-JSON response the client cannot parse.
"elastic_agent_policies_get_fleet_kubernetes_download.yml",
"elastic_agents_get_fleet_agent_status_data.yml",

// Not fixed by empty-body normalisation: mcp_post needs a JSON-RPC payload and
// returns an event-stream (-32700 Parse error); consumption 404s (route absent);
// security_role_query returns total 0 (no queryable roles in this env).
"agent_builder_mcp_post.yml",
"agent_builder_consumption.yml",
"misc_post_security_role_query.yml",

// @elastic/schemas defect:
Expand Down Expand Up @@ -495,12 +499,13 @@ const skippedFilesStack = new Set<string>([
"security_osquery_api_osquery_update_packs.yml",
"security_osquery_api_osquery_update_saved_query.yml",

// CLI serializes an empty/optional request body as `null`; Kibana rejects it
// ("expected a plain object value, but found [null]").
"agent_builder_consumption.yml",
// Not fixed by empty-body normalisation: mcp_post needs a JSON-RPC payload and
// returns an event-stream (-32700 Parse error); consumption 404s (route absent);
// security_role_query returns total 0 (no queryable roles in this env);
// search_alerts rejects an empty body ("value must have at least 1 children").
"agent_builder_mcp_post.yml",
"agent_builder_consumption.yml",
"misc_post_security_role_query.yml",
"security_ai_assistant_api_delete_all_conversations.yml",
"security_detections_api_search_alerts.yml",

// Array/oneOf query or body fields are mis-serialized (emitted as null or an
Expand Down
12 changes: 11 additions & 1 deletion src/kb/request-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,22 @@ export function buildKibanaRequestParams (
// because these endpoints require siblings alongside the file (e.g. saved-objects
// resolve-import-errors needs `retries`).
const fields = isPlainObject(body) ? body : undefined
if (fields != null && MULTIPART_ENDPOINTS.has(`${def.namespace} ${def.name}`)) {
const isMultipart = MULTIPART_ENDPOINTS.has(`${def.namespace} ${def.name}`)
if (fields != null && isMultipart) {
params.multipartFields = Object.fromEntries(
Object.entries(fields).map(([key, value]) => [key, typeof value === 'string' ? value : String(value)])
)
} else if (body !== undefined) {
params.body = body
} else if (
// POST/PUT/PATCH/DELETE with schema-defined body properties must send at minimum `{}`.
// Kibana treats a missing body as `null` for these endpoints and rejects with
// "expected a plain object value, but found [null]". GET/HEAD never carry a body.
def.method !== 'GET' && def.method !== 'HEAD' &&
!isMultipart &&
Object.values(props).some((p) => p['x-found-in'] === 'body' || p['x-found-in'] === undefined)
Comment on lines +80 to +82
) {
params.body = {}
}

return params
Expand Down
122 changes: 122 additions & 0 deletions test/kb/request-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,128 @@ describe('buildKibanaRequestParams', () => {
})
})

describe('buildKibanaRequestParams empty-body normalisation (CLI-1)', () => {
it('sends {} for a POST with optional body fields when none are provided', () => {
const def: KbApiDefinition = {
name: 'post-thing',
namespace: 'widgets',
description: 'Post a thing',
method: 'POST',
path: '/api/widgets',
input: {
type: 'object',
properties: {
note: { type: 'string', 'x-found-in': 'body' },
},
},
}
const result = buildKibanaRequestParams(def, parsed())
assert.deepEqual(result.body, {}, 'POST with no body args must send {} not null/undefined')
})

it('sends {} for a DELETE with optional body fields when none are provided', () => {
const def: KbApiDefinition = {
name: 'delete-thing',
namespace: 'widgets',
description: 'Delete things',
method: 'DELETE',
path: '/api/widgets',
input: {
type: 'object',
properties: {
excludedIds: { type: 'array', items: { type: 'string' }, 'x-found-in': 'body' },
},
},
}
const result = buildKibanaRequestParams(def, parsed())
assert.deepEqual(result.body, {}, 'DELETE with optional body must send {} not omit')
})

it('sends {} for a PATCH with optional body fields when none are provided', () => {
const def: KbApiDefinition = {
name: 'patch-thing',
namespace: 'widgets',
description: 'Patch a thing',
method: 'PATCH',
path: '/api/widgets/1',
input: {
type: 'object',
properties: {
note: { type: 'string', 'x-found-in': 'body' },
},
},
}
const result = buildKibanaRequestParams(def, parsed())
assert.deepEqual(result.body, {}, 'PATCH with no body args must send {}')
})

it('does not set body for a GET with body-routed properties', () => {
// GET requests must never carry a body regardless of property routing
const def: KbApiDefinition = {
name: 'get-thing',
namespace: 'widgets',
description: 'Get a thing',
method: 'GET',
path: '/api/widgets',
input: {
type: 'object',
properties: {
note: { type: 'string', 'x-found-in': 'body' },
},
},
}
const result = buildKibanaRequestParams(def, parsed())
assert.equal(result.body, undefined, 'GET must not send a body')
})

it('does not set body for a multipart endpoint with no body fields provided', () => {
const def: KbApiDefinition = {
name: 'post-saved-objects-import',
namespace: 'saved-objects',
description: 'Import saved objects',
method: 'POST',
path: '/api/saved_objects/_import',
input: {
type: 'object',
properties: {
file: { type: 'string', 'x-found-in': 'body' },
},
},
}
// With no file provided, multipart endpoints should have neither body nor multipartFields
const result = buildKibanaRequestParams(def, parsed())
assert.equal(result.body, undefined, 'empty multipart endpoint must not send JSON body')
assert.equal(result.multipartFields, undefined, 'empty multipart endpoint must not send empty form')
})

it('sends {} for a POST with x-body-root field and no input, using a real definition', async () => {
const { loadAllKbApis } = await import('../../src/kb/apis.ts')
const apis = await loadAllKbApis()
const def = apis.find((d) => d.namespace === 'misc' && d.name === 'post-security-role-query')
assert.ok(def != null, 'expected misc post-security-role-query in manifest')
const result = buildKibanaRequestParams(def, parsed())
assert.deepEqual(result.body, {}, 'bodyless misc POST must send {} not null/undefined')
})

it('sends {} for search-alerts with no args, using a real definition', async () => {
const { loadAllKbApis } = await import('../../src/kb/apis.ts')
const apis = await loadAllKbApis()
const def = apis.find((d) => d.namespace === 'security-detections-api' && d.name === 'search-alerts')
assert.ok(def != null, 'expected security-detections-api search-alerts in manifest')
const result = buildKibanaRequestParams(def, parsed())
assert.deepEqual(result.body, {}, 'search-alerts with no args must send {} not null/undefined')
})

it('sends {} for delete-all-conversations (DELETE with optional body) using a real definition', async () => {
const { loadAllKbApis } = await import('../../src/kb/apis.ts')
const apis = await loadAllKbApis()
const def = apis.find((d) => d.namespace === 'security-ai-assistant-api' && d.name === 'delete-all-conversations')
assert.ok(def != null, 'expected security-ai-assistant-api delete-all-conversations in manifest')
const result = buildKibanaRequestParams(def, parsed())
assert.deepEqual(result.body, {}, 'DELETE with optional body must send {} not null/undefined')
})
})

describe('buildKibanaRequestParams path param requiredness (BUG A regression)', () => {
// ponytail: no real Kibana definition currently has an optional path param
// (0 of 555 upstream definitions exercise this — see test/kb/register.test.ts),
Expand Down
Loading