Repository: RobinTail/express-zod-api (ts)

Issue:
Refinements on input object
Currently, there is no way to do refinements on the entire input object, only on it's properties. It would be great if there was a way to support this:

example:

body is
```
{
  type: 'type1',
  dynamicValue: { 
     type1Attribute: 1,
  } 
}
```
```
{
  type: 'type2',
  dynamicValue: { 
     type2Attribute: '1',
  } 
}
```

I want to be able to do:

```
defaultEndpointsFactory.build({
   input: z.object({ 
      type: z.union([z.literal('type1'), z.literal('type2')]),
      dynamicValue: z.union([
        z.object({ type1Attribute: z.number() }),
        z.object({ type2Attribute: z.string() })
      ]),
    }).refine(data => {
       if (data.type === "type1") {
          return !!(data.dynamicValue as any).type1Attribute
       }
       return true;
    }, { message: 'type1Attribute is required if type is type1', path: ['dynamicValue']]),
   ...
});
```

but I get an error like this..

`type ZodEffects<....> is not assignable to type IOSchema<any>`

As a workaround, I could change my input to be something like..
```
z.object({ data: z.object(...).refine(...) })
```

But I don't want to have to define my inputs like that.

Pull request description:
Feat: Top level refinements
Closes #654 
Fulfills #600 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cefaedb6..4855888e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,34 @@
 
 ## Version 8
 
+### v8.3.0
+
+- Feature #600: Top level refinements.
+  - Starting this version you can use the `.refine()` method on the `z.object()` of the input schema.
+  - This feature might be useful, for example, when you have multiple optional properties on the top level, but at
+    least one of them has to be specified.
+  - Currently, only the refinements of `z.object()` are supported.
+    - You can not combine it with `z.union()`, `z.intersetion()`, `z.discriminatedUnion()`, `.or()`, `.and()` yet.
+
+```typescript
+// example
+import { z } from "express-zod-api";
+
+const endpoint = endpointsFactory.build({
+  input: z
+    .object({
+      email: z.string().email().optional(),
+      id: z.string().optional(),
+      otherThing: z.string().optional(),
+    })
+    .refine(
+      (inputs) => Object.keys(inputs).length >= 1,
+      "Please provide at least one property"
+    ),
+  // ...
+});
+```
+
 ### v8.2.1
 
 - OpenAPI generator throws when attempting to use `z.file()` within input schema.
@@ -502,7 +530,6 @@ export const myMiddleware = createMiddleware({
     .passthrough(), // <— remove this if you have it in your code
   middleware: async () => ({...}),
 });
-
 ```
 
 ## Version 5
diff --git a/README.md b/README.md
index a24bb01e..9ffe5e0a 100644
--- a/README.md
+++ b/README.md
@@ -323,7 +323,7 @@ const endpointsFactory = defaultEndpointsFactory.addOptions({
 
 ## Refinements
 
-By the way, you can implement additional validation within schema.
+You can implement additional validations within schemas using refinements.
 Validation errors are reported in a response with a status code `400`.
 
 ```typescript
@@ -343,6 +343,24 @@ const nicknameConstraintMiddleware = createMiddleware({
 });
 ```
 
+By the way, you can also refine the whole I/O object, for example in case you need a complex validation of its props.
+
+```typescript
+const endpoint = endpointsFactory.build({
+  input: z
+    .object({
+      email: z.string().email().optional(),
+      id: z.string().optional(),
+      otherThing: z.string().optional(),
+    })
+    .refine(
+      (inputs) => Object.keys(inputs).length >= 1,
+      "Please provide at least one property"
+    ),
+  // ...,
+});
+```
+
 ## Transformations
 
 Since parameters of GET requests come in the form of strings, there is often a need to transform them into numbers or
diff --git a/src/common-helpers.ts b/src/common-helpers.ts
index 5a06abd7..79604218 100644
--- a/src/common-helpers.ts
+++ b/src/common-helpers.ts
@@ -15,11 +15,16 @@ import { ZodUpload } from "./upload-schema";
 
 export type FlatObject = Record<string, any>;
 
+type Refined<T extends z.ZodType> = T extends z.ZodType<infer O>
+  ? z.ZodEffects<T, O, O>
+  : never;
+
 export type IOSchema<U extends UnknownKeysParam = any> =
   | z.ZodObject<any, U>
   | z.ZodUnion<[IOSchema<U>, ...IOSchema<U>[]]>
   | z.ZodIntersection<IOSchema<U>, IOSchema<U>>
-  | z.ZodDiscriminatedUnion<string, z.Primitive, z.ZodObject<any, U>>;
+  | z.ZodDiscriminatedUnion<string, z.Primitive, z.ZodObject<any, U>>
+  | Refined<z.ZodObject<any, U>>;
 
 export type ArrayElement<T extends readonly unknown[]> =
   T extends readonly (infer K)[] ? K : never;
@@ -130,7 +135,9 @@ export function makeErrorFromAnything<T>(subject: T): Error {
 export function getMessageFromError(error: Error): string {
   if (error instanceof z.ZodError) {
     return error.issues
-      .map(({ path, message }) => `${path.join("/")}: ${message}`)
+      .map(({ path, message }) =>
+        (path.length ? [path.join("/")] : []).concat(message).join(": ")
+      )
       .join("; ");
   }
   return error.message;
diff --git a/src/open-api-helpers.ts b/src/open-api-helpers.ts
index 4e379b16..51605ef4 100644
--- a/src/open-api-helpers.ts
+++ b/src/open-api-helpers.ts
@@ -561,8 +561,10 @@ export function extractObjectSchema(subject: IOSchema) {
     objectSchema = Array.from(subject.options.values())
       .map((option) => extractObjectSchema(option))
       .reduce((acc, option) => acc.merge(option.partial()), z.object({}));
+  } else if (subject instanceof z.ZodEffects) {
+    objectSchema = extractObjectSchema(subject._def.schema); // object refinement
   } else {
-    // intersection schema
+    // intersection
     objectSchema = extractObjectSchema(subject._def.left).merge(
       extractObjectSchema(subject._def.right)
     );


Test patch:
diff --git a/tests/unit/__snapshots__/common-helpers.spec.ts.snap b/tests/unit/__snapshots__/common-helpers.spec.ts.snap
index 4e1a352b..6a598e88 100644
--- a/tests/unit/__snapshots__/common-helpers.spec.ts.snap
+++ b/tests/unit/__snapshots__/common-helpers.spec.ts.snap
@@ -279,6 +279,8 @@ exports[`Common Helpers getFinalEndpointInputSchema() Should merge union object
 
 exports[`Common Helpers getMessageFromError() should compile a string from ZodError 1`] = `"user/id: expected number, got string; user/name: expected string, got number"`;
 
+exports[`Common Helpers getMessageFromError() should handle empty path in ZodIssue 1`] = `"Top level refinement issue"`;
+
 exports[`Common Helpers getMessageFromError() should pass message from other error types 1`] = `"something went wrong"`;
 
 exports[`Common Helpers getMessageFromError() should pass message from other error types 2`] = `"something went wrong"`;
diff --git a/tests/unit/__snapshots__/endpoints-factory.spec.ts.snap b/tests/unit/__snapshots__/endpoints-factory.spec.ts.snap
index f08556c3..8bc267ca 100644
--- a/tests/unit/__snapshots__/endpoints-factory.spec.ts.snap
+++ b/tests/unit/__snapshots__/endpoints-factory.spec.ts.snap
@@ -44,6 +44,51 @@ exports[`EndpointsFactory .build() Should create an endpoint with intersection m
 }
 `;
 
+exports[`EndpointsFactory .build() Should create an endpoint with refined object middleware 1`] = `
+{
+  "_type": "ZodIntersection",
+  "left": {
+    "_type": "ZodEffects",
+    "value": {
+      "_type": "ZodObject",
+      "shape": {
+        "a": {
+          "_type": "ZodOptional",
+          "value": {
+            "_type": "ZodNumber",
+          },
+        },
+        "b": {
+          "_type": "ZodOptional",
+          "value": {
+            "_type": "ZodString",
+          },
+        },
+      },
+    },
+  },
+  "right": {
+    "_type": "ZodObject",
+    "shape": {
+      "i": {
+        "_type": "ZodString",
+      },
+    },
+  },
+}
+`;
+
+exports[`EndpointsFactory .build() Should create an endpoint with refined object middleware 2`] = `
+{
+  "_type": "ZodObject",
+  "shape": {
+    "o": {
+      "_type": "ZodBoolean",
+    },
+  },
+}
+`;
+
 exports[`EndpointsFactory .build() Should create an endpoint with simple middleware 1`] = `
 {
   "_type": "ZodIntersection",
diff --git a/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap b/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap
index 49539f7a..bdd09a04 100644
--- a/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap
+++ b/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap
@@ -140,6 +140,20 @@ exports[`Open API helpers depictEffect() should depict ZodEffects transformation
 }
 `;
 
+exports[`Open API helpers depictEffect() should depict refinements 1`] = `
+{
+  "properties": {
+    "s": {
+      "type": "string",
+    },
+  },
+  "required": [
+    "s",
+  ],
+  "type": "object",
+}
+`;
+
 exports[`Open API helpers depictEnum() should depict ZodEnum 1`] = `
 {
   "description": "test",
@@ -851,6 +865,17 @@ exports[`Open API helpers excludeParamsFromDepiction() should omit specified pat
 }
 `;
 
+exports[`Open API helpers extractObjectSchema() should handle refined object schema 1`] = `
+{
+  "_type": "ZodObject",
+  "shape": {
+    "one": {
+      "_type": "ZodString",
+    },
+  },
+}
+`;
+
 exports[`Open API helpers extractObjectSchema() should pass the object schema through 1`] = `
 {
   "_type": "ZodObject",
diff --git a/tests/unit/common-helpers.spec.ts b/tests/unit/common-helpers.spec.ts
index 2ed0198d..5b00f1c2 100644
--- a/tests/unit/common-helpers.spec.ts
+++ b/tests/unit/common-helpers.spec.ts
@@ -1,5 +1,5 @@
 import { UploadedFile } from "express-fileupload";
-import { expectType } from "tsd";
+import { expectNotType, expectType } from "tsd";
 import {
   combinations,
   defaultInputSources,
@@ -10,6 +10,7 @@ import {
   getRoutePathParams,
   getStatusCodeFromError,
   hasUpload,
+  IOSchema,
   isLoggerConfig,
   isValidDate,
   makeErrorFromAnything,
@@ -21,6 +22,50 @@ import { AnyMiddlewareDef } from "../../src/middleware";
 import { serializeSchemaForTest } from "../helpers";
 
 describe("Common Helpers", () => {
+  describe("IOSchema", () => {
+    test("accepts object", () => {
+      expectType<IOSchema>(z.object({}));
+      expectType<IOSchema<"strip">>(z.object({}));
+      expectType<IOSchema<"strict">>(z.object({}).strict());
+      expectType<IOSchema<"passthrough">>(z.object({}).passthrough());
+      expectType<IOSchema<"strip">>(z.object({}).strip());
+      expectNotType<IOSchema<"passthrough">>(z.object({}));
+    });
+    test("accepts union of objects", () => {
+      expectType<IOSchema>(z.union([z.object({}), z.object({})]));
+      expectType<IOSchema>(z.object({}).or(z.object({})));
+      expectType<IOSchema>(z.object({}).or(z.object({}).or(z.object({}))));
+      expectNotType<IOSchema>(z.object({}).or(z.string()));
+    });
+    test("accepts intersection of objects", () => {
+      expectType<IOSchema>(z.intersection(z.object({}), z.object({})));
+      expectType<IOSchema>(z.object({}).and(z.object({})));
+      expectType<IOSchema>(z.object({}).and(z.object({}).and(z.object({}))));
+      expectNotType<IOSchema>(z.object({}).and(z.string()));
+    });
+    test("accepts discriminated union of objects", () => {
+      expectType<IOSchema>(
+        z.discriminatedUnion("type", [
+          z.object({ type: z.literal("one") }),
+          z.object({ type: z.literal("two") }),
+        ])
+      );
+    });
+    test("accepts a mix of types based on object", () => {
+      expectType<IOSchema>(z.object({}).or(z.object({}).and(z.object({}))));
+      expectType<IOSchema>(z.object({}).and(z.object({}).or(z.object({}))));
+    });
+    test("accepts a refinement of object", () => {
+      expectType<IOSchema>(z.object({}).refine(() => true));
+      expectType<IOSchema>(
+        z.object({}).refine((obj) => ({ ...obj, test: true }))
+      );
+      expectNotType<IOSchema>(
+        z.object({}).transform((obj) => Object.keys(obj))
+      );
+    });
+  });
+
   describe("getFinalEndpointInputSchema()", () => {
     test("Should handle no middlewares", () => {
       const middlewares: AnyMiddlewareDef[] = [];
@@ -449,6 +494,13 @@ describe("Common Helpers", () => {
       expect(getMessageFromError(error)).toMatchSnapshot();
     });
 
+    test("should handle empty path in ZodIssue", () => {
+      const error = new z.ZodError([
+        { code: "custom", path: [], message: "Top level refinement issue" },
+      ]);
+      expect(getMessageFromError(error)).toMatchSnapshot();
+    });
+
     test("should pass message from other error types", () => {
       expect(
         getMessageFromError(createHttpError(502, "something went wrong"))
diff --git a/tests/unit/endpoint.spec.ts b/tests/unit/endpoint.spec.ts
index aa46cadf..ed72e22a 100644
--- a/tests/unit/endpoint.spec.ts
+++ b/tests/unit/endpoint.spec.ts
@@ -171,6 +171,25 @@ describe("Endpoint", () => {
   });
 
   describe("#parseOutput", () => {
+    test("Should throw on output validation failure", async () => {
+      const endpoint = defaultEndpointsFactory.build({
+        method: "post",
+        input: z.object({}),
+        output: z.object({ email: z.string().email() }),
+        handler: async () => ({ email: "not email" }),
+      });
+      const { responseMock } = await testEndpoint({
+        endpoint,
+      });
+      expect(responseMock.status).toBeCalledWith(400);
+      expect(responseMock.json).toBeCalledWith({
+        status: "error",
+        error: {
+          message: "output: Invalid format; email: Invalid email",
+        },
+      });
+    });
+
     test("Should throw on output parsing non-Zod error", async () => {
       const factory = new EndpointsFactory(defaultResultHandler);
       const endpoint = factory.build({
@@ -544,4 +563,152 @@ describe("Endpoint", () => {
       });
     });
   });
+
+  describe("Issue #654: Top level refinements", () => {
+    const endpoint = defaultEndpointsFactory.build({
+      method: "post",
+      input: z
+        .object({
+          type: z.union([z.literal("type1"), z.literal("type2")]),
+          dynamicValue: z.union([
+            z.object({ type1Attribute: z.number() }),
+            z.object({ type2Attribute: z.string() }),
+          ]),
+          emitOutputValidationFailure: z.boolean().optional(),
+        })
+        .refine(
+          (data) => {
+            if (data.type === "type1") {
+              return "type1Attribute" in data.dynamicValue;
+            }
+            return "type2Attribute" in data.dynamicValue;
+          },
+          {
+            message: "type1Attribute is required if type is type1",
+            path: ["dynamicValue"],
+          }
+        ),
+      output: z
+        .object({})
+        .passthrough()
+        .refine((obj) => !("emitOutputValidationFailure" in obj), {
+          message: "failure on demand",
+        }),
+      handler: async ({ input }) =>
+        input.emitOutputValidationFailure
+          ? { emitOutputValidationFailure: true }
+          : {},
+    });
+
+    test("should accept valid inputs", async () => {
+      const { responseMock } = await testEndpoint({
+        endpoint,
+        requestProps: {
+          method: "POST",
+          body: {
+            type: "type1",
+            dynamicValue: { type1Attribute: 123 },
+          },
+        },
+      });
+      expect(responseMock.json).toHaveBeenCalledWith({
+        data: {},
+        status: "success",
+      });
+      expect(responseMock.status).toHaveBeenCalledWith(200);
+    });
+
+    test("should fail during the refinement of invalid inputs", async () => {
+      const { responseMock } = await testEndpoint({
+        endpoint,
+        requestProps: {
+          method: "POST",
+          body: {
+            type: "type1",
+            dynamicValue: { type2Attribute: "test" },
+          },
+        },
+      });
+      expect(responseMock.json).toHaveBeenCalledWith({
+        error: {
+          message: "dynamicValue: type1Attribute is required if type is type1",
+        },
+        status: "error",
+      });
+      expect(responseMock.status).toHaveBeenCalledWith(400);
+    });
+
+    test("should refine the output schema as well", async () => {
+      const { responseMock } = await testEndpoint({
+        endpoint,
+        requestProps: {
+          method: "POST",
+          body: {
+            type: "type1",
+            dynamicValue: { type1Attribute: 123 },
+            emitOutputValidationFailure: true,
+          },
+        },
+      });
+      expect(responseMock.json).toHaveBeenCalledWith({
+        status: "error",
+        error: {
+          message: "output: Invalid format; output: failure on demand",
+        },
+      });
+      expect(responseMock.status).toHaveBeenCalledWith(400);
+    });
+  });
+
+  describe("Feature #600: Top level refinements", () => {
+    const endpoint = defaultEndpointsFactory.build({
+      method: "post",
+      input: z
+        .object({
+          email: z.string().email().optional(),
+          id: z.string().optional(),
+          otherThing: z.string().optional(),
+        })
+        .refine(
+          (x) => Object.keys(x).length >= 1,
+          "Please provide at least one property"
+        ),
+      output: z.object({}),
+      handler: async () => ({}),
+    });
+
+    test("should accept valid inputs", async () => {
+      const { responseMock } = await testEndpoint({
+        endpoint,
+        requestProps: {
+          method: "POST",
+          body: {
+            id: "test",
+          },
+        },
+      });
+      expect(responseMock.json).toHaveBeenCalledWith({
+        data: {},
+        status: "success",
+      });
+      expect(responseMock.status).toHaveBeenCalledWith(200);
+    });
+
+    test("should fail during the refinement of invalid inputs", async () => {
+      const { responseMock } = await testEndpoint({
+        endpoint,
+        requestProps: {
+          method: "POST",
+          body: {},
+        },
+      });
+      expect(responseMock.json).toHaveBeenCalledWith({
+        error: {
+          message: "Please provide at least one property",
+        },
+        status: "error",
+      });
+      expect(responseMock.status).toHaveBeenCalledWith(400);
+    });
+  });
 });
diff --git a/tests/unit/endpoints-factory.spec.ts b/tests/unit/endpoints-factory.spec.ts
index 1bba9131..fcbdc3d1 100644
--- a/tests/unit/endpoints-factory.spec.ts
+++ b/tests/unit/endpoints-factory.spec.ts
@@ -263,6 +263,49 @@ describe("EndpointsFactory", () => {
       >(endpoint.getInputSchema());
     });
 
+    test("Should create an endpoint with refined object middleware", () => {
+      const middleware = createMiddleware({
+        input: z
+          .object({
+            a: z.number().optional(),
+            b: z.string().optional(),
+          })
+          .refine((props) => Object.keys(props).length, {
+            message: "Should be at least one option specified",
+          }),
+        middleware: jest.fn(),
+      });
+      const resultHandlerMock = { handler: jest.fn() };
+      const factory = new EndpointsFactory(
+        resultHandlerMock as unknown as ResultHandlerDefinition<any, any>
+      ).addMiddleware(middleware);
+      const endpoint = factory.build({
+        method: "get",
+        input: z.object({
+          i: z.string(),
+        }),
+        output: z.object({
+          o: z.boolean(),
+        }),
+        handler: jest.fn(),
+      });
+      expect(serializeSchemaForTest(endpoint["inputSchema"])).toMatchSnapshot();
+      expect(
+        serializeSchemaForTest(endpoint["outputSchema"])
+      ).toMatchSnapshot();
+      expectType<
+        z.ZodIntersection<
+          z.ZodEffects<
+            z.ZodObject<{
+              a: z.ZodOptional<z.ZodNumber>;
+              b: z.ZodOptional<z.ZodString>;
+            }>
+          >,
+          z.ZodObject<{ i: z.ZodString }>
+        >
+      >(endpoint.getInputSchema());
+    });
+
     test("Should create an endpoint with intersection middleware", () => {
       const middleware = createMiddleware({
         input: z
diff --git a/tests/unit/open-api-helpers.spec.ts b/tests/unit/open-api-helpers.spec.ts
index 3a7a9f3c..23ebe3d0 100644
--- a/tests/unit/open-api-helpers.spec.ts
+++ b/tests/unit/open-api-helpers.spec.ts
@@ -57,6 +57,18 @@ describe("Open API helpers", () => {
       expect(serializeSchemaForTest(subject)).toMatchSnapshot();
     });
 
+    test("should handle refined object schema", () => {
+      const subject = extractObjectSchema(
+        z
+          .object({
+            one: z.string(),
+          })
+          .refine(() => true)
+      );
+      expect(subject).toBeInstanceOf(z.ZodObject);
+      expect(serializeSchemaForTest(subject)).toMatchSnapshot();
+    });
+
     test("should return object schema for the union of object schemas", () => {
       const subject = extractObjectSchema(
         z
@@ -103,6 +115,23 @@ describe("Open API helpers", () => {
         },
       ]);
 
+      const refinedObjSchema = withMeta(
+        z
+          .object({
+            one: z.string(),
+          })
+          .refine(() => true)
+      ).example({
+        one: "test",
+      });
+      expect(
+        getMeta(extractObjectSchema(refinedObjSchema), "examples")
+      ).toEqual([
+        {
+          one: "test",
+        },
+      ]);
+
       const unionSchema = withMeta(
         z
           .object({
@@ -610,6 +639,17 @@ describe("Open API helpers", () => {
         })
       ).toMatchSnapshot();
     });
+
+    test("should depict refinements", () => {
+      expect(
+        depictEffect({
+          schema: z
+            .object({ s: z.string() })
+            .refine(() => false, { message: "test" }),
+          isResponse: false,
+        })
+      ).toMatchSnapshot();
+    });
   });
 
   describe("depictIOExamples()", () => {