Skip to content
Draft
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
11 changes: 10 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/oas-to-har/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 36 additions & 10 deletions packages/oas-to-har/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -149,8 +155,25 @@ function isPrimitive(val: unknown) {
return typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean';
}

function stringify(json: Record<string | 'RAW_BODY', unknown>) {
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<string | 'RAW_BODY', unknown>, 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 {
Expand Down Expand Up @@ -196,7 +219,7 @@ function appendHarValue(
}
}

function encodeBodyForHAR(body: any) {
function encodeBodyForHAR(body: any, preserveEmptyArray = false) {
if (isPrimitive(body)) {
return body;
} else if (
Expand All @@ -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.
Expand Down Expand Up @@ -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' };

Expand All @@ -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' };
Expand Down Expand Up @@ -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);
}
}
}
Expand All @@ -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) };
}
}
}
Expand Down
149 changes: 149 additions & 0 deletions packages/oas-to-har/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
]);
});
});
});
2 changes: 1 addition & 1 deletion packages/oas-to-har/test/requestBody.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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==',
},
});
Expand Down