From 00ca70f1096c1b67b41d45bb395d66d585c33894 Mon Sep 17 00:00:00 2001 From: eagletrhost Date: Wed, 10 Sep 2025 20:33:03 +1000 Subject: [PATCH 1/4] feat: add check to preserve empty arrays --- packages/oas-to-har/src/index.ts | 37 +++++--- packages/oas-to-har/test/index.test.ts | 112 +++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 10 deletions(-) diff --git a/packages/oas-to-har/src/index.ts b/packages/oas-to-har/src/index.ts index 04d08e402..15a6831a8 100644 --- a/packages/oas-to-har/src/index.ts +++ b/packages/oas-to-har/src/index.ts @@ -21,7 +21,7 @@ import { HEADERS, PROXY_ENABLED } from 'oas/extensions'; import { Operation } from 'oas/operation'; import { isRef } from 'oas/types'; import { jsonSchemaTypes, matchesMimeType } from 'oas/utils'; -import removeUndefinedObjects from 'remove-undefined-objects'; +import removeUndefinedObjects from './lib/remove-undefined-objects.js'; import configureSecurity from './lib/configure-security.js'; import { get, set } from './lib/lodash.js'; @@ -149,8 +149,22 @@ function isPrimitive(val: unknown) { return typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean'; } -function stringify(json: Record) { - return JSON.stringify(removeUndefinedObjects(typeof json.RAW_BODY !== 'undefined' ? json.RAW_BODY : json)); +// Check if any of the schema & nested schema has an empty array default, which we want to preserve +function hasEmptyArrayDefault(schema: SchemaObject): boolean { + if (schema.type === 'array' && schema.default && Array.isArray(schema.default) && schema.default.length === 0) { + return true; + } else if (schema.type === 'object' && schema.properties) { + return Object.entries(schema.properties).some(([_, property]) => + hasEmptyArrayDefault(property as SchemaObject) + ); + } + return false; +} + +function stringify(json: Record, preserveEmptyArray = false) { + const data = typeof json.RAW_BODY !== 'undefined' ? json.RAW_BODY : json; + const processedData = removeUndefinedObjects(data, { preserveEmptyArray }); + return JSON.stringify(processedData); } function stringifyParameter(param: any): string { @@ -196,7 +210,7 @@ function appendHarValue( } } -function encodeBodyForHAR(body: any) { +function encodeBodyForHAR(body: any, preserveEmptyArray = false) { if (isPrimitive(body)) { return body; } else if ( @@ -211,10 +225,10 @@ function encodeBodyForHAR(body: any) { return body.RAW_BODY; } - return stringify(body.RAW_BODY); + return stringify(body.RAW_BODY, preserveEmptyArray); } - return stringify(body); + return stringify(body, preserveEmptyArray); } // biome-ignore lint/style/noDefaultExport: This is fine for now. @@ -417,10 +431,13 @@ export default function oasToHar( if (requestBody?.schema && Object.keys(requestBody.schema).length) { const requestBodySchema = requestBody.schema as SchemaObject; + // We want to preserve empty arrays if the schema has an empty array default + const preserveEmptyArray = hasEmptyArrayDefault(requestBodySchema); if (operation.isFormUrlEncoded()) { if (Object.keys(formData.formData || {}).length) { - const cleanFormData = removeUndefinedObjects(JSON.parse(JSON.stringify(formData.formData))); + const cleanFormData = removeUndefinedObjects(formData.formData, { preserveEmptyArray }); + if (cleanFormData !== undefined) { const postData: PostData = { params: [], mimeType: 'application/x-www-form-urlencoded' }; @@ -444,7 +461,7 @@ export default function oasToHar( if (isMultipart || isJSON) { try { - let cleanBody = removeUndefinedObjects(JSON.parse(JSON.stringify(formData.body))); + let cleanBody = removeUndefinedObjects(formData.body, { preserveEmptyArray }); if (isMultipart) { har.postData = { params: [], mimeType: 'multipart/form-data' }; @@ -564,7 +581,7 @@ export default function oasToHar( har.postData.text = stringify(formData.body); } } else { - har.postData.text = encodeBodyForHAR(formData.body); + har.postData.text = encodeBodyForHAR(formData.body, preserveEmptyArray); } } } @@ -574,7 +591,7 @@ export default function oasToHar( har.postData = { mimeType: contentType, text: stringify(formData.body) }; } } else { - har.postData = { mimeType: contentType, text: encodeBodyForHAR(formData.body) }; + har.postData = { mimeType: contentType, text: encodeBodyForHAR(formData.body, preserveEmptyArray) }; } } } diff --git a/packages/oas-to-har/test/index.test.ts b/packages/oas-to-har/test/index.test.ts index 6bcf850b9..ca2a83cfe 100644 --- a/packages/oas-to-har/test/index.test.ts +++ b/packages/oas-to-har/test/index.test.ts @@ -476,4 +476,116 @@ describe('oas-to-har', () => { ]); }); }); + + describe('hasEmptyArrayDefault behavior', () => { + it('should preserve empty arrays when schema has empty array defaults', () => { + const spec = Oas.init({ + paths: { + '/test': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { type: 'array', default: [] } + } + } + } + } + } + } + } + } + }); + + const har = oasToHar(spec, spec.operation('/test', 'post'), { body: { items: [] } }); + expect(har.log.entries[0].request.postData?.text).toBe('{"items":[]}'); + }); + + it('should remove empty arrays when schema has no empty array defaults', () => { + const spec = Oas.init({ + paths: { + '/test': { + post: { + requestBody: { + content: { + 'application/json': { + type: 'object', + schema: { + properties: { + items: { type: 'array' } + } + } + } + } + } + } + } + } + }); + + const har = oasToHar(spec, spec.operation('/test', 'post'), { body: { items: [] } }); + expect(har.log.entries[0].request.postData?.text).toBeUndefined(); + }); + + it('should handle nested empty array defaults', () => { + const spec = Oas.init({ + paths: { + '/test': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + nested: { + type: 'object', + properties: { + items: { type: 'array', default: [] } + } + } + } + } + } + } + } + } + } + } + }); + + const har = oasToHar(spec, spec.operation('/test', 'post'), { body: { nested: { items: [] } } }); + expect(har.log.entries[0].request.postData?.text).toBe('{"nested":{"items":[]}}'); + }); + + it('should handle mixed array defaults', () => { + const spec = Oas.init({ + paths: { + '/test': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + keepEmpty: { type: 'array', default: [] }, + removeEmpty: { type: 'array' } + } + } + } + } + } + } + } + } + }); + + const har = oasToHar(spec, spec.operation('/test', 'post'), { body: { keepEmpty: [], removeEmpty: [] } }); + expect(har.log.entries[0].request.postData?.text).toBe('{"keepEmpty":[],"removeEmpty":[]}'); + }); + }); }); From ec66659d979f2fca5c70f0ee05825f50e168e673 Mon Sep 17 00:00:00 2001 From: eagletrhost Date: Thu, 11 Sep 2025 15:21:33 +1000 Subject: [PATCH 2/4] fix: make sure default value is added for styled objects --- packages/oas-to-har/src/index.ts | 8 +- packages/oas-to-har/test/index.test.ts | 173 +++++++++++-------- packages/oas-to-har/test/requestBody.test.ts | 2 +- 3 files changed, 113 insertions(+), 70 deletions(-) diff --git a/packages/oas-to-har/src/index.ts b/packages/oas-to-har/src/index.ts index 15a6831a8..18cfc90a6 100644 --- a/packages/oas-to-har/src/index.ts +++ b/packages/oas-to-har/src/index.ts @@ -35,7 +35,13 @@ function formatter( onlyIfExists = false, ) { if (param.style) { - const value = values[type][param.name]; + let value = values[type][param.name]; + + // Make sure default value is applied if there's no user-provided values & the parameter is required + if ((value === undefined || value === 'undefined') && param.required && param.schema && !isRef(param.schema) && param.schema.default) { + value = param.schema.default; + } + // Note: Technically we could send everything through the format style and choose the proper // default for each `in` type (e.g. query defaults to form). return formatStyle(value, param); diff --git a/packages/oas-to-har/test/index.test.ts b/packages/oas-to-har/test/index.test.ts index ca2a83cfe..1e379752b 100644 --- a/packages/oas-to-har/test/index.test.ts +++ b/packages/oas-to-har/test/index.test.ts @@ -477,19 +477,22 @@ describe('oas-to-har', () => { }); }); - describe('hasEmptyArrayDefault behavior', () => { - it('should preserve empty arrays when schema has empty array defaults', () => { - const spec = Oas.init({ - paths: { - '/test': { - post: { - requestBody: { - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - items: { type: 'array', default: [] } + describe('postData', () => { + + describe('hasEmptyArrayDefault behavior', () => { + it('should preserve empty arrays when schema has empty array defaults', () => { + const spec = Oas.init({ + paths: { + '/test': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { type: 'array', default: [] } + } } } } @@ -497,25 +500,25 @@ describe('oas-to-har', () => { } } } - } - }); + }); - const har = oasToHar(spec, spec.operation('/test', 'post'), { body: { items: [] } }); - expect(har.log.entries[0].request.postData?.text).toBe('{"items":[]}'); - }); + const har = oasToHar(spec, spec.operation('/test', 'post'), { body: { items: [] } }); + expect(har.log.entries[0].request.postData?.text).toBe('{"items":[]}'); + }); - it('should remove empty arrays when schema has no empty array defaults', () => { - const spec = Oas.init({ - paths: { - '/test': { - post: { - requestBody: { - content: { - 'application/json': { - type: 'object', - schema: { - properties: { - items: { type: 'array' } + it('should remove empty arrays when schema has no empty array defaults', () => { + const spec = Oas.init({ + paths: { + '/test': { + post: { + requestBody: { + content: { + 'application/json': { + type: 'object', + schema: { + properties: { + items: { type: 'array' } + } } } } @@ -523,28 +526,28 @@ describe('oas-to-har', () => { } } } - } - }); + }); - const har = oasToHar(spec, spec.operation('/test', 'post'), { body: { items: [] } }); - expect(har.log.entries[0].request.postData?.text).toBeUndefined(); - }); + const har = oasToHar(spec, spec.operation('/test', 'post'), { body: { items: [] } }); + expect(har.log.entries[0].request.postData?.text).toBeUndefined(); + }); - it('should handle nested empty array defaults', () => { - const spec = Oas.init({ - paths: { - '/test': { - post: { - requestBody: { - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - nested: { - type: 'object', - properties: { - items: { type: 'array', default: [] } + it('should handle nested empty array defaults', () => { + const spec = Oas.init({ + paths: { + '/test': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + nested: { + type: 'object', + properties: { + items: { type: 'array', default: [] } + } } } } @@ -554,38 +557,72 @@ describe('oas-to-har', () => { } } } - } + }); + + const har = oasToHar(spec, spec.operation('/test', 'post'), { body: { nested: { items: [] } } }); + expect(har.log.entries[0].request.postData?.text).toBe('{"nested":{"items":[]}}'); }); - const har = oasToHar(spec, spec.operation('/test', 'post'), { body: { nested: { items: [] } } }); - expect(har.log.entries[0].request.postData?.text).toBe('{"nested":{"items":[]}}'); + it('should handle mixed array defaults', () => { + const spec = Oas.init({ + paths: { + '/test': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + keepEmpty: { type: 'array', default: [] }, + removeEmpty: { type: 'array' } + } + } + } + } + } + } + } + } + }); + + const har = oasToHar(spec, spec.operation('/test', 'post'), { body: { keepEmpty: [], removeEmpty: [] } }); + expect(har.log.entries[0].request.postData?.text).toBe('{"keepEmpty":[],"removeEmpty":[]}'); + }); }); - it('should handle mixed array defaults', () => { + }); + + describe('parameters', () => { + it('should apply default values for styled parameters when no value is provided', () => { const spec = Oas.init({ paths: { '/test': { - post: { - requestBody: { - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - keepEmpty: { type: 'array', default: [] }, - removeEmpty: { type: 'array' } - } - } + get: { + parameters: [ + { + name: 'filter', + in: 'query', + style: 'form', + explode: true, + required: true, + schema: { + type: 'array', + items: { type: 'string' }, + default: ['active', 'pending'] } } - } + ] } } } }); - const har = oasToHar(spec, spec.operation('/test', 'post'), { body: { keepEmpty: [], removeEmpty: [] } }); - expect(har.log.entries[0].request.postData?.text).toBe('{"keepEmpty":[],"removeEmpty":[]}'); + const har = oasToHar(spec, spec.operation('/test', 'get'), {}); + expect(har.log.entries[0].request.queryString).toStrictEqual([ + { name: 'filter', value: 'active' }, + { name: 'filter', value: 'pending' } + ]); }); }); }); diff --git a/packages/oas-to-har/test/requestBody.test.ts b/packages/oas-to-har/test/requestBody.test.ts index 66bc8f70b..19e3ad5e7 100644 --- a/packages/oas-to-har/test/requestBody.test.ts +++ b/packages/oas-to-har/test/requestBody.test.ts @@ -599,7 +599,7 @@ describe('request body handling', () => { it('should retain filename casing', () => { const fixture = Oas.init(fileUploads); const har = oasToHar(fixture, fixture.operation('/anything/multipart-formdata', 'post'), { - body: { + body: { documentFile: 'data:text/plain;name=LoREM_IpSuM.txt;base64,TG9yZW0gaXBzdW0gZG9sb3Igc2l0IG1ldA==', }, }); From bb48917ecb1119634789624cc5bb773190a20c69 Mon Sep 17 00:00:00 2001 From: eagletrhost Date: Thu, 11 Sep 2025 16:38:32 +1000 Subject: [PATCH 3/4] add comment on hasEmptyArrayDefault workflow --- packages/oas-to-har/src/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/oas-to-har/src/index.ts b/packages/oas-to-har/src/index.ts index 18cfc90a6..64d803f39 100644 --- a/packages/oas-to-har/src/index.ts +++ b/packages/oas-to-har/src/index.ts @@ -21,7 +21,7 @@ import { HEADERS, PROXY_ENABLED } from 'oas/extensions'; import { Operation } from 'oas/operation'; import { isRef } from 'oas/types'; import { jsonSchemaTypes, matchesMimeType } from 'oas/utils'; -import removeUndefinedObjects from './lib/remove-undefined-objects.js'; +import removeUndefinedObjects from 'remove-undefined-objects'; import configureSecurity from './lib/configure-security.js'; import { get, set } from './lib/lodash.js'; @@ -155,7 +155,10 @@ function isPrimitive(val: unknown) { return typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean'; } -// Check if any of the schema & nested schema has an empty array default, which we want to preserve +// Check if any schema property has an empty array default to determine whether to preserve empty arrays. +// The usage of this function still mean some empty array on properties without empty array defaults be preserved, +// if at least one other property has empty array default +// but I think this is better than always setting preserveEmptyArray to true. function hasEmptyArrayDefault(schema: SchemaObject): boolean { if (schema.type === 'array' && schema.default && Array.isArray(schema.default) && schema.default.length === 0) { return true; From 61f09140e6e4937f5e558ea3f28713160e95ad8f Mon Sep 17 00:00:00 2001 From: eagletrhost Date: Mon, 15 Sep 2025 22:38:54 +1000 Subject: [PATCH 4/4] feat: update remove undefined obejcts package --- package-lock.json | 11 ++++++++++- packages/oas-to-har/package.json | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ac1919af3..a6687b1c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17528,7 +17528,7 @@ "@readme/data-urls": "^3.0.0", "oas": "file:../oas", "qs": "^6.12.0", - "remove-undefined-objects": "^6.0.0" + "remove-undefined-objects": "^7.0.0" }, "devDependencies": { "@readme/oas-examples": "file:../oas-examples", @@ -17543,6 +17543,15 @@ "node": ">=20" } }, + "packages/oas-to-har/node_modules/remove-undefined-objects": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/remove-undefined-objects/-/remove-undefined-objects-7.0.0.tgz", + "integrity": "sha512-+9ycqqqpv6EdaOvHpyOkf81SXJ4MjARKX450Je6AmshEYeqAuiVcfbLx1coNICO3KulleXlOHd0GSHFkEdB3YQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "packages/oas-to-snippet": { "name": "@readme/oas-to-snippet", "version": "29.0.1", diff --git a/packages/oas-to-har/package.json b/packages/oas-to-har/package.json index ae7c67768..666d53782 100644 --- a/packages/oas-to-har/package.json +++ b/packages/oas-to-har/package.json @@ -51,7 +51,7 @@ "@readme/data-urls": "^3.0.0", "oas": "file:../oas", "qs": "^6.12.0", - "remove-undefined-objects": "^6.0.0" + "remove-undefined-objects": "^7.0.0" }, "devDependencies": { "@readme/oas-examples": "file:../oas-examples",