Skip to content

Commit d3273d0

Browse files
committed
fix(kibana): send an empty object when no body is provided on POST, PUT, PATCH and DELETE
1 parent a1f92bd commit d3273d0

3 files changed

Lines changed: 135 additions & 14 deletions

File tree

codegen/functional/kb.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -385,13 +385,10 @@ const skippedFilesServerless = new Set<string>([
385385
"fleet_uninstall_tokens_post_fleet_uninstall_tokens_agentpolicyid_rotate.yml",
386386
"message_signing_service_post_fleet_message_signing_service_rotate_key_pair.yml",
387387

388-
// CLI (de)serialization defects: empty body sent as null, array param not
389-
// serialized as an array, or a non-JSON response the client cannot parse.
390-
"agent_builder_consumption.yml",
391-
"agent_builder_mcp_post.yml",
388+
// CLI (de)serialization defects: array param not serialized as an array,
389+
// or a non-JSON response the client cannot parse.
392390
"elastic_agent_policies_get_fleet_kubernetes_download.yml",
393391
"elastic_agents_get_fleet_agent_status_data.yml",
394-
"misc_post_security_role_query.yml",
395392

396393
// @elastic/schemas defect:
397394
// Upstream bugs tracked at:
@@ -495,14 +492,6 @@ const skippedFilesStack = new Set<string>([
495492
"security_osquery_api_osquery_update_packs.yml",
496493
"security_osquery_api_osquery_update_saved_query.yml",
497494

498-
// CLI serializes an empty/optional request body as `null`; Kibana rejects it
499-
// ("expected a plain object value, but found [null]").
500-
"agent_builder_consumption.yml",
501-
"agent_builder_mcp_post.yml",
502-
"misc_post_security_role_query.yml",
503-
"security_ai_assistant_api_delete_all_conversations.yml",
504-
"security_detections_api_search_alerts.yml",
505-
506495
// Array/oneOf query or body fields are mis-serialized (emitted as null or an
507496
// unparseable string), failing input validation before the request.
508497
"elastic_agents_get_fleet_agent_status_data.yml",

src/kb/request-builder.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,22 @@ export function buildKibanaRequestParams (
6666
// because these endpoints require siblings alongside the file (e.g. saved-objects
6767
// resolve-import-errors needs `retries`).
6868
const fields = isPlainObject(body) ? body : undefined
69-
if (fields != null && MULTIPART_ENDPOINTS.has(`${def.namespace} ${def.name}`)) {
69+
const isMultipart = MULTIPART_ENDPOINTS.has(`${def.namespace} ${def.name}`)
70+
if (fields != null && isMultipart) {
7071
params.multipartFields = Object.fromEntries(
7172
Object.entries(fields).map(([key, value]) => [key, typeof value === 'string' ? value : String(value)])
7273
)
7374
} else if (body !== undefined) {
7475
params.body = body
76+
} else if (
77+
// POST/PUT/PATCH/DELETE with schema-defined body properties must send at minimum `{}`.
78+
// Kibana treats a missing body as `null` for these endpoints and rejects with
79+
// "expected a plain object value, but found [null]". GET/HEAD never carry a body.
80+
def.method !== 'GET' && def.method !== 'HEAD' &&
81+
!isMultipart &&
82+
Object.values(props).some((p) => p['x-found-in'] === 'body' || p['x-found-in'] === undefined)
83+
) {
84+
params.body = {}
7585
}
7686

7787
return params

test/kb/request-builder.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,128 @@ describe('buildKibanaRequestParams', () => {
117117
})
118118
})
119119

120+
describe('buildKibanaRequestParams empty-body normalisation (CLI-1)', () => {
121+
it('sends {} for a POST with optional body fields when none are provided', () => {
122+
const def: KbApiDefinition = {
123+
name: 'post-thing',
124+
namespace: 'widgets',
125+
description: 'Post a thing',
126+
method: 'POST',
127+
path: '/api/widgets',
128+
input: {
129+
type: 'object',
130+
properties: {
131+
note: { type: 'string', 'x-found-in': 'body' },
132+
},
133+
},
134+
}
135+
const result = buildKibanaRequestParams(def, parsed())
136+
assert.deepEqual(result.body, {}, 'POST with no body args must send {} not null/undefined')
137+
})
138+
139+
it('sends {} for a DELETE with optional body fields when none are provided', () => {
140+
const def: KbApiDefinition = {
141+
name: 'delete-thing',
142+
namespace: 'widgets',
143+
description: 'Delete things',
144+
method: 'DELETE',
145+
path: '/api/widgets',
146+
input: {
147+
type: 'object',
148+
properties: {
149+
excludedIds: { type: 'array', items: { type: 'string' }, 'x-found-in': 'body' },
150+
},
151+
},
152+
}
153+
const result = buildKibanaRequestParams(def, parsed())
154+
assert.deepEqual(result.body, {}, 'DELETE with optional body must send {} not omit')
155+
})
156+
157+
it('sends {} for a PATCH with optional body fields when none are provided', () => {
158+
const def: KbApiDefinition = {
159+
name: 'patch-thing',
160+
namespace: 'widgets',
161+
description: 'Patch a thing',
162+
method: 'PATCH',
163+
path: '/api/widgets/1',
164+
input: {
165+
type: 'object',
166+
properties: {
167+
note: { type: 'string', 'x-found-in': 'body' },
168+
},
169+
},
170+
}
171+
const result = buildKibanaRequestParams(def, parsed())
172+
assert.deepEqual(result.body, {}, 'PATCH with no body args must send {}')
173+
})
174+
175+
it('does not set body for a GET with body-routed properties', () => {
176+
// GET requests must never carry a body regardless of property routing
177+
const def: KbApiDefinition = {
178+
name: 'get-thing',
179+
namespace: 'widgets',
180+
description: 'Get a thing',
181+
method: 'GET',
182+
path: '/api/widgets',
183+
input: {
184+
type: 'object',
185+
properties: {
186+
note: { type: 'string', 'x-found-in': 'body' },
187+
},
188+
},
189+
}
190+
const result = buildKibanaRequestParams(def, parsed())
191+
assert.equal(result.body, undefined, 'GET must not send a body')
192+
})
193+
194+
it('does not set body for a multipart endpoint with no body fields provided', () => {
195+
const def: KbApiDefinition = {
196+
name: 'post-saved-objects-import',
197+
namespace: 'saved-objects',
198+
description: 'Import saved objects',
199+
method: 'POST',
200+
path: '/api/saved_objects/_import',
201+
input: {
202+
type: 'object',
203+
properties: {
204+
file: { type: 'string', 'x-found-in': 'body' },
205+
},
206+
},
207+
}
208+
// With no file provided, multipart endpoints should have neither body nor multipartFields
209+
const result = buildKibanaRequestParams(def, parsed())
210+
assert.equal(result.body, undefined, 'empty multipart endpoint must not send JSON body')
211+
assert.equal(result.multipartFields, undefined, 'empty multipart endpoint must not send empty form')
212+
})
213+
214+
it('sends {} for a POST with x-body-root field and no input, using a real definition', async () => {
215+
const { loadAllKbApis } = await import('../../src/kb/apis.ts')
216+
const apis = await loadAllKbApis()
217+
const def = apis.find((d) => d.namespace === 'misc' && d.name === 'post-security-role-query')
218+
assert.ok(def != null, 'expected misc post-security-role-query in manifest')
219+
const result = buildKibanaRequestParams(def, parsed())
220+
assert.deepEqual(result.body, {}, 'bodyless misc POST must send {} not null/undefined')
221+
})
222+
223+
it('sends {} for search-alerts with no args, using a real definition', async () => {
224+
const { loadAllKbApis } = await import('../../src/kb/apis.ts')
225+
const apis = await loadAllKbApis()
226+
const def = apis.find((d) => d.namespace === 'security-detections-api' && d.name === 'search-alerts')
227+
assert.ok(def != null, 'expected security-detections-api search-alerts in manifest')
228+
const result = buildKibanaRequestParams(def, parsed())
229+
assert.deepEqual(result.body, {}, 'search-alerts with no args must send {} not null/undefined')
230+
})
231+
232+
it('sends {} for delete-all-conversations (DELETE with optional body) using a real definition', async () => {
233+
const { loadAllKbApis } = await import('../../src/kb/apis.ts')
234+
const apis = await loadAllKbApis()
235+
const def = apis.find((d) => d.namespace === 'security-ai-assistant-api' && d.name === 'delete-all-conversations')
236+
assert.ok(def != null, 'expected security-ai-assistant-api delete-all-conversations in manifest')
237+
const result = buildKibanaRequestParams(def, parsed())
238+
assert.deepEqual(result.body, {}, 'DELETE with optional body must send {} not null/undefined')
239+
})
240+
})
241+
120242
describe('buildKibanaRequestParams path param requiredness (BUG A regression)', () => {
121243
// ponytail: no real Kibana definition currently has an optional path param
122244
// (0 of 555 upstream definitions exercise this — see test/kb/register.test.ts),

0 commit comments

Comments
 (0)