Repository: BRIKEV/express-jsdoc-swagger (js)

Issue:
[Feature] Allowing `null` as property data type
**Is your feature request related to a problem? Please describe.**
There can be scenarios on which any of our properties can also take `null` values, and such case is not considered in the current state of the library. It would be good to modify the library to support this, so we can specify whether our fields are nullable or not.

**Describe the solution you'd like**
We could add `null` as a possible data type to specify that a given property can contain `null` values:

```
/**
 * @property {string} notNullProp - prop that does not support null values
 * @property {(string | null)} nullableProp - prop that can contain null values
 */
```

When generating the Swagger UI docs, this `null` data type would translate to [`nullable: true`](https://swagger.io/docs/specification/data-models/data-types/#null).

The syntax used to define multiple types in the example above is based on the specifications found in the [official JSDoc website](https://jsdoc.app/tags-type.html). 

Pull request description:
feat: add support to `nullable` properties
### What kind of change does this PR introduce?
 - [ ] Bugfix
 - [X] Feature
 - [X] Test
 - [ ] Docs
 - [ ] Refactor
 - [ ] Build-related changes
 - [ ] Other, please describe:

### Description:

This PR closes #144 by adding support to `nullable` properties. It's now possible for the user to declare that a property or parameter can either be a value of a given data type or take the value `null`. The syntax for doing this is illustrated in the example below:

```js
/**
 * Model with nullable property
 * @typedef {object} Entity
 * @property {oneOf|string|null} nullableProp
 */
```

As we can see here, `null` is added to the list of data types supported by our property, as if it were a type on itself. We'll use `oneOf` to specify that `nullableProp` can either contain a string or a `null` value. 

This `null` data type will translate to [`nullable: true`](https://swagger.io/docs/specification/data-models/data-types/#null) in the property definition object that will be added to our schemas in the generated Open API specification. 

```
components: {
  ​schemas: {
    Entity: {
      type: 'object',
      description: '',
      ​properties: {
        ​nullableProp: {
          ​type: 'string',
          ​description: '',
          nullable: false
        }
     }
   }
}
```

### Task list:

:cyclone: **Components**
- [x] If `null` is inside `types.elements` array, set property as `nullable`.
- [x] If element's type is `NullLiteral` in `refSchema`, return object with `nullable` set to `true`.
- [x] If `oneOf` or `anyOf` is followed by a single element, don't wrap the type in `oneOf` or `anyOf` block. 

:heavy_check_mark: **Test**
- [x] Update expected output for nullable property validation in `combineSchema` test. 

:memo: **Documentation**
- [x] Add new example with nullable parameters.

Patch:
diff --git a/examples/nullableFields/index.js b/examples/nullableFields/index.js
new file mode 100644
index 0000000..5214296
--- /dev/null
+++ b/examples/nullableFields/index.js
@@ -0,0 +1,58 @@
+const express = require('express');
+
+const logger = require('../utils/logger');
+const expressJSDocSwagger = require('../..');
+
+const options = {
+  info: {
+    version: '1.0.0',
+    title: 'Albums store',
+    license: {
+      name: 'MIT',
+    },
+  },
+  filesPattern: './index.js',
+  baseDir: __dirname,
+};
+
+const app = express();
+const port = 3000;
+
+expressJSDocSwagger(app)(options);
+
+/**
+ * @typedef {object} Song
+ * @property {number} id.required - Id
+ * @property {string} title.required - The title
+ * @property {string} artist - The artist
+ * @property {integer} year - The year - int64
+ * @property {oneOf|string|null} album - The album to which the song belongs (if any)
+ */
+
+/**
+ * POST /api/v1/songs
+ * @summary Nullable props in request body
+ * @param {Song} request.body.required - Song to add
+ * @return {string} 200 - Success message
+ */
+ app.post('/api/v1/songs', (req, res) => res.send('You saved a song!'));
+
+/**
+ * GET /api/v1/song
+ * @summary Nullable props in response
+ * @return {array<Song>} 200 - List of songs
+ */
+app.get('/api/v1/song', (req, res) => res.json([
+  {
+    id: 1,
+    title: 'Song from album',
+    album: 'Collection',
+  },
+  {
+    id: 2,
+    title: 'Song with no album',
+    album: null,
+  },
+]));
+
+app.listen(port, () => logger.info(`Example app listening at http://localhost:${port}`));
diff --git a/examples/package.json b/examples/package.json
index 3034408..28c4db4 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -31,7 +31,8 @@
     "combineSchemas": "node combineSchemas/index.js",
     "eventEmitter": "node eventEmitter/index.js",
     "eventEmitter:multipleInstance": "node eventEmitter/multipleInstance.js",
-    "merge": "node merge/simple.js"
+    "merge": "node merge/simple.js",
+    "nullableFields": "node nullableFields/index.js"
   },
   "keywords": [],
   "author": "",
diff --git a/transforms/paths/schema.js b/transforms/paths/schema.js
index 3dec472..85ab9ca 100644
--- a/transforms/paths/schema.js
+++ b/transforms/paths/schema.js
@@ -11,6 +11,7 @@ const getSchema = (entity, message) => (type, enumValues = []) => {
   let schema = {
     ...refSchema(nameType),
   };
+
   schema = {
     ...schema,
     ...combineSchema(type.elements),
diff --git a/transforms/utils/combineSchema.js b/transforms/utils/combineSchema.js
index e4f685f..d6c19e2 100644
--- a/transforms/utils/combineSchema.js
+++ b/transforms/utils/combineSchema.js
@@ -8,15 +8,34 @@ const validType = type => VALID_TYPES.includes(type);
 const combineSchema = elements => {
   let schema = {};
   if (!elements || elements.length === 0) return schema;
+
+  // If param has multiple types, check if null is part of them and remove it from array
+  const nullIndex = elements.findIndex(el => el.type === 'NullLiteral');
+  if (nullIndex > 0) {
+    elements.splice(nullIndex, 1);
+    schema = { nullable: true };
+  }
+
   const schemaType = elements[0].name;
   const [, ...types] = elements;
+
   if (validType(schemaType)) {
-    schema = {
-      [schemaType]: types.map(type => refSchema(type)),
-    };
+    if (types.length > 1 || schemaType === 'allOf') {
+      schema = {
+        ...schema,
+        [schemaType]: types.map(type => refSchema(type)),
+      };
+    } else {
+      // If there's only a type in the list, don't wrap it in 'oneOf' or 'anyOf'
+      schema = {
+        ...schema,
+        ...refSchema(types[0]),
+      };
+    }
   } else {
     errorMessage(`SchemaType ${schemaType} invalid, it should be one of these ${VALID_TYPES.join(', ')}`);
   }
+
   return schema;
 };
 
diff --git a/transforms/utils/refSchema.js b/transforms/utils/refSchema.js
index 9ba3466..43fd0db 100644
--- a/transforms/utils/refSchema.js
+++ b/transforms/utils/refSchema.js
@@ -31,7 +31,8 @@ const refSchema = value => {
 
   const nameValue = value.name || value;
   // support null
-  if (nameValue.type === 'NullLiteral') return {};
+  if (nameValue.type === 'NullLiteral') return { nullable: true };
+
   const isPrimitive = validateTypes(nameValue);
   return isPrimitive ? { type: nameValue } : { $ref: `${REF_ROUTE}${nameValue}` };
 };


Test patch:
diff --git a/test/e2e/parameters/parameters.test.js b/test/e2e/parameters/parameters.test.js
index 3475c6f..35aff8f 100644
--- a/test/e2e/parameters/parameters.test.js
+++ b/test/e2e/parameters/parameters.test.js
@@ -12,7 +12,7 @@ const options = {
   baseDir: __dirname,
 };
 
-test('should parse components jsdoc from jsdoc-example', async () => {
+test('should parse parameters from jsdoc-example', async () => {
   const expected = {
     openapi: '3.0.0',
     info: {
diff --git a/test/transforms/paths/combineSchemas.test.js b/test/transforms/paths/combineSchemas.test.js
index c1bff98..3871f31 100644
--- a/test/transforms/paths/combineSchemas.test.js
+++ b/test/transforms/paths/combineSchemas.test.js
@@ -259,8 +259,8 @@ test('should not parse component with anyOf array keyword', () => {
                     type: 'string',
                   },
                 },
-                {},
               ],
+              nullable: true,
             },
           },
         },