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

Issue:
Example request and response
How to add Example request and response to an endpoint. When writing API documentation, you would usually want to provide your users with sample request body to guide how the request payload should be constructed and formatted and also provide at least a sample returned response to showcase the format of the response 

**Describe the solution you'd like**
/**
*@example  {requestBody} - a sample request body
 {
          "username": "LA-AM-0001",
          "password": "PMNmj@123",
          "firstName": "segufkn",
          "lastName": "IDEklmhj",
          "dob": "2000-07-08",
          "gender": "Male",
          "role": "master-agent",
          "phone": {
            "code": "234",
            "number": "807489388"
          }
*@example  {responseBody} - a sample request body
{
          "status": true,
          "message": "user registered successfully",
          "data": {
            "username": "LA-AM-0001",
            "firstName": "segufkn",
            "lastName": "IDEklmhj",
            "dob": "04-26-202",
            "gender": "Male",
            "role": "master-agent",
            "phone": {
              "code": "234",
              "number": "807489388"
            },
            "wallet": {
              "balance": 20000,
              "ledgerBalance": 20000,
              "trust": 0
            }
          }
        }
*/





Pull request description:
feat: add support for `example` annotations
### What kind of change does this PR introduce? (check at least one)
 - ~Bugfix~
 - Feature
 - Test
 - Docs
 - ~Refactor~
 - Build-related changes
 - ~Other, please describe:~

### Description:

This PR adds support for `@example` annotations. These can be used to add examples on the expected input / output for every endpoint of our API. More details on its usage and specifications can be found in the sections listed below.

Should close #89.

### Usage example:

The following example illustrates how the `@example` annotation can be used to add examples on how the request body must be, as well as what the endpoint is supposed to return for specific status codes.

```javascript
/**
 * POST /api/v1/song
 * @param {Song} request.body.required - song info
 * @return {object} 200 - song response
 * @return {object} 400 - Bad request response
 * @example request - example payload
 * {
 *   "title": "untitled song",
 *   "artist": "anonymous",
 *   "year": 2019
 * }
 * @example response - 200 - example success response
 * {
 *   "message": "you have saved song"
 * }
 * @example response - 400 - example error response
 * {
 *   "message": "failed to save song",
 *   "errCode": "E120"
 * }
 */
```

### Usage instructions:

The `@example` annotation must be immediatly be followed by a keyword that will represent what our example will be illustrating: `request` or `response`.

**It's possible to add as many examples as required for both requests and responses in a single endpoint**, there's no limit on that. In the case of responses, multiple examples for the same status code are also supported (so we can provide multiple examples of what our API returns if it succeeds, for example).

The sections below describe more in detail the expected syntax for the `@example` tag in each case (request and responses). Although it's same for the most part, it will differ slightly depending on the keyword used.

#### :inbox_tray: Request body example:

```
@example request - [summary]
[content]
```

| Field | Description |
| --- | --- |
| Summary | Small text that briefly describes the example. This text must occupy a single line. Everything outside of the `@example` tag line will be considered part of the example content and *NOT* the summary. |
| Content | Example content for the request body. It's imperative that its contents start at a new line below the `@example` tag, and not in the same one. The content can be split into multiple lines if necessary, for better readibility. Indentation and breaklines will be preserved in Swagger UI. |

#### :outbox_tray: Response body example:

```
@example response - [status code] - [summary]
[content]
```

| Field | Description |
| --- | --- |
| Summary | Small text that briefly describes the example. This text must occupy a single line. Everything outside of the `@example` tag line will be considered part of the example content and *NOT* the summary. |
| Status code | An HTTP status code (ex.: `200`). Keep in mind that any code not available in [`validStatusCodes`](https://github.com/BRIKEV/express-jsdoc-swagger/blob/master/transforms/paths/validStatusCodes.js) will be ignored. Also, it's necessary for a `@return` tag to exist for that same code in order for the example to appear in Swagger UI. |
| Content | Example content for the response body. It's imperative that its contents start at a new line below the `@example` tag, and not in the same one. The content can be split into multiple lines if necessary, for better readibility. Indentation and breaklines will be preserved in Swagger UI. |

____

### Task list:

:computer: **Development**
- [x] Create parser for `@example` within the `paths` folder.
- [x] Add examples to response objects.
- [x] Add examples to request body objects.
- [x] Create new example in `examples` > `requestBody` folder to illustrate how to use `@example` for the request body.
- [x] Create new example in `examples` > `responses` folder to illustrate how to use `@example` for the responses.

:heavy_check_mark: **Tests**
- [x] Create unit test for `@example` parsing in request body.
- [x] Create unit test for `@example` parsing in responses.

:memo: **Documentation**
- [x] Add descriptive comments to new `examples.js` parser.
- [x] Update `README` file with usage example for the `@example` tag.
- [x] Generate usage instructions for the `@example` tag (included in this PR message).

:whale: **Project settings**
- [x] Update `examples` > `package.json` with references to the new examples.

Patch:
diff --git a/README.md b/README.md
index 30503ce..ea833ab 100644
--- a/README.md
+++ b/README.md
@@ -128,6 +128,29 @@ app.get('/api/v1/album', (req, res) => (
 ));
 ```
 
+5. Basic endpoint definition with code example for response body
+```javascript
+/**
+ * GET /api/v1/albums
+ * @summary This is the summary or description of the endpoint
+ * @tags album
+ * @return {array<Song>} 200 - success response - application/json
+ * @example response - 200 - success response example
+ * [
+ *   {
+ *     "title": "Bury the light",
+ *     "artist": "Casey Edwards ft. Victor Borba",
+ *     "year": 2020
+ *   }
+ * ]
+ */
+app.get('/api/v1/albums', (req, res) => (
+  res.json([{
+    title: 'track 1',
+  }])
+));
+```
+
 You can find more examples [here](https://github.com/BRIKEV/express-jsdoc-swagger/tree/master/examples), or visit our [documentation](https://brikev.github.io/express-jsdoc-swagger-docs/#/).
 
 ## Contributors ✨
@@ -147,4 +170,4 @@ You can find more examples [here](https://github.com/BRIKEV/express-jsdoc-swagge
 <!-- prettier-ignore-end -->
 <!-- ALL-CONTRIBUTORS-LIST:END -->
 
-This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
\ No newline at end of file
+This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
diff --git a/examples/package.json b/examples/package.json
index 45461d3..d2a53ff 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -13,11 +13,13 @@
     "responses:multiple": "node responses/multiple.js",
     "responses:components": "node responses/components.js",
     "responses:full-example": "node responses/full-example.js",
+    "responses:withExamples": "node responses/withExamples.js",
     "requestBody:simple": "node requestBody/simple.js",
     "requestBody:multiple": "node requestBody/multiple.js",
     "requestBody:components": "node requestBody/components.js",
     "requestBody:formdata": "node requestBody/formdata.js",
     "requestBody:formUrlencoded": "node requestBody/formUrlencoded.js",
+    "requestBody:withExamples": "node requestBody/withExamples.js",
     "security:basic-auth": "node security/basic-auth.js",
     "components:simple": "node components/simple.js",
     "components:enum": "node components/enum.js",
diff --git a/examples/requestBody/withExamples.js b/examples/requestBody/withExamples.js
new file mode 100644
index 0000000..9eebdd5
--- /dev/null
+++ b/examples/requestBody/withExamples.js
@@ -0,0 +1,53 @@
+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: './withExamples.js',
+  baseDir: __dirname,
+};
+
+const app = express();
+const port = 3000;
+
+expressJSDocSwagger(app)(options);
+
+/**
+ * A song
+ * @typedef {object} Song
+ * @property {string} title.required - The title
+ * @property {string} artist - The artist
+ * @property {integer} year - The year - int64
+ */
+
+/**
+ * POST /api/v1/song
+ * @param {Song} request.body.required - song info
+ * @return {object} 200 - song response
+ * @return {object} 400 - Bad request response
+ * @example request - payload example
+ * {
+ *   "title": "Bury The Light",
+ *   "artist": "Casey Edwards ft. Victor Borba",
+ *   "year": 2020
+ * }
+ * @example request - other payload example
+ * {
+ *   "title": "The war we made",
+ *   "artist": "Red",
+ *   "year": 2020
+ * }
+ */
+app.post('/api/v1/song', (req, res) => res.send({
+  message: 'You added a song!',
+}));
+
+app.listen(port, () => logger.info(`Example app listening at http://localhost:${port}`));
diff --git a/examples/responses/withExamples.js b/examples/responses/withExamples.js
new file mode 100644
index 0000000..7ce2182
--- /dev/null
+++ b/examples/responses/withExamples.js
@@ -0,0 +1,68 @@
+const express = require('express');
+
+const logger = require('../utils/logger');
+const expressJSDocSwagger = require('../..');
+
+const options = {
+  info: {
+    version: '1.0.0',
+    title: 'Pet store',
+    license: {
+      name: 'MIT',
+    },
+  },
+  filesPattern: './withExamples.js',
+  baseDir: __dirname,
+};
+
+const app = express();
+const port = 3000;
+
+expressJSDocSwagger(app)(options);
+
+/**
+ * A pet
+ * @typedef {object} Pet
+ * @property {string} name.required - The name
+ * @property {string} type.required - The type of pet
+ * @property {string} breed - The breed
+ */
+
+/**
+ * GET /api/v1/pet
+ * @return {Pet[]} 200 - success response
+ * @return {object} 403 - forbidden request response
+ * @example response - 200 - example success response
+ * [
+ *   {
+ *     "name": "Blaze",
+ *     "type": "Dog"
+ *     "breed": "Siberian husky"
+ *   },
+ *   {
+ *     "name": "Luna",
+ *     "type": "Cat"
+ *   }
+ * ]
+ * @example response - 200 - second example success response
+ * [
+ *   {
+ *     "name": "Hachiko",
+ *     "type": "Dog"
+ *     "breed": "Akita Inu"
+ *   },
+ * ]
+ * @example response - 403 - example error response
+ * {
+ *   "message": "you cannot access pet data",
+ * }
+ */
+app.get('/api/v1/pet', (req, res) => (
+  res.json([{
+    name: 'Hachiko',
+    type: 'Dog',
+    breed: 'Akita Inu'
+  }])
+));
+
+app.listen(port, () => logger.info(`Example app listening at http://localhost:${port}`));
diff --git a/transforms/paths/content.js b/transforms/paths/content.js
index e7b5b57..62ef3d9 100644
--- a/transforms/paths/content.js
+++ b/transforms/paths/content.js
@@ -2,11 +2,12 @@ const getSchema = require('./schema');
 
 const DEFAULT_CONTENT_TYPE = 'application/json';
 
-const getContent = entity => (type, contentType, originalValue) => {
+const getContent = entity => (type, contentType, originalValue, examples) => {
   const schema = getSchema(entity, originalValue)(type);
   return {
     [contentType || DEFAULT_CONTENT_TYPE]: {
       schema,
+      examples,
     },
   };
 };
diff --git a/transforms/paths/examples.js b/transforms/paths/examples.js
new file mode 100644
index 0000000..3280b3a
--- /dev/null
+++ b/transforms/paths/examples.js
@@ -0,0 +1,96 @@
+const STATUS_CODES = require('./validStatusCodes');
+
+const errorMessage = require('../utils/errorMessage');
+const mapDescription = require('../utils/mapDescription');
+
+const REQUEST_BODY = 'request';
+const RESPONSE_BODY = 'response';
+
+// Generates a new object with information on a request body example
+const parseRequestPayloadExample = (description, content) => {
+  const [summary] = description;
+  return {
+    type: REQUEST_BODY,
+    summary,
+    value: content,
+  };
+};
+
+// Generates a new object with information on a response body example
+const parseResponsePayloadExample = (description, content) => {
+  const [status, summary] = description;
+  if (!STATUS_CODES[status]) {
+    errorMessage(`${status} is not a valid status for a response`);
+    return {};
+  }
+  return {
+    type: RESPONSE_BODY,
+    status,
+    summary,
+    value: content,
+  };
+};
+
+/**
+ *  Parses a single example tag contents. Depending on the type (response or
+ * request), the expected data and returned structure will be different.
+ *
+ *  To prevent compatibility issues between SO, all `\r\n` instances (used in
+ * Windows by default as end of line sequence) will be replaced by `\n`.
+ *
+ * @param {string} exampleTagDescription - Text passed to the example tag.
+ *
+ * @return {object} Structured information about the example, including the
+ * example type (request or response), summary, status code (only applicable to
+ * response types) and the example code itself.
+ */
+const parseExample = ({ description: exampleTagDescription }) => {
+  const formattedTagDescription = exampleTagDescription.replace(/\r\n/gi, '\n');
+
+  // The example content starts right after the first end of line character
+  const contentStartIndex = formattedTagDescription.indexOf('\n');
+  const content = formattedTagDescription.substring(contentStartIndex + 1);
+  const description = formattedTagDescription.substring(0, contentStartIndex);
+
+  const [type, ...metadata] = mapDescription(description);
+
+  switch (type) {
+    case REQUEST_BODY:
+      return parseRequestPayloadExample(metadata, content);
+    case RESPONSE_BODY:
+      return parseResponsePayloadExample(metadata, content);
+    default:
+      errorMessage(`Cannot determine where to use example of type ${type}`);
+      return {};
+  }
+};
+
+/**
+ *  This receives a set of values extracted from "example" tags, and translates
+ * them to objects with the example type (request or response), summary, status
+ * code (only applicable to response types) and the example code itself.
+ *
+ * @param {object[]} exampleTags - Set of example tags, which description are
+ *  expected to have the following information in order to be parsed properly:
+ *
+ *    > For request body examples:
+ *
+ *      response - [example summary]
+ *      [example content]
+ *
+ *    > For response examples:
+ *
+ *      response - [http status code] - [example summary]
+ *      [example content]
+ *
+ * @return {object[]} List of objects containing the example type (request or
+ *  response), summary, status code (only applicable to response types) and
+ *  the example code itself.
+ */
+const examplesGenerator = (exampleTags = []) => {
+  if (!exampleTags || !Array.isArray(exampleTags)) return [];
+  const examples = exampleTags.map(parseExample).filter(example => example.type);
+  return examples;
+};
+
+module.exports = examplesGenerator;
diff --git a/transforms/paths/index.js b/transforms/paths/index.js
index 7ad798e..400336f 100644
--- a/transforms/paths/index.js
+++ b/transforms/paths/index.js
@@ -1,3 +1,4 @@
+const examplesGenerator = require('./examples');
 const responsesGenerator = require('./responses');
 const parametersGenerator = require('./parameters');
 const requestBodyGenerator = require('./requestBody');
@@ -18,21 +19,25 @@ const formatSecurity = (securityValues = []) => securityValues.map(({ descriptio
 
 const formatSummary = summary => (summary || {}).description || '';
 
-const setRequestBody = (lowerCaseMethod, bodyValues) => {
+const setRequestBody = (lowerCaseMethod, bodyValues, requestExamples) => {
   const hasBodyValues = bodyValues.length > 0;
-  const requestBody = requestBodyGenerator(bodyValues);
+  const requestBody = requestBodyGenerator(bodyValues, requestExamples);
   return bodyMethods[lowerCaseMethod] && hasBodyValues ? { requestBody } : {};
 };
 
 const bodyParams = ({ name }) => name.includes('request.body');
 
 const pathValues = tags => {
+  const examplesValues = getTagsInfo(tags, 'example');
+  const examples = examplesGenerator(examplesValues);
+
   const summary = getTagInfo(tags, 'summary');
   const deprecated = getTagInfo(tags, 'deprecated');
   const isDeprecated = !!deprecated;
   /* Response info */
   const returnValues = getTagsInfo(tags, 'return');
-  const responses = responsesGenerator(returnValues);
+  const responseExamples = examples.filter(example => example.type === 'response');
+  const responses = responsesGenerator(returnValues, responseExamples);
   /* Parameters info */
   const paramValues = getTagsInfo(tags, 'param');
   const parameters = parametersGenerator(paramValues);
@@ -50,6 +55,7 @@ const pathValues = tags => {
     tagsValues,
     bodyValues,
     securityValues,
+    examples,
   };
 };
 
@@ -61,8 +67,9 @@ const parsePath = (path, state) => {
   if (!validHTTPMethod(lowerCaseMethod)) return {};
   const { tags } = path;
   const {
-    summary, bodyValues, isDeprecated, responses, parameters, tagsValues, securityValues,
+    summary, bodyValues, isDeprecated, responses, parameters, tagsValues, securityValues, examples,
   } = pathValues(tags);
+  const requestExamples = examples.filter(example => example.type === 'request');
   return {
     ...state,
     [endpoint]: {
@@ -74,7 +81,7 @@ const parsePath = (path, state) => {
         responses,
         parameters,
         tags: formatTags(tagsValues),
-        ...(setRequestBody(lowerCaseMethod, bodyValues)),
+        ...(setRequestBody(lowerCaseMethod, bodyValues, requestExamples)),
       },
     },
   };
diff --git a/transforms/paths/requestBody.js b/transforms/paths/requestBody.js
index 729f1ef..a1dee76 100644
--- a/transforms/paths/requestBody.js
+++ b/transforms/paths/requestBody.js
@@ -5,7 +5,16 @@ const mapDescription = require('../utils/mapDescription');
 const REQUIRED = 'required';
 const BODY_PARAM = 'body';
 
-const parseBodyParameter = (currentState, body) => {
+const formatExamples = (exampleValues = []) => exampleValues
+  .reduce((exampleMap, example, i) => ({
+    ...exampleMap,
+    [`example${i + 1}`]: {
+      summary: example.summary,
+      value: example.value,
+    },
+  }), {});
+
+const parseBodyParameter = (currentState, body, examples) => {
   const [name, inOption, ...extraOptions] = body.name.split('.');
   if (inOption !== BODY_PARAM) {
     return {};
@@ -17,6 +26,12 @@ const parseBodyParameter = (currentState, body) => {
     required: isRequired,
     description,
   };
+
+  let requestExamples;
+  if (Array.isArray(examples) && examples.length > 0) {
+    requestExamples = formatExamples(examples);
+  }
+
   return {
     ...currentState,
     description: setProperty(options, 'description', {
@@ -28,17 +43,17 @@ const parseBodyParameter = (currentState, body) => {
     }),
     content: {
       ...currentState.content,
-      ...getContent(body.type, contentType, body.description),
+      ...getContent(body.type, contentType, body.description, requestExamples),
     },
   };
 };
 
 const INITIAL_STATE = { content: {} };
 
-const requestBodyGenerator = (params = []) => {
+const requestBodyGenerator = (params = [], examples) => {
   if (!params || !Array.isArray(params)) return {};
   const requestBody = params.reduce((acc, body) => (
-    { ...acc, ...parseBodyParameter(acc, body) }
+    { ...acc, ...parseBodyParameter(acc, body, examples) }
   ), INITIAL_STATE);
   return requestBody;
 };
diff --git a/transforms/paths/responses.js b/transforms/paths/responses.js
index c9783b8..050b2e0 100644
--- a/transforms/paths/responses.js
+++ b/transforms/paths/responses.js
@@ -5,7 +5,7 @@ const getContent = require('./content')('@return');
 
 const hasOldContent = (value, status) => (value[status] && value[status].content);
 
-const formatResponses = values => values.reduce((acc, value) => {
+const formatResponses = (values, examples) => values.reduce((acc, value) => {
   const [status, description, contentType] = mapDescription(value.description);
   if (!STATUS_CODES[status]) {
     errorMessage(`Status ${status} is not valid to create a response`);
@@ -17,15 +17,27 @@ const formatResponses = values => values.reduce((acc, value) => {
       description,
       content: {
         ...(hasOldContent(acc, status) ? { ...acc[status].content } : {}),
-        ...getContent(value.type, contentType, value.description),
+        ...getContent(value.type, contentType, value.description, examples[status]),
       },
     },
   };
 }, {});
 
-const responsesGenerator = (returnValues = []) => {
+const formatExamples = (exampleValues = []) => exampleValues
+  .reduce((exampleMap, example, i) => ({
+    ...exampleMap,
+    [example.status]: {
+      ...exampleMap[example.status],
+      [`example${i + 1}`]: {
+        summary: example.summary,
+        value: example.value,
+      },
+    },
+  }), {});
+
+const responsesGenerator = (returnValues = [], exampleValues = []) => {
   if (!returnValues || !Array.isArray(returnValues)) return {};
-  return formatResponses(returnValues);
+  return formatResponses(returnValues, formatExamples(exampleValues));
 };
 
 module.exports = responsesGenerator;


Test patch:
diff --git a/test/transforms/paths/requestBody.test.js b/test/transforms/paths/requestBody.test.js
index 34b9713..92e082f 100644
--- a/test/transforms/paths/requestBody.test.js
+++ b/test/transforms/paths/requestBody.test.js
@@ -314,4 +314,49 @@ describe('request body tests', () => {
     const result = setPaths({}, parsedJSDocs);
     expect(result).toEqual(expected);
   });
+
+  it('should parse jsdoc request body with examples', () => {
+    const jsdocInput = [`
+      /**
+       * POST /api/v1
+       * @param {string} request.body.required - name body description
+       * @example request - example payload
+       * sample input string
+       */
+    `];
+    const expected = {
+      paths: {
+        '/api/v1': {
+          post: {
+            deprecated: false,
+            summary: '',
+            responses: {},
+            tags: [],
+            security: [],
+            parameters: [],
+            requestBody: {
+              description: 'name body description',
+              required: true,
+              content: {
+                'application/json': {
+                  schema: {
+                    type: 'string',
+                  },
+                  examples: {
+                    example1: {
+                      summary: 'example payload',
+                      value: 'sample input string',
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+      },
+    };
+    const parsedJSDocs = jsdocInfo()(jsdocInput);
+    const result = setPaths({}, parsedJSDocs);
+    expect(result).toEqual(expected);
+  });
 });
diff --git a/test/transforms/paths/responses.test.js b/test/transforms/paths/responses.test.js
index b7dbb46..68e5653 100644
--- a/test/transforms/paths/responses.test.js
+++ b/test/transforms/paths/responses.test.js
@@ -296,4 +296,74 @@ describe('response tests', () => {
     const result = setPaths({}, parsedJSDocs);
     expect(result).toEqual(expected);
   });
+
+  it('should parse jsdoc path response with examples', () => {
+    const jsdocInput = [`
+      /**
+       * GET /api/v1
+       * @summary This is the summary or description of the endpoint
+       * @return {Song} 200 - success response - application/json
+       * @return {object} 403 - forbidden response - application/json
+       * @example response - 200 - example success response
+       * {
+       *   "title": "untitled song",
+       *   "artist": "anonymous"
+       * }
+       * @example response - 403 - example error response
+       * {
+       *   "error": "failed to retrieve results"
+       * }
+       */
+    `];
+    const expected = {
+      paths: {
+        '/api/v1': {
+          get: {
+            deprecated: false,
+            summary: 'This is the summary or description of the endpoint',
+            parameters: [],
+            tags: [],
+            security: [],
+            responses: {
+              200: {
+                description: 'success response',
+                content: {
+                  'application/json': {
+                    schema: {
+                      $ref: '#/components/schemas/Song',
+                    },
+                    examples: {
+                      example1: {
+                        summary: 'example success response',
+                        value: '{\n  "title": "untitled song",\n  "artist": "anonymous"\n}',
+                      },
+                    },
+                  },
+                },
+              },
+              403: {
+                description: 'forbidden response',
+                content: {
+                  'application/json': {
+                    schema: {
+                      type: 'object',
+                    },
+                    examples: {
+                      example2: {
+                        summary: 'example error response',
+                        value: '{\n  "error": "failed to retrieve results"\n}',
+                      },
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+      },
+    };
+    const parsedJSDocs = jsdocInfo()(jsdocInput);
+    const result = setPaths({}, parsedJSDocs);
+    expect(result).toEqual(expected);
+  });
 });