diff --git a/package-lock.json b/package-lock.json index ac1919af..a6687b1c 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 ae7c6776..666d5378 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", diff --git a/packages/oas-to-har/src/index.ts b/packages/oas-to-har/src/index.ts index 04d08e40..64d803f3 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); @@ -149,8 +155,25 @@ 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 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; + } 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 +219,7 @@ function appendHarValue( } } -function encodeBodyForHAR(body: any) { +function encodeBodyForHAR(body: any, preserveEmptyArray = false) { if (isPrimitive(body)) { return body; } else if ( @@ -211,10 +234,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 +440,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 +470,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 +590,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 +600,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 6bcf850b..1e379752 100644 --- a/packages/oas-to-har/test/index.test.ts +++ b/packages/oas-to-har/test/index.test.ts @@ -476,4 +476,153 @@ describe('oas-to-har', () => { ]); }); }); + + 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: [] } + } + } + } + } + } + } + } + } + }); + + 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":[]}'); + }); + }); + + }); + + describe('parameters', () => { + it('should apply default values for styled parameters when no value is provided', () => { + const spec = Oas.init({ + paths: { + '/test': { + 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', '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 66bc8f70..19e3ad5e 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==', }, });