From 8c054941a669a301efbf4128fc4c3c8317708fc3 Mon Sep 17 00:00:00 2001
From: Jon Ursenbach <erunion@users.noreply.github.com>
Date: Mon, 30 Aug 2021 10:32:43 -0700
Subject: [PATCH] feat: constrain integer and number formats when generating
 JSON Schema (#492)

* feat: constrain integer and number formats when generating JSON Schema

* docs: minor code comment fixes
---
 .npmignore                                    |  2 +
 .prettierignore                               |  1 +
 .../__snapshots__/operation.test.js.snap      | 18 +++++
 __tests__/lib/openapi-to-json-schema.test.js  | 74 ++++++++++++++++---
 ...get-parameters-as-json-schema.test.js.snap | 10 +++
 .../get-parameters-as-json-schema.test.js     |  2 +
 .../get-response-as-json-schema.test.js       |  2 +-
 src/lib/openapi-to-json-schema.js             | 49 ++++++++++++
 8 files changed, 148 insertions(+), 10 deletions(-)

diff --git a/.npmignore b/.npmignore
index 666b19bd..70775d1c 100644
--- a/.npmignore
+++ b/.npmignore
@@ -2,5 +2,7 @@ __tests__/
 .github/
 .husky/
 coverage/
+.babel*
 .eslint*
 .prettier*
+webpack.*
diff --git a/.prettierignore b/.prettierignore
index 9f84a832..c9211890 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,3 +1,4 @@
+__tests__/cli/__fixtures__/
 coverage/
 dist/
 packages/
diff --git a/__tests__/__snapshots__/operation.test.js.snap b/__tests__/__snapshots__/operation.test.js.snap
index 751b0062..9c355883 100644
--- a/__tests__/__snapshots__/operation.test.js.snap
+++ b/__tests__/__snapshots__/operation.test.js.snap
@@ -44,6 +44,8 @@ Array [
             "properties": Object {
               "code": Object {
                 "format": "int32",
+                "maximum": 2147483647,
+                "minimum": -2147483648,
                 "type": "integer",
               },
               "message": Object {
@@ -59,6 +61,8 @@ Array [
             "properties": Object {
               "id": Object {
                 "format": "int64",
+                "maximum": 9223372036854776000,
+                "minimum": -9223372036854776000,
                 "type": "integer",
               },
               "name": Object {
@@ -75,14 +79,20 @@ Array [
               },
               "id": Object {
                 "format": "int64",
+                "maximum": 9223372036854776000,
+                "minimum": -9223372036854776000,
                 "type": "integer",
               },
               "petId": Object {
                 "format": "int64",
+                "maximum": 9223372036854776000,
+                "minimum": -9223372036854776000,
                 "type": "integer",
               },
               "quantity": Object {
                 "format": "int32",
+                "maximum": 2147483647,
+                "minimum": -2147483648,
                 "type": "integer",
               },
               "shipDate": Object {
@@ -108,6 +118,8 @@ Array [
               },
               "id": Object {
                 "format": "int64",
+                "maximum": 9223372036854776000,
+                "minimum": -9223372036854776000,
                 "readOnly": true,
                 "type": "integer",
               },
@@ -149,6 +161,8 @@ Array [
             "properties": Object {
               "id": Object {
                 "format": "int64",
+                "maximum": 9223372036854776000,
+                "minimum": -9223372036854776000,
                 "type": "integer",
               },
               "name": Object {
@@ -167,6 +181,8 @@ Array [
               },
               "id": Object {
                 "format": "int64",
+                "maximum": 9223372036854776000,
+                "minimum": -9223372036854776000,
                 "type": "integer",
               },
               "lastName": Object {
@@ -181,6 +197,8 @@ Array [
               "userStatus": Object {
                 "description": "User Status",
                 "format": "int32",
+                "maximum": 2147483647,
+                "minimum": -2147483648,
                 "type": "integer",
               },
               "username": Object {
diff --git a/__tests__/lib/openapi-to-json-schema.test.js b/__tests__/lib/openapi-to-json-schema.test.js
index 5eaaddd7..75d6bcc0 100644
--- a/__tests__/lib/openapi-to-json-schema.test.js
+++ b/__tests__/lib/openapi-to-json-schema.test.js
@@ -407,9 +407,11 @@ describe('`enum` support', () => {
 
 describe('`format` support', () => {
   it('should support format', () => {
-    expect(toJSONSchema({ type: 'integer', format: 'int32' })).toStrictEqual({
+    expect(toJSONSchema({ type: 'integer', format: 'int8' })).toStrictEqual({
       type: 'integer',
-      format: 'int32',
+      format: 'int8',
+      minimum: -128,
+      maximum: 127,
     });
 
     // Should support nested objects as well.
@@ -421,7 +423,57 @@ describe('`format` support', () => {
       },
     };
 
-    expect(toJSONSchema(schema)).toStrictEqual(schema);
+    expect(toJSONSchema(schema)).toStrictEqual({
+      type: 'array',
+      items: {
+        type: 'integer',
+        format: 'int8',
+        minimum: -128,
+        maximum: 127,
+      },
+    });
+  });
+
+  describe('minimum/maximum constraints', () => {
+    describe.each([
+      ['integer', 'int8', -128, 127],
+      ['integer', 'int16', -32768, 32767],
+      ['integer', 'int32', -2147483648, 2147483647],
+      ['integer', 'int64', 0 - 2 ** 63, 2 ** 63 - 1], // -9223372036854775808 to 9223372036854775807
+      ['integer', 'uint8', 0, 255],
+      ['integer', 'uint16', 0, 65535],
+      ['integer', 'uint32', 0, 4294967295],
+      ['integer', 'uint64', 0, 2 ** 64 - 1], // 0 to 1844674407370955161
+      ['number', 'float', 0 - 2 ** 128, 2 ** 128 - 1], // -3.402823669209385e+38 to 3.402823669209385e+38
+      ['number', 'double', 0 - Number.MAX_VALUE, Number.MAX_VALUE],
+    ])('`%s`', (type, format, min, max) => {
+      it('should add a `minimum` and `maximum` if not present', () => {
+        expect(toJSONSchema({ type, format })).toStrictEqual({
+          type,
+          format,
+          minimum: min,
+          maximum: max,
+        });
+      });
+
+      it('should alter constraints if present and beyond the allowable points', () => {
+        expect(toJSONSchema({ type, format, minimum: min ** 19, maximum: max * 2 })).toStrictEqual({
+          type,
+          format,
+          minimum: min,
+          maximum: max,
+        });
+      });
+
+      it('should not touch their constraints if they are within their limits', () => {
+        expect(toJSONSchema({ type, format, minimum: 0, maximum: 100 })).toStrictEqual({
+          type,
+          format,
+          minimum: 0,
+          maximum: 100,
+        });
+      });
+    });
   });
 });
 
@@ -456,7 +508,7 @@ describe('`additionalProperties` support', () => {
     ['false', false],
     ['an empty object', true],
     ['an object containing a string', { type: 'string' }],
-  ])('should support when set to `%s`', (tc, additionalProperties) => {
+  ])('should support additionalProperties when set to `%s`', (tc, additionalProperties) => {
     const schema = {
       type: 'array',
       items: {
@@ -474,7 +526,7 @@ describe('`additionalProperties` support', () => {
     });
   });
 
-  it('should support when set to an object containing an array', () => {
+  it('should support additionalProperties when set to an object that contains an array', () => {
     const schema = {
       type: 'array',
       items: {
@@ -486,7 +538,7 @@ describe('`additionalProperties` support', () => {
             properties: {
               id: {
                 type: 'integer',
-                format: 'int64',
+                format: 'int8',
               },
             },
           },
@@ -503,7 +555,9 @@ describe('`additionalProperties` support', () => {
           properties: {
             id: {
               type: 'integer',
-              format: 'int64',
+              format: 'int8',
+              minimum: -128,
+              maximum: 127,
             },
           },
         },
@@ -808,7 +862,7 @@ describe('`example` / `examples` support', () => {
                       },
                       price: {
                         type: 'integer',
-                        format: 'int32',
+                        format: 'int8',
                       },
                     },
                     example: {
@@ -846,7 +900,9 @@ describe('`example` / `examples` support', () => {
         },
         price: {
           type: 'integer',
-          format: 'int32',
+          format: 'int8',
+          minimum: -128,
+          maximum: 127,
           examples: [1],
         },
       },
diff --git a/__tests__/operation/__snapshots__/get-parameters-as-json-schema.test.js.snap b/__tests__/operation/__snapshots__/get-parameters-as-json-schema.test.js.snap
index 786d54b4..e353716e 100644
--- a/__tests__/operation/__snapshots__/get-parameters-as-json-schema.test.js.snap
+++ b/__tests__/operation/__snapshots__/get-parameters-as-json-schema.test.js.snap
@@ -100,6 +100,8 @@ Array [
         "petId": Object {
           "description": "Pet id to delete",
           "format": "int64",
+          "maximum": 9223372036854776000,
+          "minimum": -9223372036854776000,
           "type": "integer",
         },
       },
@@ -220,6 +222,8 @@ Array [
           "properties": Object {
             "id": Object {
               "format": "int64",
+              "maximum": 9223372036854776000,
+              "minimum": -9223372036854776000,
               "type": "integer",
             },
             "name": Object {
@@ -231,6 +235,8 @@ Array [
         },
         "id": Object {
           "format": "int64",
+          "maximum": 9223372036854776000,
+          "minimum": -9223372036854776000,
           "readOnly": true,
           "type": "integer",
         },
@@ -260,6 +266,8 @@ Array [
             "properties": Object {
               "id": Object {
                 "format": "int64",
+                "maximum": 9223372036854776000,
+                "minimum": -9223372036854776000,
                 "type": "integer",
               },
               "name": Object {
@@ -293,6 +301,8 @@ Array [
         "petId": Object {
           "description": "ID of pet that needs to be updated",
           "format": "int64",
+          "maximum": 9223372036854776000,
+          "minimum": -9223372036854776000,
           "type": "integer",
         },
       },
diff --git a/__tests__/operation/get-parameters-as-json-schema.test.js b/__tests__/operation/get-parameters-as-json-schema.test.js
index fa9f1737..228c402c 100644
--- a/__tests__/operation/get-parameters-as-json-schema.test.js
+++ b/__tests__/operation/get-parameters-as-json-schema.test.js
@@ -470,6 +470,8 @@ describe('descriptions', () => {
         pathId: {
           type: 'integer',
           format: 'uint32',
+          maximum: 4294967295,
+          minimum: 0,
           description: 'Description for the pathId',
         },
       },
diff --git a/__tests__/operation/get-response-as-json-schema.test.js b/__tests__/operation/get-response-as-json-schema.test.js
index 70136ecc..e9370c92 100644
--- a/__tests__/operation/get-response-as-json-schema.test.js
+++ b/__tests__/operation/get-response-as-json-schema.test.js
@@ -27,7 +27,7 @@ test('it should return a response as JSON Schema', async () => {
       schema: {
         type: 'object',
         properties: {
-          code: { type: 'integer', format: 'int32' },
+          code: { type: 'integer', format: 'int32', maximum: 2147483647, minimum: -2147483648 },
           type: { type: 'string' },
           message: { type: 'string' },
         },
diff --git a/src/lib/openapi-to-json-schema.js b/src/lib/openapi-to-json-schema.js
index 3ef246af..8115b692 100644
--- a/src/lib/openapi-to-json-schema.js
+++ b/src/lib/openapi-to-json-schema.js
@@ -20,6 +20,37 @@ const UNSUPPORTED_SCHEMA_PROPS = [
   'deprecated',
 ];
 
+/**
+ * List partially sourced from `openapi-schema-to-json-schema`.
+ *
+ * @link https://github.com/openapi-contrib/openapi-schema-to-json-schema/blob/master/lib/converters/schema.js#L140-L154
+ */
+const FORMAT_OPTIONS = {
+  INT8_MIN: 0 - 2 ** 7, // -128
+  INT8_MAX: 2 ** 7 - 1, // 127
+  INT16_MIN: 0 - 2 ** 15, // -32768
+  INT16_MAX: 2 ** 15 - 1, // 32767
+  INT32_MIN: 0 - 2 ** 31, // -2147483648
+  INT32_MAX: 2 ** 31 - 1, // 2147483647
+  INT64_MIN: 0 - 2 ** 63, // -9223372036854775808
+  INT64_MAX: 2 ** 63 - 1, // 9223372036854775807
+
+  UINT8_MIN: 0,
+  UINT8_MAX: 2 ** 8 - 1, // 255
+  UINT16_MIN: 0,
+  UINT16_MAX: 2 ** 16 - 1, // 65535
+  UINT32_MIN: 0,
+  UINT32_MAX: 2 ** 32 - 1, // 4294967295
+  UINT64_MIN: 0,
+  UINT64_MAX: 2 ** 64 - 1, // 18446744073709551615
+
+  FLOAT_MIN: 0 - 2 ** 128, // -3.402823669209385e+38
+  FLOAT_MAX: 2 ** 128 - 1, // 3.402823669209385e+38
+
+  DOUBLE_MIN: 0 - Number.MAX_VALUE,
+  DOUBLE_MAX: Number.MAX_VALUE,
+};
+
 /**
  * Take a string and encode it to be used as a JSON pointer.
  *
@@ -388,6 +419,24 @@ function toJSONSchema(data, opts = {}) {
     }
   }
 
+  // Ensure that number schemas formats have properly constrained min/max attributes according to whatever type of
+  // `format` and `type` they adhere to.
+  if ('format' in schema) {
+    const formatUpper = schema.format.toUpperCase();
+
+    if (`${formatUpper}_MIN` in FORMAT_OPTIONS) {
+      if ((!schema.minimum && schema.minimum !== 0) || schema.minimum < FORMAT_OPTIONS[`${formatUpper}_MIN`]) {
+        schema.minimum = FORMAT_OPTIONS[`${formatUpper}_MIN`];
+      }
+    }
+
+    if (`${formatUpper}_MAX` in FORMAT_OPTIONS) {
+      if ((!schema.maximum && schema.maximum !== 0) || schema.maximum > FORMAT_OPTIONS[`${formatUpper}_MAX`]) {
+        schema.maximum = FORMAT_OPTIONS[`${formatUpper}_MAX`];
+      }
+    }
+  }
+
   // Users can pass in parameter defaults via JWT User Data: https://docs.readme.com/docs/passing-data-to-jwt
   // We're checking to see if the defaults being passed in exist on endpoints via jsonpointer
   if (globalDefaults && Object.keys(globalDefaults).length > 0 && currentLocation) {