diff --git a/CHANGELOG.md b/CHANGELOG.md index ae57ddf..80ad88f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.4.3-beta.1] + +### Fixed +- `nullable` properties are now translated into a JSON Schema `null` type in the generated response schema. + ## [2.4.2] - 2026-07-21 ### Fixed diff --git a/package-lock.json b/package-lock.json index 2b6ff1e..13f4d0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openapi2postman", - "version": "2.4.2-beta.1", + "version": "2.4.3-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openapi2postman", - "version": "2.4.2-beta.1", + "version": "2.4.3-beta.1", "license": "ISC", "dependencies": { "js-yaml": "^4.1.0", diff --git a/package.json b/package.json index c80be03..864b690 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openapi2postman", - "version": "2.4.2", + "version": "2.4.3-beta.1", "description": "openapi2postman", "bin": { "o2p": "index.js" diff --git a/seeds/parserBodyNullableInitial3.1.json b/seeds/parserBodyNullableInitial3.1.json new file mode 100644 index 0000000..c2d2977 --- /dev/null +++ b/seeds/parserBodyNullableInitial3.1.json @@ -0,0 +1,101 @@ +{ + "openapi":"3.1.0", + "info":{ + "version":"1.0.0", + "title":"Nullable properties" + }, + "paths":{ + "/professionals":{ + "get":{ + "description":"Returns the professionals adhered to the SAC", + "operationId":"findProfessionals", + "responses":{ + "200":{ + "description":"professional response", + "content":{ + "application/json":{ + "schema":{ + "$ref":"#/components/schemas/Professional" + } + } + } + } + } + } + } + }, + "components":{ + "schemas":{ + "Professional":{ + "type":"object", + "required":[ + "nombre" + ], + "properties":{ + "nombre":{ + "type":"string" + }, + "razon_social":{ + "type":[ + "string", + "null" + ], + "maxLength":70, + "description":"Razon social" + }, + "estado":{ + "type":[ + "string", + "null" + ], + "enum":[ + "ALTA", + "BAJA", + null + ] + }, + "telefonos":{ + "type":[ + "array", + "null" + ], + "items":{ + "type":"string" + } + }, + "info_profesional":{ + "type":[ + "object", + "null" + ], + "description":"Datos del profesional adherido al SAC.", + "required":[ + "codigo" + ], + "properties":{ + "codigo":{ + "type":[ + "integer", + "null" + ] + } + } + }, + "contacto":{ + "oneOf":[ + { + "type":"string" + }, + { + "type":"integer" + }, + { + "type":"null" + } + ] + } + } + } + } + } +} diff --git a/seeds/parserBodyNullableInitial3.json b/seeds/parserBodyNullableInitial3.json new file mode 100644 index 0000000..f77f926 --- /dev/null +++ b/seeds/parserBodyNullableInitial3.json @@ -0,0 +1,116 @@ +{ + "openapi":"3.0.0", + "info":{ + "version":"1.0.0", + "title":"Nullable properties" + }, + "paths":{ + "/professionals":{ + "get":{ + "description":"Returns the professionals adhered to the SAC", + "operationId":"findProfessionals", + "responses":{ + "200":{ + "description":"professional response", + "content":{ + "application/json":{ + "schema":{ + "$ref":"#/components/schemas/Professional" + } + } + } + } + } + } + } + }, + "components":{ + "schemas":{ + "Professional":{ + "type":"object", + "required":[ + "nombre" + ], + "properties":{ + "nombre":{ + "type":"string" + }, + "razon_social":{ + "type":"string", + "nullable":true, + "maxLength":70, + "description":"Razon social" + }, + "estado":{ + "type":"string", + "nullable":true, + "enum":[ + "ALTA", + "BAJA" + ] + }, + "telefonos":{ + "type":"array", + "nullable":true, + "items":{ + "type":"string" + } + }, + "info_profesional":{ + "type":"object", + "nullable":true, + "description":"Datos del profesional adherido al SAC.", + "required":[ + "codigo" + ], + "properties":{ + "codigo":{ + "type":"integer", + "nullable":true + } + } + }, + "contacto":{ + "nullable":true, + "oneOf":[ + { + "type":"string" + }, + { + "type":"integer" + } + ] + }, + "compuesto":{ + "allOf":[ + { + "$ref":"#/components/schemas/Base" + }, + { + "type":"object", + "nullable":true, + "properties":{ + "extra":{ + "type":"string", + "nullable":true + } + } + } + ] + } + } + }, + "Base":{ + "type":"object", + "required":[ + "id" + ], + "properties":{ + "id":{ + "type":"integer" + } + } + } + } + } +} diff --git a/seeds/parserBodyNullableInitialSwagger2.json b/seeds/parserBodyNullableInitialSwagger2.json new file mode 100644 index 0000000..975325a --- /dev/null +++ b/seeds/parserBodyNullableInitialSwagger2.json @@ -0,0 +1,78 @@ +{ + "swagger":"2.0", + "info":{ + "version":"1.0.0", + "title":"Nullable properties" + }, + "paths":{ + "/professionals":{ + "get":{ + "description":"Returns the professionals adhered to the SAC", + "operationId":"findProfessionals", + "responses":{ + "200":{ + "description":"professional response", + "schema":{ + "$ref":"#/definitions/Professional" + } + } + } + } + } + }, + "definitions":{ + "Professional":{ + "type":"object", + "required":[ + "nombre" + ], + "properties":{ + "nombre":{ + "type":"string" + }, + "razon_social":{ + "type":"string", + "x-nullable":true, + "maxLength":70, + "description":"Razon social" + }, + "estado":{ + "type":"string", + "enum":[ + "ALTA", + "BAJA" + ] + }, + "estado_nullable":{ + "type":"string", + "x-nullable":true, + "enum":[ + "ALTA", + "BAJA" + ] + }, + "telefonos":{ + "type":"array", + "x-nullable":true, + "items":{ + "type":"string" + } + }, + "info_profesional":{ + "type":"object", + "x-nullable":true, + "description":"Datos del profesional adherido al SAC.", + "required":[ + "codigo" + ], + "properties":{ + "codigo":{ + "type":"integer", + "x-nullable":true + } + } + } + } + } + } +} diff --git a/seeds/parserBodyNullableResult3.1.json b/seeds/parserBodyNullableResult3.1.json new file mode 100644 index 0000000..418f196 --- /dev/null +++ b/seeds/parserBodyNullableResult3.1.json @@ -0,0 +1,72 @@ +{ + "200": { + "type": "object", + "required": [ + "nombre" + ], + "properties": { + "nombre": { + "type": "string" + }, + "razon_social": { + "type": [ + "string", + "null" + ], + "maxLength": 70, + "description": "Razon social" + }, + "estado": { + "type": [ + "string", + "null" + ], + "enum": [ + "ALTA", + "BAJA", + null + ] + }, + "telefonos": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "info_profesional": { + "type": [ + "object", + "null" + ], + "description": "Datos del profesional adherido al SAC.", + "required": [ + "codigo" + ], + "properties": { + "codigo": { + "type": [ + "integer", + "null" + ] + } + } + }, + "contacto": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "null" + } + ] + } + } + } +} diff --git a/seeds/parserBodyNullableResult3.json b/seeds/parserBodyNullableResult3.json new file mode 100644 index 0000000..86eeb2c --- /dev/null +++ b/seeds/parserBodyNullableResult3.json @@ -0,0 +1,100 @@ +{ + "200": { + "type": "object", + "required": [ + "nombre" + ], + "properties": { + "nombre": { + "type": "string" + }, + "razon_social": { + "type": [ + "string", + "null" + ], + "nullable": true, + "maxLength": 70, + "description": "Razon social" + }, + "estado": { + "type": [ + "string", + "null" + ], + "nullable": true, + "enum": [ + "ALTA", + "BAJA", + null + ] + }, + "telefonos": { + "type": [ + "array", + "null" + ], + "nullable": true, + "items": { + "type": "string" + } + }, + "info_profesional": { + "type": [ + "object", + "null" + ], + "nullable": true, + "description": "Datos del profesional adherido al SAC.", + "required": [ + "codigo" + ], + "properties": { + "codigo": { + "type": [ + "integer", + "null" + ], + "nullable": true + } + } + }, + "contacto": { + "nullable": true, + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "compuesto": { + "type": [ + "object", + "null" + ], + "nullable": true, + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "extra": { + "type": [ + "string", + "null" + ], + "nullable": true + } + } + } + } + } +} diff --git a/seeds/parserBodyNullableResultSwagger2.json b/seeds/parserBodyNullableResultSwagger2.json new file mode 100644 index 0000000..4089d5d --- /dev/null +++ b/seeds/parserBodyNullableResultSwagger2.json @@ -0,0 +1,71 @@ +{ + "200": { + "type": "object", + "required": [ + "nombre" + ], + "properties": { + "nombre": { + "type": "string" + }, + "razon_social": { + "type": [ + "string", + "null" + ], + "x-nullable": true, + "maxLength": 70, + "description": "Razon social" + }, + "estado": { + "type": "string", + "enum": [ + "ALTA", + "BAJA" + ] + }, + "estado_nullable": { + "type": [ + "string", + "null" + ], + "x-nullable": true, + "enum": [ + "ALTA", + "BAJA", + null + ] + }, + "telefonos": { + "type": [ + "array", + "null" + ], + "x-nullable": true, + "items": { + "type": "string" + } + }, + "info_profesional": { + "type": [ + "object", + "null" + ], + "x-nullable": true, + "description": "Datos del profesional adherido al SAC.", + "required": [ + "codigo" + ], + "properties": { + "codigo": { + "type": [ + "integer", + "null" + ], + "x-nullable": true + } + } + } + } + } +} diff --git a/seeds/parserBodyTypeListInitial3.1.json b/seeds/parserBodyTypeListInitial3.1.json new file mode 100644 index 0000000..ab7c50f --- /dev/null +++ b/seeds/parserBodyTypeListInitial3.1.json @@ -0,0 +1,75 @@ +{ + "openapi":"3.1.0", + "info":{ + "version":"1.0.0", + "title":"Listas de tipos" + }, + "paths":{ + "/valores":{ + "post":{ + "operationId":"postValores", + "requestBody":{ + "required":true, + "content":{ + "application/json":{ + "schema":{ + "type":"object", + "properties":{ + "mixto":{ + "type":[ + "string", + "integer" + ] + }, + "anulable":{ + "type":[ + "string", + "null" + ] + } + } + } + } + } + }, + "responses":{ + "200":{ + "description":"ok", + "content":{ + "application/json":{ + "schema":{ + "type":"object", + "properties":{ + "mixto":{ + "type":[ + "string", + "integer" + ] + }, + "anulable":{ + "type":[ + "string", + "null" + ] + }, + "null_primero":{ + "type":[ + "null", + "integer" + ] + }, + "solo_null":{ + "type":[ + "null" + ] + } + } + } + } + } + } + } + } + } + } +} diff --git a/seeds/parserBodyTypeListRequestResult3.1.json b/seeds/parserBodyTypeListRequestResult3.1.json new file mode 100644 index 0000000..c005503 --- /dev/null +++ b/seeds/parserBodyTypeListRequestResult3.1.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "mixto": { + "type": "string" + }, + "anulable": { + "type": "string" + } + } +} diff --git a/seeds/parserBodyTypeListResult3.1.json b/seeds/parserBodyTypeListResult3.1.json new file mode 100644 index 0000000..e3157c3 --- /dev/null +++ b/seeds/parserBodyTypeListResult3.1.json @@ -0,0 +1,30 @@ +{ + "200": { + "type": "object", + "properties": { + "mixto": { + "type": [ + "string", + "integer" + ] + }, + "anulable": { + "type": [ + "string", + "null" + ] + }, + "null_primero": { + "type": [ + "null", + "integer" + ] + }, + "solo_null": { + "type": [ + "null" + ] + } + } + } +} diff --git a/src/parser/openapi3/body.js b/src/parser/openapi3/body.js index 920630b..d1a54f7 100644 --- a/src/parser/openapi3/body.js +++ b/src/parser/openapi3/body.js @@ -4,6 +4,7 @@ const _ = require('lodash') const checkCircularReferences = require('../../utils/circularRef.js'); +const applyNullableTypes = require('../../utils/nullableSchema.js'); const STREAMING_CONTENT_TYPES = [ 'application/jsonl', @@ -113,7 +114,7 @@ module.exports = function () { if (schema) { const withOutRefs = replaceRefs(schema, 1) - bodyResponses[status] = replaceAllOfs(withOutRefs) + bodyResponses[status] = applyNullableTypes(replaceAllOfs(withOutRefs, true)) if (bodyResponses[status].hasOwnProperty('required')) { const requiredWtihoutDuplicates = bodyResponses[status].required.filter((value, index, arr) => { return arr.indexOf(value) === index; @@ -149,7 +150,7 @@ module.exports = function () { entity = replaceRefs(entity, depthLevel + 1); result = _.merge({}, result, entity) - } else if (_.isArray(schema[i]) && i !== 'required') { + } else if (Array.isArray(schema[i]) && i !== 'required') { const arrayResult = [] if (i === 'example' || i === 'examples') { result[i] = schema[i]; @@ -169,7 +170,7 @@ module.exports = function () { return result } - function replaceAllOfs(schema) { + function replaceAllOfs(schema, keepTypeList) { if (!_.isObject(schema)) return schema; if (seenSchemas.has(schema)) { @@ -181,90 +182,113 @@ module.exports = function () { seenSchemas.add(schema); let result = {} - - if (Array.isArray(schema.type)) { - const nonNullType = schema.type.find(t => t !== 'null'); - result.type = nonNullType || schema.type[0]; - } + resolveTypeKeyword(schema, result, keepTypeList) for (let i in schema) { - if (i === 'type' && Array.isArray(schema.type)) { - continue; - } - if (i === 'allOf' && _.isArray(schema[i])) { - let merged = { 'required': [], 'properties': {}, 'type': 'object' } - for (let t in schema[i]) { - - if (schema[i][t]['type'] === 'string') { - merged = schema[i][t] - } else { - - for (let k in schema[i][t]) { - if (k === 'type') { - merged['type'] = schema[i][t][k] - } else if (k === 'required') { - merged['required'] = _.concat(merged['required'], schema[i][t]['required']) - } else if (k === 'properties') { - for (let z in schema[i][t]['properties']) { - merged['properties'][z] = replaceAllOfs(schema[i][t]['properties'][z]) - } - } else if (k === 'allOf') { - let downSchema = replaceAllOfs(schema[k]) - if (downSchema['0']) { - downSchema = downSchema['0'] - } - merged['required'] = _.concat(merged['required'], downSchema['required']) - merged['properties'] = _.merge(merged['properties'], downSchema['properties']) - continue - } else if (k === 'description') { - continue - } else if (k === 'items') { - continue - } else { - console.warn('the property ' + k + ' of allOf is not implemented') - } - } - - } - - } - result = _.merge({}, result, merged) - } else if (_.isArray(schema[i]) && i !== 'required') { - if (schema[i].every(v => !_.isObject(v))) { - result[i] = [...schema[i]]; - } else { - result[i] = schema[i].map(item => replaceAllOfs(item)); - } - } else if (_.isObject(schema[i]) && i !== 'required') { - const value = replaceAllOfs(schema[i]); - - if (_.isPlainObject(value) && _.isPlainObject(result[i])) { - result[i] = _.merge({}, result[i], value); - } else { - result[i] = value; - } - } else { - result[i] = schema[i] - } + result = copyKeyword(schema, result, i, keepTypeList) } + if (typeof result.exclusiveMinimum === 'number') { result.minimum = result.exclusiveMinimum; result.exclusiveMinimum = true; } - if (typeof result.exclusiveMaximum === 'number') { result.maximum = result.exclusiveMaximum; result.exclusiveMaximum = true; } - if (schema.contentEncoding) { result.contentEncoding = schema.contentEncoding; } - if (schema.contentMediaType) { result.contentMediaType = schema.contentMediaType; } return result } + function resolveTypeKeyword(schema, result, keepTypeList) { + if (!Array.isArray(schema.type)) return; + + if (keepTypeList) { + result.type = [...schema.type]; + return; + } + const nonNullType = schema.type.find(t => t !== 'null'); + result.type = nonNullType || schema.type[0]; + } + + function copyKeyword(schema, result, key, keepTypeList) { + if (key === 'type' && Array.isArray(schema.type)) return result; + + if (key === 'allOf' && Array.isArray(schema[key])) { + return _.merge({}, result, mergeAllOf(schema, keepTypeList)) + } + if (Array.isArray(schema[key]) && key !== 'required') { + result[key] = copyArray(schema[key], keepTypeList) + return result; + } + if (_.isObject(schema[key]) && key !== 'required') { + const value = replaceAllOfs(schema[key], keepTypeList); + const fusionable = _.isPlainObject(value) && _.isPlainObject(result[key]); + result[key] = fusionable ? _.merge({}, result[key], value) : value; + return result; + } + result[key] = schema[key] + return result; + } + + function copyArray(values, keepTypeList) { + if (values.every(v => !_.isObject(v))) return [...values]; + return values.map(item => replaceAllOfs(item, keepTypeList)); + } + + function mergeAllOf(schema, keepTypeList) { + let merged = { 'required': [], 'properties': {}, 'type': 'object' } + + for (let t in schema['allOf']) { + const member = schema['allOf'][t] + + if (member['type'] === 'string') { + merged = member + continue + } + for (let k in member) { + mergeAllOfKeyword(merged, member, k, schema, keepTypeList) + } + } + return merged + } + + function mergeAllOfKeyword(merged, member, key, schema, keepTypeList) { + switch (key) { + case 'type': + merged['type'] = member[key] + break + case 'required': + merged['required'] = [].concat(merged['required'], member['required']) + break + case 'properties': + for (let z in member['properties']) { + merged['properties'][z] = replaceAllOfs(member['properties'][z], keepTypeList) + } + break + case 'allOf': { + let downSchema = replaceAllOfs(schema['allOf'], keepTypeList) + if (downSchema['0']) { + downSchema = downSchema['0'] + } + merged['required'] = [].concat(merged['required'], downSchema['required']) + merged['properties'] = _.merge(merged['properties'], downSchema['properties']) + break + } + case 'nullable': + if (member[key] === true) merged['nullable'] = true + break + case 'description': + case 'items': + break + default: + console.warn('the property ' + key + ' of allOf is not implemented') + } + } + }() diff --git a/src/parser/swagger2/body.js b/src/parser/swagger2/body.js index bd4370e..2ea1d3f 100644 --- a/src/parser/swagger2/body.js +++ b/src/parser/swagger2/body.js @@ -4,6 +4,7 @@ const _ = require('lodash'); const checkCircularReferences = require('../../utils/circularRef.js'); +const applyNullableTypes = require('../../utils/nullableSchema.js'); module.exports = function() { @@ -29,7 +30,7 @@ module.exports = function() { _.forEach(endpoint['responses'], function(response, status) { if (response.schema){ const withOutRefs = replaceRefs(response.schema); - bodyResponses[status] = replaceAllOfs(withOutRefs); + bodyResponses[status] = applyNullableTypes(replaceAllOfs(withOutRefs)); if (bodyResponses[status].hasOwnProperty('required')) { const requiredWtihoutDuplicates = bodyResponses[status].required.filter((value, index, arr) => { return arr.indexOf(value) === index; @@ -91,6 +92,8 @@ module.exports = function() { } function replaceAllOfs(schema){ + if (!_.isObject(schema)) return schema; + let result = {}; for (let i in schema) { if (i === 'allOf' && _.isArray(schema[i])){ diff --git a/src/utils/nullableSchema.js b/src/utils/nullableSchema.js new file mode 100644 index 0000000..8e1143f --- /dev/null +++ b/src/utils/nullableSchema.js @@ -0,0 +1,60 @@ +/** Part of APIAddicts. See LICENSE fileor full copyright and licensing details. Supported by Madrid Digital and CloudAPPi **/ + +'use strict' + +const _ = require('lodash'); + +const NULL_TYPE = 'null'; + +module.exports = function() { + + return function applyNullableTypes(schema) { + walk(schema, new WeakSet()); + return schema; + } + + function walk(schema, seen) { + if (!_.isObject(schema) || seen.has(schema)) return; + seen.add(schema); + + if (Array.isArray(schema)) { + schema.forEach(item => walk(item, seen)); + return; + } + + if (isNullable(schema)) allowNull(schema); + + _.forEach(schema.properties, property => walk(property, seen)); + walk(schema.items, seen); + walk(schema.additionalProperties, seen); + walk(schema.oneOf, seen); + walk(schema.anyOf, seen); + walk(schema.allOf, seen); + } + + function isNullable(schema) { + return schema.nullable === true || schema['x-nullable'] === true; + } + + function allowNull(schema) { + if (Array.isArray(schema.enum) && !schema.enum.includes(null)) { + schema.enum.push(null); + } + + if (Array.isArray(schema.type)) { + if (!schema.type.includes(NULL_TYPE)) schema.type.push(NULL_TYPE); + return; + } + + if (typeof schema.type === 'string') { + if (schema.type !== NULL_TYPE) schema.type = [schema.type, NULL_TYPE]; + return; + } + + const alternatives = Array.isArray(schema.oneOf) ? schema.oneOf : schema.anyOf; + if (Array.isArray(alternatives) && !alternatives.some(alternative => alternative?.type === NULL_TYPE)) { + alternatives.push({ type: NULL_TYPE }); + } + } + +}() diff --git a/test/parser-body.js b/test/parser-body.js index 11c8b37..df7aab9 100644 --- a/test/parser-body.js +++ b/test/parser-body.js @@ -59,4 +59,16 @@ describe('parser-body', () => { checkBody('body ref and allof response openapi3.2','openapi3','parserInitialGoodOpenApiExpanded3.2.json','POST','/pets',true,'parserInitialGoodResultRespObject.json'); checkBody('body streaming itemSchema response openapi3.2','openapi3','parserBodyItemSchema3.2.json','GET','/events',true,'parserBodyItemSchemaResult3.2.json'); + + + checkBody('nullable response properties accept null openapi3.0','openapi3','parserBodyNullableInitial3.json','GET','/professionals',true,'parserBodyNullableResult3.json'); + + checkBody('nullable response properties accept null openapi3.1','openapi3','parserBodyNullableInitial3.1.json','GET','/professionals',true,'parserBodyNullableResult3.1.json'); + + checkBody('nullable response properties accept null swagger2','swagger2','parserBodyNullableInitialSwagger2.json','GET','/professionals',true,'parserBodyNullableResultSwagger2.json'); + + + checkBody('response schema keeps every declared type openapi3.1','openapi3','parserBodyTypeListInitial3.1.json','POST','/valores',true,'parserBodyTypeListResult3.1.json'); + + checkBody('request schema collapses the type list to a single type openapi3.1','openapi3','parserBodyTypeListInitial3.1.json','POST','/valores',false,'parserBodyTypeListRequestResult3.1.json'); }); \ No newline at end of file diff --git a/test/utils-nullableSchema.js b/test/utils-nullableSchema.js new file mode 100644 index 0000000..fe393fe --- /dev/null +++ b/test/utils-nullableSchema.js @@ -0,0 +1,78 @@ +/** Part of APIAddicts. See LICENSE fileor full copyright and licensing details. Supported by Madrid Digital and CloudAPPi **/ + +const assert = require('node:assert'); + +const applyNullableTypes = require('../src/utils/nullableSchema.js'); + +describe('utils-nullableSchema', () => { + + it('turns a nullable scalar type into a type list including null', () => { + const schema = applyNullableTypes({ type: 'string', nullable: true, maxLength: 70 }); + + assert.deepStrictEqual(schema, { type: ['string', 'null'], nullable: true, maxLength: 70 }); + }); + + it('accepts the swagger2 x-nullable extension', () => { + const schema = applyNullableTypes({ type: 'integer', 'x-nullable': true }); + + assert.deepStrictEqual(schema, { type: ['integer', 'null'], 'x-nullable': true }); + }); + + it('adds null to an existing type list only once', () => { + const schema = applyNullableTypes({ type: ['string', 'null'], nullable: true }); + + assert.deepStrictEqual(schema, { type: ['string', 'null'], nullable: true }); + }); + + it('adds null to the allowed values of a nullable enum', () => { + const schema = applyNullableTypes({ type: 'string', nullable: true, enum: ['ALTA', 'BAJA'] }); + + assert.deepStrictEqual(schema, { type: ['string', 'null'], nullable: true, enum: ['ALTA', 'BAJA', null] }); + }); + + it('adds a null alternative when a nullable node has no type but delegates to oneOf', () => { + const schema = applyNullableTypes({ nullable: true, oneOf: [{ type: 'string' }] }); + + assert.deepStrictEqual(schema, { nullable: true, oneOf: [{ type: 'string' }, { type: 'null' }] }); + }); + + it('leaves a nullable node without type or alternatives untouched', () => { + const schema = applyNullableTypes({ nullable: true, description: 'anything goes' }); + + assert.deepStrictEqual(schema, { nullable: true, description: 'anything goes' }); + }); + + it('leaves nodes that are not nullable untouched', () => { + const schema = applyNullableTypes({ type: 'string', enum: ['ALTA'] }); + + assert.deepStrictEqual(schema, { type: 'string', enum: ['ALTA'] }); + }); + + it('reaches nullable nodes nested in properties, items and additionalProperties', () => { + const schema = applyNullableTypes({ + type: 'object', + properties: { + telefonos: { type: 'array', items: { type: 'string', nullable: true } }, + extras: { type: 'object', additionalProperties: { type: 'boolean', nullable: true } } + } + }); + + assert.deepStrictEqual(schema, { + type: 'object', + properties: { + telefonos: { type: 'array', items: { type: ['string', 'null'], nullable: true } }, + extras: { type: 'object', additionalProperties: { type: ['boolean', 'null'], nullable: true } } + } + }); + }); + + it('does not loop forever on a self referencing schema', () => { + const schema = { type: 'object', properties: { hijo: { type: 'object', nullable: true } } }; + schema.properties.hijo.properties = { padre: schema }; + + applyNullableTypes(schema); + + assert.deepStrictEqual(schema.properties.hijo.type, ['object', 'null']); + }); + +});