From fce2753f3931d2155ceac88568cf9f0856f993e3 Mon Sep 17 00:00:00 2001 From: Iivo Kapanen Date: Fri, 16 Aug 2024 14:57:17 +0300 Subject: [PATCH] Add support for nullish option in OpenAPI 3.0.3 - Handle nullish with a union of Typebox's Type.Null() and the actual type so that the schema accepts null as value --- src/writer.js | 73 ++++++++++++++++++++++++++++------------- test/index.js | 10 ++++++ test/index.js.snapshot | 4 +++ test/test-nullable.yaml | 27 +++++++++++++++ 4 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 test/test-nullable.yaml diff --git a/src/writer.js b/src/writer.js index 1a94169..622c0a6 100644 --- a/src/writer.js +++ b/src/writer.js @@ -154,10 +154,11 @@ export const write = async (source, opts = {}) => { } function writeType(schema, isRequired = false) { + const isNullable = !!schema.nullable schema = cleanupSchema(schema) if (schema[kRef]) { - writeRef(schema, isRequired) + writeRef(schema, isRequired, isNullable) return } @@ -167,7 +168,7 @@ export const write = async (source, opts = {}) => { } if (schema.const) { - writeLiteral(schema, isRequired) + writeLiteral(schema, isRequired, isNullable) return } @@ -175,12 +176,12 @@ export const write = async (source, opts = {}) => { writeCompound({ ...schema, anyOf: schema.enum, - }, isRequired) + }, isRequired, isNullable) return } if (schema.anyOf || schema.allOf || schema.oneOf) { - writeCompound(schema, isRequired) + writeCompound(schema, isRequired, isNullable) return } @@ -194,21 +195,39 @@ export const write = async (source, opts = {}) => { } if (schema.type === 'object') { - writeObject(schema, isRequired) + writeObject(schema, isRequired, isNullable) return } if (schema.type === 'array') { - writeArray(schema, isRequired) + writeArray(schema, isRequired, isNullable) return } if (schema.type in scalarTypes) { - writeScalar(schema, isRequired) + writeScalar(schema, isRequired, isNullable) } } - function writeLiteral(schema, isRequired = false) { + /** + * @param {boolean} isRequired + * @param {boolean} isNullable + */ + function startNullish(isRequired, isNullable) { + if (!isRequired) w.write('T.Optional(') + if (isNullable) w.write('T.Union([T.Null(), ') + } + + /** + * @param {boolean} isRequired + * @param {boolean} isNullable + */ + function endNullish(isRequired, isNullable) { + if (isNullable) w.write('])') + if (!isRequired) w.write(')') + } + + function writeLiteral(schema, isRequired = false, isNullable = false) { let { const: value } = schema let options = extractSchemaOptions(schema) @@ -221,10 +240,12 @@ export const write = async (source, opts = {}) => { options = '' } - w.write(`${isRequired ? '' : 'T.Optional('}T.Literal(${value}${options})${isRequired ? '' : ')'}`) + startNullish(isRequired, isNullable) + w.write(`T.Literal(${value}${options})`) + endNullish(isRequired, isNullable) } - function writeRef(schema, isRequired = false) { + function writeRef(schema, isRequired = false, isNullable = false) { let options = extractSchemaOptions(schema) if (Object.keys(options).length) { options = `,${JSON.stringify(options)}` @@ -233,13 +254,15 @@ export const write = async (source, opts = {}) => { } const value = `CloneType(${schema[kRef]}${options})` - w.write(`${isRequired ? '' : 'T.Optional('}${value}${isRequired ? '' : ')'}`) + startNullish(isRequired, isNullable) + w.write(`${value}`) + endNullish(isRequired, isNullable) } - function writeCompound(schema, isRequired = false) { + function writeCompound(schema, isRequired = false, isNullable = false) { const { enum: _, type, anyOf, allOf, oneOf, ...options } = schema - if (!isRequired) w.write('T.Optional(') + startNullish(isRequired, isNullable) const compoundType = anyOf ? 'T.Union' // anyOf @@ -271,13 +294,13 @@ export const write = async (source, opts = {}) => { w.write(')') - if (!isRequired) w.write(')') + endNullish(isRequired, isNullable) } - function writeObject(schema, isRequired = false) { + function writeObject(schema, isRequired = false, isNullable = false) { const { type, properties = {}, required = [], ...options } = schema - if (!isRequired) w.write('T.Optional(') + startNullish(isRequired, isNullable) let optionsString const optionsKeys = Object.keys(options) @@ -330,14 +353,16 @@ export const write = async (source, opts = {}) => { w.write(')') } - if (!isRequired) w.write(')') + endNullish(isRequired, isNullable) } - function writeScalar(schema, isRequired = false) { + function writeScalar(schema, isRequired = false, isNullable = false) { let { type, ...options } = schema if (type === 'string' && options?.format === 'binary') { - w.write(`${isRequired ? '' : 'T.Optional('}Binary()${isRequired ? '' : ')'}`) + startNullish(isRequired, isNullable) + w.write('Binary()') + endNullish(isRequired, isNullable) return } @@ -347,13 +372,15 @@ export const write = async (source, opts = {}) => { options = '' } - w.write(`${isRequired ? '' : 'T.Optional('}T.${scalarTypes[type]}(${options})${isRequired ? '' : ')'}`) + startNullish(isRequired, isNullable) + w.write(`T.${scalarTypes[type]}(${options})`) + endNullish(isRequired, isNullable) } - function writeArray(schema, isRequired = false) { + function writeArray(schema, isRequired = false, isNullable = false) { const { type, items, ...options } = schema - if (!isRequired) w.write('T.Optional(') + startNullish(isRequired, isNullable) const isArray = Array.isArray(items) @@ -386,7 +413,7 @@ export const write = async (source, opts = {}) => { w.write(')') - if (!isRequired) w.write(')') + endNullish(isRequired, isNullable) } function buildSchema(paths, pathKey, method) { diff --git a/test/index.js b/test/index.js index e76789a..439ca1d 100644 --- a/test/index.js +++ b/test/index.js @@ -390,3 +390,13 @@ test('parse some openapi examples', async (t) => { await write('https://raw.githubusercontent.com/openai/openai-openapi/master/openapi.yaml') }) }) + +test('nullable test', async (t) => { + await writeFile('./tmp/test-nullable.yaml.js', await write('./test/test-nullable.yaml')) + t.assert.snapshot(await readFile('./tmp/petstore.yaml.js', 'utf8')) + const { components } = await import('../tmp/test-nullable.yaml.js') + assert.deepEqual(components.schemas.Test, Type.Union([Type.Null(), Type.Object({ + testStr: Type.Optional(Type.Union([Type.Null(), Type.String({ minLength: 2, maxLength: 2 })])), + testArr: Type.Union([Type.Null(), Type.Array(Type.Number())]), + })])) +}) diff --git a/test/index.js.snapshot b/test/index.js.snapshot index 52a7492..f30fc2f 100644 --- a/test/index.js.snapshot +++ b/test/index.js.snapshot @@ -6,6 +6,10 @@ exports[`basic test > esm 1`] = ` "/* eslint eslint-comments/no-unlimited-disable: off */\\n/* eslint-disable */\\n// This document was generated automatically by openapi-box\\n\\n/**\\n * @typedef {import('@sinclair/typebox').TSchema} TSchema\\n */\\n\\n/**\\n * @template {TSchema} T\\n * @typedef {import('@sinclair/typebox').Static} Static\\n */\\n\\n/**\\n * @typedef {import('@sinclair/typebox').SchemaOptions} SchemaOptions\\n */\\n\\n/**\\n * @typedef {{\\n * [Path in keyof typeof schema]: {\\n * [Method in keyof typeof schema[Path]]: {\\n * [Prop in keyof typeof schema[Path][Method]]: typeof schema[Path][Method][Prop] extends TSchema ?\\n * Static :\\n * undefined\\n * }\\n * }\\n * }} SchemaType\\n */\\n\\n/**\\n * @typedef {{\\n * [ComponentType in keyof typeof _components]: {\\n * [ComponentName in keyof typeof _components[ComponentType]]: typeof _components[ComponentType][ComponentName] extends TSchema ?\\n * Static :\\n * undefined\\n * }\\n * }} ComponentType\\n */\\n\\nimport { Type as T, TypeRegistry, Kind, CloneType } from '@sinclair/typebox'\\nimport { Value } from '@sinclair/typebox/value'\\n\\n/**\\n * @typedef {{\\n * [Kind]: 'Binary'\\n * static: string | File | Blob | Uint8Array\\n * anyOf: [{\\n * type: 'object',\\n * additionalProperties: true\\n * }, {\\n * type: 'string',\\n * format: 'binary'\\n * }]\\n * } & TSchema} TBinary\\n */\\n\\n/**\\n * @returns {TBinary}\\n */\\nconst Binary = () => {\\n /**\\n * @param {TBinary} schema\\n * @param {unknown} value\\n * @returns {boolean}\\n */\\n function BinaryCheck(schema, value) {\\n const type = Object.prototype.toString.call(value)\\n return (\\n type === '[object Blob]' ||\\n type === '[object File]' ||\\n type === '[object String]' ||\\n type === '[object Uint8Array]'\\n )\\n }\\n\\n if (!TypeRegistry.Has('Binary')) TypeRegistry.Set('Binary', BinaryCheck)\\n\\n return /** @type {TBinary} */ ({\\n anyOf: [\\n {\\n type: 'object',\\n additionalProperties: true\\n },\\n {\\n type: 'string',\\n format: 'binary'\\n }\\n ],\\n [Kind]: 'Binary'\\n })\\n}\\n\\nconst ComponentsSchemasDef0 = T.Object({\\n lat: T.Number(),\\n long: T.Number()\\n})\\nconst ComponentsSchemasDef1 = T.Array(\\n T.Object({\\n title: T.String(),\\n address: T.String(),\\n coordinates: CloneType(ComponentsSchemasDef0)\\n })\\n)\\n\\nconst schema = {\\n '/hello': {\\n GET: {\\n args: T.Void(),\\n data: T.Any({ 'x-status-code': '200' }),\\n error: T.Union([T.Any({ 'x-status-code': 'default' })])\\n }\\n },\\n '/hello-typed': {\\n GET: {\\n args: T.Void(),\\n data: T.Object(\\n {\\n hello: T.Boolean()\\n },\\n {\\n 'x-status-code': '200',\\n 'x-content-type': 'application/json'\\n }\\n ),\\n error: T.Union([\\n T.Object(\\n {\\n error: T.String()\\n },\\n {\\n 'x-status-code': '404',\\n 'x-content-type': 'application/json'\\n }\\n )\\n ])\\n }\\n },\\n '/multiple-content': {\\n GET: {\\n args: T.Void(),\\n data: T.Object(\\n {\\n name: T.String()\\n },\\n {\\n 'x-status-code': '200',\\n 'x-content-type': 'application/json'\\n }\\n ),\\n error: T.Union([\\n T.Object(\\n {\\n error: T.String()\\n },\\n {\\n 'x-status-code': '404',\\n 'x-content-type': 'application/json'\\n }\\n )\\n ])\\n }\\n },\\n '/some-route/{id}': {\\n POST: {\\n args: T.Object({\\n headers: T.Object({\\n auth: T.String({ 'x-in': 'header' })\\n }),\\n params: T.Object({\\n id: T.String({ 'x-in': 'path' })\\n }),\\n query: T.Object({\\n filter: T.String({ 'x-in': 'query' }),\\n address: T.Array(T.String(), { 'x-in': 'query' }),\\n deep: T.Object(\\n {\\n deepTitle: T.Optional(T.String())\\n },\\n {\\n 'x-in': 'query'\\n }\\n )\\n }),\\n body: T.Object(\\n {\\n human: T.Object({\\n name: T.String(),\\n age: T.Optional(T.Number()),\\n gender: T.Union([T.Literal('batman'), T.Literal('joker')])\\n }),\\n address: CloneType(ComponentsSchemasDef1),\\n recursive: T.Object(\\n {},\\n {\\n additionalProperties: true\\n }\\n )\\n },\\n {\\n 'x-content-type': 'application/json'\\n }\\n )\\n }),\\n data: T.Object(\\n {\\n params: T.Object({\\n id: T.Optional(T.String())\\n }),\\n query: T.Object({\\n filter: T.String(),\\n address: T.Array(T.String()),\\n deep: T.Object({\\n deepTitle: T.String()\\n })\\n }),\\n body: T.Object({\\n human: T.Object({\\n name: T.String(),\\n age: T.Optional(T.Number()),\\n gender: T.Union([T.Literal('batman'), T.Literal('joker')])\\n }),\\n address: CloneType(ComponentsSchemasDef1),\\n recursive: T.Object(\\n {},\\n {\\n additionalProperties: true\\n }\\n )\\n })\\n },\\n {\\n 'x-status-code': '201',\\n 'x-content-type': 'application/json'\\n }\\n ),\\n error: T.Union([T.Any({ 'x-status-code': 'default' })])\\n }\\n }\\n}\\n\\nconst _components = {\\n schemas: {\\n 'def-0': CloneType(ComponentsSchemasDef0),\\n 'def-1': CloneType(ComponentsSchemasDef1)\\n }\\n}\\n\\nexport { schema, _components as components }\\n" `; +exports[`nullable test 1`] = ` +"/* eslint eslint-comments/no-unlimited-disable: off */\\n/* eslint-disable */\\n// This document was generated automatically by openapi-box\\n\\n/**\\n * @typedef {import('@sinclair/typebox').TSchema} TSchema\\n */\\n\\n/**\\n * @template {TSchema} T\\n * @typedef {import('@sinclair/typebox').Static} Static\\n */\\n\\n/**\\n * @typedef {import('@sinclair/typebox').SchemaOptions} SchemaOptions\\n */\\n\\n/**\\n * @typedef {{\\n * [Path in keyof typeof schema]: {\\n * [Method in keyof typeof schema[Path]]: {\\n * [Prop in keyof typeof schema[Path][Method]]: typeof schema[Path][Method][Prop] extends TSchema ?\\n * Static :\\n * undefined\\n * }\\n * }\\n * }} SchemaType\\n */\\n\\n/**\\n * @typedef {{\\n * [ComponentType in keyof typeof _components]: {\\n * [ComponentName in keyof typeof _components[ComponentType]]: typeof _components[ComponentType][ComponentName] extends TSchema ?\\n * Static :\\n * undefined\\n * }\\n * }} ComponentType\\n */\\n\\nimport { Type as T, TypeRegistry, Kind, CloneType } from '@sinclair/typebox'\\nimport { Value } from '@sinclair/typebox/value'\\n\\n/**\\n * @typedef {{\\n * [Kind]: 'Binary'\\n * static: string | File | Blob | Uint8Array\\n * anyOf: [{\\n * type: 'object',\\n * additionalProperties: true\\n * }, {\\n * type: 'string',\\n * format: 'binary'\\n * }]\\n * } & TSchema} TBinary\\n */\\n\\n/**\\n * @returns {TBinary}\\n */\\nconst Binary = () => {\\n /**\\n * @param {TBinary} schema\\n * @param {unknown} value\\n * @returns {boolean}\\n */\\n function BinaryCheck(schema, value) {\\n const type = Object.prototype.toString.call(value)\\n return (\\n type === '[object Blob]' ||\\n type === '[object File]' ||\\n type === '[object String]' ||\\n type === '[object Uint8Array]'\\n )\\n }\\n\\n if (!TypeRegistry.Has('Binary')) TypeRegistry.Set('Binary', BinaryCheck)\\n\\n return /** @type {TBinary} */ ({\\n anyOf: [\\n {\\n type: 'object',\\n additionalProperties: true\\n },\\n {\\n type: 'string',\\n format: 'binary'\\n }\\n ],\\n [Kind]: 'Binary'\\n })\\n}\\n\\nconst ComponentsSchemasError = T.Object({\\n code: T.Integer({ format: 'int32' }),\\n message: T.String()\\n})\\nconst ComponentsSchemasPet = T.Object({\\n id: T.Integer({ format: 'int64' }),\\n name: T.String(),\\n tag: T.Optional(T.String())\\n})\\nconst ComponentsSchemasPets = T.Array(CloneType(ComponentsSchemasPet))\\n\\nconst schema = {\\n '/pets': {\\n GET: {\\n args: T.Optional(\\n T.Object({\\n query: T.Optional(\\n T.Object({\\n limit: T.Optional(T.Integer({ format: 'int32', 'x-in': 'query' }))\\n })\\n )\\n })\\n ),\\n data: CloneType(ComponentsSchemasPets, {\\n 'x-status-code': '200',\\n 'x-content-type': 'application/json'\\n }),\\n error: T.Union([\\n CloneType(ComponentsSchemasError, {\\n 'x-status-code': 'default',\\n 'x-content-type': 'application/json'\\n })\\n ])\\n },\\n POST: {\\n args: T.Void(),\\n data: T.Any({ 'x-status-code': '201' }),\\n error: T.Union([\\n CloneType(ComponentsSchemasError, {\\n 'x-status-code': 'default',\\n 'x-content-type': 'application/json'\\n })\\n ])\\n }\\n },\\n '/pets/{petId}': {\\n GET: {\\n args: T.Object({\\n params: T.Object({\\n petId: T.String({ 'x-in': 'path' })\\n })\\n }),\\n data: CloneType(ComponentsSchemasPet, {\\n 'x-status-code': '200',\\n 'x-content-type': 'application/json'\\n }),\\n error: T.Union([\\n CloneType(ComponentsSchemasError, {\\n 'x-status-code': 'default',\\n 'x-content-type': 'application/json'\\n })\\n ])\\n }\\n }\\n}\\n\\nconst _components = {\\n parameters: {\\n skipParam: T.Integer({ format: 'int32', 'x-in': 'query' }),\\n limitParam: T.Integer({ format: 'int32', 'x-in': 'query' })\\n },\\n responses: {\\n NotFound: T.Any({}),\\n IllegalInput: T.Any({}),\\n GeneralError: CloneType(ComponentsSchemasError, {\\n 'x-content-type': 'application/json'\\n })\\n },\\n requestBodies: {\\n Pet: CloneType(ComponentsSchemasPet, {\\n 'x-content-type': 'application/json'\\n })\\n },\\n schemas: {\\n Error: CloneType(ComponentsSchemasError),\\n Pet: CloneType(ComponentsSchemasPet),\\n Pets: CloneType(ComponentsSchemasPets)\\n }\\n}\\n\\nexport { schema, _components as components }\\n" +`; + exports[`petstore.json 1`] = ` "/* eslint eslint-comments/no-unlimited-disable: off */\\n/* eslint-disable */\\n// This document was generated automatically by openapi-box\\n\\n/**\\n * @typedef {import('@sinclair/typebox').TSchema} TSchema\\n */\\n\\n/**\\n * @template {TSchema} T\\n * @typedef {import('@sinclair/typebox').Static} Static\\n */\\n\\n/**\\n * @typedef {import('@sinclair/typebox').SchemaOptions} SchemaOptions\\n */\\n\\n/**\\n * @typedef {{\\n * [Path in keyof typeof schema]: {\\n * [Method in keyof typeof schema[Path]]: {\\n * [Prop in keyof typeof schema[Path][Method]]: typeof schema[Path][Method][Prop] extends TSchema ?\\n * Static :\\n * undefined\\n * }\\n * }\\n * }} SchemaType\\n */\\n\\n/**\\n * @typedef {{\\n * [ComponentType in keyof typeof _components]: {\\n * [ComponentName in keyof typeof _components[ComponentType]]: typeof _components[ComponentType][ComponentName] extends TSchema ?\\n * Static :\\n * undefined\\n * }\\n * }} ComponentType\\n */\\n\\nimport { Type as T, TypeRegistry, Kind, CloneType } from '@sinclair/typebox'\\nimport { Value } from '@sinclair/typebox/value'\\n\\n/**\\n * @typedef {{\\n * [Kind]: 'Binary'\\n * static: string | File | Blob | Uint8Array\\n * anyOf: [{\\n * type: 'object',\\n * additionalProperties: true\\n * }, {\\n * type: 'string',\\n * format: 'binary'\\n * }]\\n * } & TSchema} TBinary\\n */\\n\\n/**\\n * @returns {TBinary}\\n */\\nconst Binary = () => {\\n /**\\n * @param {TBinary} schema\\n * @param {unknown} value\\n * @returns {boolean}\\n */\\n function BinaryCheck(schema, value) {\\n const type = Object.prototype.toString.call(value)\\n return (\\n type === '[object Blob]' ||\\n type === '[object File]' ||\\n type === '[object String]' ||\\n type === '[object Uint8Array]'\\n )\\n }\\n\\n if (!TypeRegistry.Has('Binary')) TypeRegistry.Set('Binary', BinaryCheck)\\n\\n return /** @type {TBinary} */ ({\\n anyOf: [\\n {\\n type: 'object',\\n additionalProperties: true\\n },\\n {\\n type: 'string',\\n format: 'binary'\\n }\\n ],\\n [Kind]: 'Binary'\\n })\\n}\\n\\nconst ComponentsSchemasError = T.Object({\\n code: T.Integer({ format: 'int32' }),\\n message: T.String()\\n})\\nconst ComponentsSchemasPet = T.Object({\\n id: T.Integer({ format: 'int64' }),\\n name: T.String(),\\n tag: T.Optional(T.String())\\n})\\nconst ComponentsSchemasPets = T.Array(CloneType(ComponentsSchemasPet))\\n\\nconst schema = {\\n '/pets': {\\n GET: {\\n args: T.Optional(\\n T.Object({\\n query: T.Optional(\\n T.Object({\\n limit: T.Optional(T.Integer({ format: 'int32', 'x-in': 'query' }))\\n })\\n )\\n })\\n ),\\n data: CloneType(ComponentsSchemasPets, {\\n 'x-status-code': '200',\\n 'x-content-type': 'application/json'\\n }),\\n error: T.Union([\\n CloneType(ComponentsSchemasError, {\\n 'x-status-code': 'default',\\n 'x-content-type': 'application/json'\\n })\\n ])\\n },\\n POST: {\\n args: T.Void(),\\n data: T.Any({ 'x-status-code': '201' }),\\n error: T.Union([\\n CloneType(ComponentsSchemasError, {\\n 'x-status-code': 'default',\\n 'x-content-type': 'application/json'\\n })\\n ])\\n }\\n },\\n '/pets/{petId}': {\\n GET: {\\n args: T.Object({\\n params: T.Object({\\n petId: T.String({ 'x-in': 'path' })\\n })\\n }),\\n data: CloneType(ComponentsSchemasPet, {\\n 'x-status-code': '200',\\n 'x-content-type': 'application/json'\\n }),\\n error: T.Union([\\n CloneType(ComponentsSchemasError, {\\n 'x-status-code': 'default',\\n 'x-content-type': 'application/json'\\n })\\n ])\\n }\\n }\\n}\\n\\nconst _components = {\\n parameters: {\\n skipParam: T.Integer({ format: 'int32', 'x-in': 'query' }),\\n limitParam: T.Integer({ format: 'int32', 'x-in': 'query' })\\n },\\n responses: {\\n NotFound: T.Any({}),\\n IllegalInput: T.Any({}),\\n GeneralError: CloneType(ComponentsSchemasError, {\\n 'x-content-type': 'application/json'\\n })\\n },\\n requestBodies: {\\n Pet: CloneType(ComponentsSchemasPet, {\\n 'x-content-type': 'application/json'\\n })\\n },\\n schemas: {\\n Error: CloneType(ComponentsSchemasError),\\n Pet: CloneType(ComponentsSchemasPet),\\n Pets: CloneType(ComponentsSchemasPets)\\n }\\n}\\n\\nexport { schema, _components as components }\\n" `; diff --git a/test/test-nullable.yaml b/test/test-nullable.yaml new file mode 100644 index 0000000..7f252c9 --- /dev/null +++ b/test/test-nullable.yaml @@ -0,0 +1,27 @@ +openapi: 3.0.3 +info: + description: Title + version: 1.0.0 +servers: + - url: https + +components: + schemas: + Test: + type: object + nullable: true + required: + - testArr + properties: + testStr: + type: string + description: Should be a 2 character string, null or undefined + minLength: 2 + maxLength: 2 + nullable: true + testArr: + type: array + description: Should be an array of numbers or null + items: + type: number + nullable: true