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

Issue:
OpenAPI spec generates extra examples if the output schema extends the input schema
### Steps to reproduce:
- Add test (e.g. at `tests/unit/open-api.spec.ts`):
```
test("should pass over the multiple examples of an object", () => {
  const zodSchema = z.object({ a: z.string() });
  const spec = new OpenAPI({
    config: sampleConfig,
    routing: {
      v1: {
        addSomething: defaultEndpointsFactory.build({
          method: "post",
          input: withMeta(zodSchema).example({ a: "first" }),
          output: withMeta(zodSchema.extend({ b: z.string() }))
            .example({ a: "first", b: "prefix_first" })
            .example({ a: "second", b: "prefix_second" }),
          handler: async ({ input: { a } }) => ({ a, b: `prefix_${a}` }),
        }),
      },
    },
    version: "3.4.5",
    title: "Testing Metadata:example on IO parameter",
    serverUrl: "http://example.com",
  }).getSpecAsYaml();
  expect(spec).toMatchSnapshot();
});
```
- Add snapshot (e.g. at `tests/unit/__snapshots__/open-api.spec.ts.snap`):
```
exports[`Open API generator Metadata should pass over the multiple examples of an object 1`] = `
"openapi: 3.0.0
info:
  title: Testing Metadata:example on IO parameter
  version: 3.4.5
paths:
  /v1/addSomething:
    post:
      responses:
        "200":
          description: POST /v1/addSomething Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum:
                      - success
                  data:
                    type: object
                    properties:
                      a:
                        type: string
                      b:
                        type: string
                    required:
                      - a
                      - b
                    example:
                      a: first
                      b: prefix_first
                required:
                  - status
                  - data
              examples:
                example1:
                  value:
                    status: success
                    data:
                      a: first
                      b: prefix_first
                example2:
                  value:
                    status: success
                    data:
                      a: second
                      b: prefix_second
        "400":
          description: POST /v1/addSomething 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
              examples:
                example1:
                  value:
                    status: error
                    error:
                      message: Sample error message
      requestBody:
        content:
          application/json:
            schema:
              description: POST /v1/addSomething request body
              type: object
              properties:
                a:
                  type: string
              required:
                - a
            examples:
              example1:
                value:
                  a: first
components:
  schemas: {}
  responses: {}
  parameters: {}
  examples: {}
  requestBodies: {}
  headers: {}
  securitySchemes: {}
  links: {}
  callbacks: {}
tags: []
servers:
  - url: http://example.com
"
`;
```
- run test

### Actual behaviour:
The above test fails because there are extra generated examples in the endpoint `requestBody`:
```
                  - a
                  examples:
                    example1:
                      value:
                        a: first
    +               example2:
    +                 value:
    +                   a: first
    +                   b: prefix_first
    +               example3:
    +                 value:
    +                   a: second
    +                   b: prefix_second
    +               example4:
    +                 value:
    +                   a: first
    +                   b: prefix_first
    +               example5:
    +                 value:
    +                   a: first
    +                   b: prefix_first
    +               example6:
    +                 value:
    +                   a: second
    +                   b: prefix_second
    +               example7:
    +                 value:
    +                   a: first
    +                   b: prefix_second
    +               example8:
    +                 value:
    +                   a: first
    +                   b: prefix_first
    +               example9:
    +                 value:
    +                   a: second
    +                   b: prefix_second
      components:
        schemas: {}
        responses: {}
        parameters: {}
        examples: {}
```

### Expected behaviour
The above test passes.

---
The workaround is to use
```
input: withMeta(zodSchema.extend({})).example(...
```
instead of
```
input: withMeta(zodSchema).example(...
```
So my guess is that the reason for this behaviour is that [withMeta mutates its argument](https://github.com/RobinTail/express-zod-api/blob/712560ca135da7fe08b11cb7bfacb1fb3070aa1c/CHANGELOG.md#L1218)

Pull request:
Immutable `withMeta`
Fixes #827 

Patch:
diff --git a/src/io-schema.ts b/src/io-schema.ts
index e820aceb..18ab7a08 100644
--- a/src/io-schema.ts
+++ b/src/io-schema.ts
@@ -32,6 +32,8 @@ export type ProbableIntersection<
 /**
  * @description intersects input schemas of middlewares and the endpoint
  * @since 07.03.2022 former combineEndpointAndMiddlewareInputSchemas()
+ * @since 05.03.2023 is immutable to metadata
+ * @see copyMeta
  */
 export const getFinalEndpointInputSchema = <
   MIN extends IOSchema<"strip"> | null,
@@ -40,13 +42,13 @@ export const getFinalEndpointInputSchema = <
   middlewares: AnyMiddlewareDef[],
   input: IN
 ): ProbableIntersection<MIN, IN> => {
-  const result = middlewares
+  const allSchemas = middlewares
     .map(({ input: schema }) => schema)
-    .concat(input)
-    .reduce((acc, schema) => acc.and(schema)) as ProbableIntersection<MIN, IN>;
-  for (const middleware of middlewares) {
-    copyMeta(middleware.input, result);
-  }
-  copyMeta(input, result);
-  return result;
+    .concat(input);
+
+  const finalSchema = allSchemas.reduce((acc, schema) =>
+    acc.and(schema)
+  ) as ProbableIntersection<MIN, IN>;
+
+  return allSchemas.reduce((acc, schema) => copyMeta(schema, acc), finalSchema);
 };
diff --git a/src/metadata.ts b/src/metadata.ts
index e62cd0e2..b4002334 100644
--- a/src/metadata.ts
+++ b/src/metadata.ts
@@ -1,6 +1,6 @@
 import { combinations } from "./common-helpers";
 import { z } from "zod";
-import { mergeDeepRight } from "ramda";
+import { clone, mergeDeepRight } from "ramda";
 
 export const metaProp = "expressZodApiMeta";
 type MetaProp = typeof metaProp;
@@ -32,20 +32,26 @@ type WithMeta<T extends z.ZodTypeAny> = MetaFixForStrippedObject<T> & {
   example: ExampleSetter<T>;
 };
 
-export const withMeta = <T extends z.ZodTypeAny>(schema: T) => {
-  const def = schema._def as MetaDef<T>;
+/** @desc it's the same approach as in zod's .describe() */
+const cloneSchemaForMeta = <T extends z.ZodTypeAny>(schema: T): WithMeta<T> => {
+  const This = (schema as any).constructor;
+  const def = clone(schema._def) as MetaDef<T>;
   def[metaProp] = def[metaProp] || { examples: [] };
-  if (!("example" in schema)) {
-    Object.defineProperties(schema, {
-      example: {
-        get: (): ExampleSetter<T> => (value) => {
-          def[metaProp].examples.push(value);
-          return schema as WithMeta<T>;
-        },
+  return new This(def) as WithMeta<T>;
+};
+
+export const withMeta = <T extends z.ZodTypeAny>(schema: T): WithMeta<T> => {
+  const copy = cloneSchemaForMeta(schema);
+  Object.defineProperties(copy, {
+    example: {
+      get: (): ExampleSetter<T> => (value) => {
+        const localCopy = withMeta<T>(copy);
+        localCopy._def[metaProp].examples.push(value);
+        return localCopy;
       },
-    });
-  }
-  return schema as WithMeta<T>;
+    },
+  });
+  return copy;
 };
 
 export const hasMeta = <T extends z.ZodTypeAny>(
@@ -77,25 +83,20 @@ export const copyMeta = <A extends z.ZodTypeAny, B extends z.ZodTypeAny>(
   if (!hasMeta(src)) {
     return dest;
   }
-  dest = withMeta(dest);
-  const def = dest._def as MetaDef<B>;
-  const examplesCombinations = combinations(
-    def[metaProp].examples,
+  const result = withMeta(dest);
+  const examplesCombinations = combinations<B>(
+    result._def[metaProp].examples,
     src._def[metaProp].examples
   );
-  // general deep merge except examples
-  def[metaProp] = mergeDeepRight(
-    { ...def[metaProp], examples: [] },
-    { ...src._def[metaProp], examples: [] }
-  );
+  result._def[metaProp].examples = []; // if added more meta, restore mergeDeepRight
   if (examplesCombinations.type === "single") {
-    def[metaProp].examples = examplesCombinations.value;
+    result._def[metaProp].examples = examplesCombinations.value;
   } else {
     for (const [destExample, srcExample] of examplesCombinations.value) {
-      def[metaProp].examples.push(
+      result._def[metaProp].examples.push(
         mergeDeepRight({ ...destExample }, { ...srcExample })
       );
     }
   }
-  return dest;
+  return result;
 };
diff --git a/src/result-handler.ts b/src/result-handler.ts
index 8faf5b40..3db1cecb 100644
--- a/src/result-handler.ts
+++ b/src/result-handler.ts
@@ -56,14 +56,14 @@ export const defaultResultHandler = createResultHandler({
         data: output,
       })
     );
-    for (const example of examples) {
-      // forwarding output examples to response schema
-      responseSchema.example({
-        status: "success",
-        data: example,
-      });
-    }
-    return responseSchema;
+    return examples.reduce<typeof responseSchema>(
+      (acc, example) =>
+        acc.example({
+          status: "success",
+          data: example,
+        }),
+      responseSchema
+    );
   },
   getNegativeResponse: () =>
     withMeta(


Test patch:
diff --git a/tests/unit/__snapshots__/open-api.spec.ts.snap b/tests/unit/__snapshots__/open-api.spec.ts.snap
index 00743293..5ddfc811 100644
--- a/tests/unit/__snapshots__/open-api.spec.ts.snap
+++ b/tests/unit/__snapshots__/open-api.spec.ts.snap
@@ -266,6 +266,114 @@ servers:
 "
 `;
 
+exports[`Open API generator Metadata Issue #827: withMeta() should be immutable 1`] = `
+"openapi: 3.0.0
+info:
+  title: Testing Metadata:example on IO parameter
+  version: 3.4.5
+paths:
+  /v1/addSomething:
+    post:
+      operationId: PostV1Addsomething
+      responses:
+        "200":
+          description: POST /v1/addSomething Successful response
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  status:
+                    type: string
+                    enum:
+                      - success
+                  data:
+                    type: object
+                    properties:
+                      a:
+                        type: string
+                      b:
+                        type: string
+                    required:
+                      - a
+                      - b
+                    example:
+                      a: first
+                      b: prefix_first
+                required:
+                  - status
+                  - data
+              examples:
+                example1:
+                  value:
+                    status: success
+                    data:
+                      a: first
+                      b: prefix_first
+                example2:
+                  value:
+                    status: success
+                    data:
+                      a: second
+                      b: prefix_second
+        "400":
+          description: POST /v1/addSomething 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
+              examples:
+                example1:
+                  value:
+                    status: error
+                    error:
+                      message: Sample error message
+      requestBody:
+        content:
+          application/json:
+            schema:
+              description: POST /v1/addSomething request body
+              type: object
+              properties:
+                a:
+                  type: string
+              required:
+                - a
+            examples:
+              example1:
+                value:
+                  a: first
+components:
+  schemas: {}
+  responses: {}
+  parameters: {}
+  examples: {}
+  requestBodies: {}
+  headers: {}
+  securitySchemes: {}
+  links: {}
+  callbacks: {}
+tags: []
+servers:
+  - url: http://example.com
+"
+`;
+
 exports[`Open API generator Metadata should merge endpoint handler examples with its middleware examples 1`] = `
 "openapi: 3.0.0
 info:
diff --git a/tests/unit/metadata.spec.ts b/tests/unit/metadata.spec.ts
index e5290d1a..88752ddc 100644
--- a/tests/unit/metadata.spec.ts
+++ b/tests/unit/metadata.spec.ts
@@ -14,10 +14,11 @@ describe("Metadata", () => {
       const schema = z.string();
       const schemaWithMeta = withMeta(schema);
       expect(schemaWithMeta).toBeInstanceOf(z.ZodString);
-      expect(schemaWithMeta).toEqual(schema);
       expect(metaProp).toBe("expressZodApiMeta");
       expect(schemaWithMeta._def).toHaveProperty(metaProp);
-      expect(schemaWithMeta._def[metaProp]).toEqual({ examples: [] });
+      const { expressZodApiMeta, ...rest } = schemaWithMeta._def;
+      expect(rest).toEqual(schema._def);
+      expect(expressZodApiMeta).toEqual({ examples: [] });
     });
 
     test("should provide example() method", () => {
@@ -30,8 +31,17 @@ describe("Metadata", () => {
     test("example() should set the corresponding metadata in the schema definition", () => {
       const schema = z.string();
       const schemaWithMeta = withMeta(schema).example("test");
-      expect(schemaWithMeta._def[metaProp]).toHaveProperty("examples");
-      expect(schemaWithMeta._def[metaProp].examples).toEqual(["test"]);
+      expect(schemaWithMeta._def.expressZodApiMeta).toHaveProperty("examples");
+      expect(schemaWithMeta._def.expressZodApiMeta.examples).toEqual(["test"]);
+    });
+
+    test("Issue 827: example() should be immutable", () => {
+      const schemaWithMeta = withMeta(z.string());
+      const schemaWithExample = schemaWithMeta.example("test");
+      expect(schemaWithExample._def.expressZodApiMeta.examples).toEqual([
+        "test",
+      ]);
+      expect(schemaWithMeta._def.expressZodApiMeta.examples).toEqual([]);
     });
 
     test("example() can set multiple examples", () => {
@@ -40,7 +50,7 @@ describe("Metadata", () => {
         .example("test1")
         .example("test2")
         .example("test3");
-      expect(schemaWithMeta._def[metaProp].examples).toEqual([
+      expect(schemaWithMeta._def.expressZodApiMeta.examples).toEqual([
         "test1",
         "test2",
         "test3",
@@ -50,24 +60,25 @@ describe("Metadata", () => {
     test("metadata should withstand refinements", () => {
       const schema = z.string();
       const schemaWithMeta = withMeta(schema).example("test");
-      expect(schemaWithMeta._def[metaProp].examples).toEqual(["test"]);
+      expect(schemaWithMeta._def.expressZodApiMeta.examples).toEqual(["test"]);
       expect(
         (
           schemaWithMeta.email()._def as unknown as MetaDef<
             typeof schemaWithMeta
           >
-        )[metaProp].examples
+        ).expressZodApiMeta.examples
       ).toEqual(["test"]);
     });
 
     test("metadata should withstand double withMeta()", () => {
       const schema = z.string();
       const schemaWithMeta = withMeta(schema).example("test");
-      expect(withMeta(schemaWithMeta)._def[metaProp].examples).toEqual([
+      expect(withMeta(schemaWithMeta)._def.expressZodApiMeta.examples).toEqual([
         "test",
       ]);
       expect(
-        withMeta(schemaWithMeta).example("another")._def[metaProp].examples
+        withMeta(schemaWithMeta).example("another")._def.expressZodApiMeta
+          .examples
       ).toEqual(["test", "another"]);
     });
   });
@@ -135,7 +146,6 @@ describe("Metadata", () => {
       const result = copyMeta(src, dest);
       expect(hasMeta(result)).toBeTruthy();
       expect(getMeta(result, "examples")).toEqual(getMeta(src, "examples"));
-      expect(result).toEqual(dest);
     });
 
     test("should merge the meta from src to dest (deep merge)", () => {
@@ -174,7 +184,6 @@ describe("Metadata", () => {
         { a: "some", b: 789 },
         { a: "another", b: 789 },
       ]);
-      expect(result).toEqual(dest);
     });
   });
 });
diff --git a/tests/unit/open-api.spec.ts b/tests/unit/open-api.spec.ts
index d41631a0..6b4623cf 100644
--- a/tests/unit/open-api.spec.ts
+++ b/tests/unit/open-api.spec.ts
@@ -959,5 +959,28 @@ describe("Open API generator", () => {
       }).getSpecAsYaml();
       expect(spec).toMatchSnapshot();
     });
+
+    test("Issue #827: withMeta() should be immutable", () => {
+      const zodSchema = z.object({ a: z.string() });
+      const spec = new OpenAPI({
+        config: sampleConfig,
+        routing: {
+          v1: {
+            addSomething: defaultEndpointsFactory.build({
+              method: "post",
+              input: withMeta(zodSchema).example({ a: "first" }),
+              output: withMeta(zodSchema.extend({ b: z.string() }))
+                .example({ a: "first", b: "prefix_first" })
+                .example({ a: "second", b: "prefix_second" }),
+              handler: async ({ input: { a } }) => ({ a, b: `prefix_${a}` }),
+            }),
+          },
+        },
+        version: "3.4.5",
+        title: "Testing Metadata:example on IO parameter",
+        serverUrl: "http://example.com",
+      }).getSpecAsYaml();
+      expect(spec).toMatchSnapshot();
+    });
   });
 });