Repository: RobinTail/express-zod-api
Pull request #95 (2021-08-08 19:51:39)

Improving OpenAPI arrays description
Scope:

- ZodArray
- ZodTuple


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a826652..a6984629 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,33 @@
 
 ## Version 2
 
+### v2.3.0
+
+- Changes and improvements of the generated Swagger / OpenAPI documentation:
+```yaml
+ZodArray: # z.array()
+  before:
+    type: array
+    items:
+      type: type # type of the array items 
+  after:
+    type: array
+    items:
+      type: type
+    minItems: value # optional, when z.array().min(value)
+    maxItems: value # optional, when z.array().max(value)
+ZodTuple: # z.tuple()
+  before:
+    error: unsupported
+  after:
+    type: array
+    items:
+      oneOf: [] # schemas of the tuple items
+    minItems: value # number of items in the tuple
+    maxItems: value # number of items in the tuple
+    description: '0: type, 1: type, ...'
+```
+
 ### v2.2.0
 
 - Changes and improvements of the generated Swagger / OpenAPI documentation:
diff --git a/coverage.svg b/coverage.svg
index 03e28a6e..e913cdf8 100644
--- a/coverage.svg
+++ b/coverage.svg
@@ -1,1 +1,1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="20" role="img" aria-label="Coverage: 98.36%"><title>Coverage: 98.36%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="116" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="53" height="20" fill="#4c1"/><rect width="116" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="885" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">98.36%</text><text x="885" y="140" transform="scale(.1)" fill="#fff" textLength="430">98.36%</text></g></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="110" height="20" role="img" aria-label="Coverage: 98.4%"><title>Coverage: 98.4%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="110" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="47" height="20" fill="#4c1"/><rect width="110" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="855" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">98.4%</text><text x="855" y="140" transform="scale(.1)" fill="#fff" textLength="370">98.4%</text></g></svg>
\ No newline at end of file
diff --git a/src/open-api.ts b/src/open-api.ts
index e8318067..69dddce7 100644
--- a/src/open-api.ts
+++ b/src/open-api.ts
@@ -40,8 +40,12 @@ const describeSchema = (value: z.ZodTypeAny, isResponse: boolean): SchemaObject
     case value instanceof z.ZodArray:
       return {
         ...otherProps,
-        type: 'array',
-        items: describeSchema((value._def as z.ZodArrayDef).type, isResponse)
+        ...describeArray(value._def as z.ZodArrayDef, isResponse)
+      };
+    case value instanceof z.ZodTuple:
+      return {
+        ...otherProps,
+        ...describeTuple(value as z.ZodTuple, isResponse)
       };
     case value instanceof z.ZodRecord:
       return {
@@ -109,7 +113,6 @@ const describeSchema = (value: z.ZodTypeAny, isResponse: boolean): SchemaObject
         format: 'any'
       };
     case value instanceof z.ZodUndefined:
-    case value instanceof z.ZodTuple:
     case value instanceof z.ZodMap:
     case value instanceof z.ZodFunction:
     case value instanceof z.ZodLazy:
@@ -122,6 +125,30 @@ const describeSchema = (value: z.ZodTypeAny, isResponse: boolean): SchemaObject
   }
 };
 
+const describeArray = (definition: z.ZodArrayDef, isResponse: boolean): SchemaObject => ({
+  type: 'array',
+  items: describeSchema(definition.type, isResponse),
+  ...(definition.minLength ? { minItems: definition.minLength.value } : {}),
+  ...(definition.maxLength ? { maxItems: definition.maxLength?.value } : {})
+});
+
+/** @todo improve it when OpenAPI 3.1.0 will be released */
+const describeTuple = (schema: z.ZodTuple, isResponse: boolean): SchemaObject => {
+  const types = schema.items.map((item) => describeSchema(item, isResponse));
+  return {
+    type: 'array',
+    minItems: types.length,
+    maxItems: types.length,
+    items: {
+      oneOf: types,
+      format: 'tuple',
+      ...(types.length === 0 ? {} : {
+        description: types.map((schema, index) => `${index}: ${schema.type}`).join(', ')
+      })
+    }
+  };
+};
+
 const describeString = (schema: z.ZodString): SchemaObject => {
   const checks = schema._def.checks;
   const isEmail = checks.find(({kind}) => kind === 'email') !== undefined;


Test patch:
diff --git a/tests/unit/__snapshots__/open-api.spec.ts.snap b/tests/unit/__snapshots__/open-api.spec.ts.snap
index 2edc90b0..ced6cb09 100644
--- a/tests/unit/__snapshots__/open-api.spec.ts.snap
+++ b/tests/unit/__snapshots__/open-api.spec.ts.snap
@@ -74,6 +74,16 @@ paths:
               exclusiveMinimum: true
               maximum: 9007199254740991
               exclusiveMaximum: false
+            minItems: 1
+            maxItems: 3
+            description: GET /v1/getSomething parameter
+        - name: unlimited
+          in: query
+          required: true
+          schema:
+            type: array
+            items:
+              type: boolean
             description: GET /v1/getSomething parameter
         - name: transformer
           in: query
@@ -1203,6 +1213,125 @@ servers:
 "
 `;
 
+exports[`Open API generator generateOpenApi() should handle tuples 1`] = `
+"openapi: 3.0.0
+info:
+  title: Testing tuples
+  version: 3.4.5
+paths:
+  /v1/getSomething:
+    post:
+      responses:
+        \\"200\\":
+          description: POST /v1/getSomething Successful response
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  status:
+                    type: string
+                    enum:
+                      - success
+                  data:
+                    type: object
+                    properties:
+                      empty:
+                        type: array
+                        minItems: 0
+                        maxItems: 0
+                        items:
+                          oneOf: []
+                          format: tuple
+                    required:
+                      - empty
+                required:
+                  - status
+                  - data
+        \\"400\\":
+          description: POST /v1/getSomething Error response
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  status:
+                    type: string
+                    enum:
+                      - error
+                  error:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+                    required:
+                      - message
+                required:
+                  - status
+                  - error
+      requestBody:
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                ofOne:
+                  type: array
+                  minItems: 1
+                  maxItems: 1
+                  items:
+                    oneOf:
+                      - type: boolean
+                    format: tuple
+                    description: \\"0: boolean\\"
+                ofStrings:
+                  type: array
+                  minItems: 2
+                  maxItems: 2
+                  items:
+                    oneOf:
+                      - type: string
+                      - nullable: true
+                        type: string
+                    format: tuple
+                    description: \\"0: string, 1: string\\"
+                complex:
+                  type: array
+                  minItems: 3
+                  maxItems: 3
+                  items:
+                    oneOf:
+                      - type: boolean
+                      - type: string
+                      - type: integer
+                        format: int64
+                        minimum: 0
+                        exclusiveMinimum: true
+                        maximum: 9007199254740991
+                        exclusiveMaximum: false
+                    format: tuple
+                    description: \\"0: boolean, 1: string, 2: integer\\"
+              required:
+                - ofOne
+                - ofStrings
+                - complex
+              description: POST /v1/getSomething request body
+components:
+  schemas: {}
+  responses: {}
+  parameters: {}
+  examples: {}
+  requestBodies: {}
+  headers: {}
+  securitySchemes: {}
+  links: {}
+  callbacks: {}
+tags: []
+servers:
+  - url: http://example.com
+"
+`;
+
 exports[`Open API generator generateOpenApi() should handle type any 1`] = `
 "openapi: 3.0.0
 info:
diff --git a/tests/unit/open-api.spec.ts b/tests/unit/open-api.spec.ts
index 0b93acb5..b26ca83f 100644
--- a/tests/unit/open-api.spec.ts
+++ b/tests/unit/open-api.spec.ts
@@ -21,7 +21,8 @@ describe('Open API generator', () => {
             getSomething: defaultEndpointsFactory.build({
               methods: ['get'],
               input: z.object({
-                array: z.array(z.number().int().positive()),
+                array: z.array(z.number().int().positive()).min(1).max(3),
+                unlimited: z.array(z.boolean()),
                 transformer: z.string().transform((str) => str.length)
               }),
               output: z.object({
@@ -299,6 +300,31 @@ describe('Open API generator', () => {
       expect(spec).toMatchSnapshot();
     });
 
+    test('should handle tuples', () => {
+      const spec = new OpenAPI({
+        routing: {
+          v1: {
+            getSomething: defaultEndpointsFactory.build({
+              method: 'post',
+              input: z.object({
+                ofOne: z.tuple([z.boolean()]),
+                ofStrings: z.tuple([z.string(), z.string().nullable()]),
+                complex: z.tuple([z.boolean(), z.string(), z.number().int().positive()])
+              }),
+              output: z.object({
+                empty: z.tuple([])
+              }),
+              handler: jest.fn()
+            })
+          }
+        },
+        version: '3.4.5',
+        title: 'Testing tuples',
+        serverUrl: 'http://example.com'
+      }).getSpecAsYaml();
+      expect(spec).toMatchSnapshot();
+    });
+
     test('should handle enum types', () => {
       const spec = new OpenAPI({
         routing: {
@@ -327,7 +353,6 @@ describe('Open API generator', () => {
     test('should throw on unsupported types', () => {
       [
         z.undefined(),
-        z.tuple([]),
         z.map(z.any(), z.any()),
         z.function(),
         z.lazy(() => z.any()),