Repository: typestack/class-validator (ts)

Issue:
docs: add link to contribution guide
### Description
If you use your browser's search function to search for `'contrib'` on this repository's home page, you won't find any information about how to contribute. I myself was unaware of the existing contribution guide in [TypeStack/.github](https://github.com/typestack/.github/blob/master/CONTRIBUTING.md) until recently

### Proposed solution
We should add a link to CONTRIBUTING.md in this repository's readme, or perhaps make a dedicated CONTRIBUTING.md here.


Pull request:
docs: add link to contribution guide in README
## Description
Adds a new heading for contributing in the readme, which directs you to the existing contribution guideline under Typestack/.github

## Checklist
<!-- Replace  the [ ] with [x] to check the boxes. -->
- [x] the pull request title describes what this PR does (not a vague title like `Update index.md`)
- [x] the pull request targets the *default* branch of the repository (`develop`)
- [x] the code follows the established code style of the repository
  - `npm run prettier:check` passes
  - `npm run lint:check` passes
- [x] tests are added for the changes I made (if any source code was modified)
- [x] documentation added or updated
- [ ] I have run the project locally and verified that there are no errors

### Fixes
fixes #1784


Patch:
diff --git a/README.md b/README.md
index 4d1cccc..a90ff96 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,7 @@ Class-validator works on both browser and node.js platforms.
   - [Samples](#samples)
   - [Extensions](#extensions)
   - [Release notes](#release-notes)
+  - [Contributing](#contributing)
 
 ## Installation
 
@@ -885,6 +886,7 @@ isBoolean(value);
 | `@MaxLength(max: number)`                       | Checks if the string's length is not more than given number. |
 | `@Matches(pattern: RegExp, modifiers?: string)` | Checks if string matches the pattern. Either matches('foo', /foo/i) or matches('foo', 'foo', 'i'). |
 | `@IsMilitaryTime()`                             | Checks if the string is a valid representation of military time in the format HH:MM. |
+| `@IsTimeZone()`                                 | Checks if the string represents a valid IANA time-zone. |
 | `@IsHash(algorithm: string)`                    | Checks if the string is a hash The following types are supported:`md4`, `md5`, `sha1`, `sha256`, `sha384`, `sha512`, `ripemd128`, `ripemd160`, `tiger128`, `tiger160`, `tiger192`, `crc32`, `crc32b`. |
 | `@IsMimeType()`                                 | Checks if the string matches to a valid [MIME type](https://en.wikipedia.org/wiki/Media_type) format |
 | `@IsSemVer()`                                   | Checks if the string is a Semantic Versioning Specification (SemVer). |
@@ -932,3 +934,7 @@ See information about breaking changes and release notes [here][3].
 [1]: https://github.com/chriso/validator.js
 [2]: https://github.com/pleerock/typedi
 [3]: CHANGELOG.md
+
+## Contributing
+
+For information about how to contribute to this project, see [TypeStack's general contribution guide](https://github.com/typestack/.github/blob/master/CONTRIBUTING.md).
diff --git a/src/decorator/decorators.ts b/src/decorator/decorators.ts
index 41b843f..d8e9b56 100644
--- a/src/decorator/decorators.ts
+++ b/src/decorator/decorators.ts
@@ -111,6 +111,7 @@ export * from './string/IsPostalCode';
 export * from './string/IsRFC3339';
 export * from './string/IsRgbColor';
 export * from './string/IsSemVer';
+export * from './string/IsTimeZone';
 
 // -------------------------------------------------------------------------
 // Type checkers
diff --git a/src/decorator/string/IsTimeZone.ts b/src/decorator/string/IsTimeZone.ts
new file mode 100644
index 0000000..3504d0a
--- /dev/null
+++ b/src/decorator/string/IsTimeZone.ts
@@ -0,0 +1,43 @@
+import { ValidationOptions } from '../ValidationOptions';
+import { buildMessage, ValidateBy } from '../common/ValidateBy';
+
+export const IS_TIMEZONE = 'isTimeZone';
+
+/**
+ * Checks if the string represents a valid IANA timezone
+ * If the given value is not a valid IANA timezone, then it returns false.
+ */
+export function isTimeZone(value: unknown): boolean {
+  try {
+    if (typeof value !== 'string') {
+      return false;
+    }
+
+    /** Specifying an invalid time-zone will raise a `RangeError: Invalid time zone specified` error. */
+    Intl.DateTimeFormat(undefined, { timeZone: value });
+
+    return true;
+  } catch (exception) {
+    return false;
+  }
+}
+
+/**
+ * Checks if the string represents a valid IANA timezone
+ * If the given value is not a valid IANA timezone, then it returns false.
+ */
+export function IsTimeZone(validationOptions?: ValidationOptions): PropertyDecorator {
+  return ValidateBy(
+    {
+      name: IS_TIMEZONE,
+      validator: {
+        validate: (value, args): boolean => isTimeZone(value),
+        defaultMessage: buildMessage(
+          eachPrefix => eachPrefix + '$property must be a valid IANA time-zone',
+          validationOptions
+        ),
+      },
+    },
+    validationOptions
+  );
+}
diff --git a/src/validation/ValidationExecutor.ts b/src/validation/ValidationExecutor.ts
index 5134a80..844bd20 100644
--- a/src/validation/ValidationExecutor.ts
+++ b/src/validation/ValidationExecutor.ts
@@ -205,7 +205,7 @@ export class ValidationExecutor {
     }
 
     this.customValidations(object, value, customValidationMetadatas, validationError);
-    this.nestedValidations(value, nestedValidationMetadatas, validationError.children);
+    this.nestedValidations(value, nestedValidationMetadatas, validationError);
 
     this.mapContexts(object, value, metadatas, validationError);
     this.mapContexts(object, value, customValidationMetadatas, validationError);
@@ -333,7 +333,7 @@ export class ValidationExecutor {
     });
   }
 
-  private nestedValidations(value: any, metadatas: ValidationMetadata[], errors: ValidationError[]): void {
+  private nestedValidations(value: any, metadatas: ValidationMetadata[], error: ValidationError): void {
     if (value === void 0) {
       return;
     }
@@ -341,27 +341,26 @@ export class ValidationExecutor {
     metadatas.forEach(metadata => {
       if (metadata.type !== ValidationTypes.NESTED_VALIDATION && metadata.type !== ValidationTypes.PROMISE_VALIDATION) {
         return;
+      } else if (
+        this.validatorOptions &&
+        this.validatorOptions.stopAtFirstError &&
+        Object.keys(error.constraints || {}).length > 0
+      ) {
+        return;
       }
 
       if (Array.isArray(value) || value instanceof Set || value instanceof Map) {
         // Treats Set as an array - as index of Set value is value itself and it is common case to have Object as value
         const arrayLikeValue = value instanceof Set ? Array.from(value) : value;
         arrayLikeValue.forEach((subValue: any, index: any) => {
-          this.performValidations(value, subValue, index.toString(), [], metadatas, errors);
+          this.performValidations(value, subValue, index.toString(), [], metadatas, error.children);
         });
       } else if (value instanceof Object) {
         const targetSchema = typeof metadata.target === 'string' ? metadata.target : metadata.target.name;
-        this.execute(value, targetSchema, errors);
+        this.execute(value, targetSchema, error.children);
       } else {
-        const error = new ValidationError();
-        error.value = value;
-        error.property = metadata.propertyName;
-        error.target = metadata.target as object;
         const [type, message] = this.createValidationError(metadata.target as object, value, metadata);
-        error.constraints = {
-          [type]: message,
-        };
-        errors.push(error);
+        error.constraints[type] = message;
       }
     });
   }


Test patch:
diff --git a/test/functional/nested-validation.spec.ts b/test/functional/nested-validation.spec.ts
index 862af95..b7c3eb1 100644
--- a/test/functional/nested-validation.spec.ts
+++ b/test/functional/nested-validation.spec.ts
@@ -165,10 +165,8 @@ describe('nested validation', () => {
     return validator.validate(model).then(errors => {
       expect(errors[0].target).toEqual(model);
       expect(errors[0].property).toEqual('mySubClass');
-      expect(errors[0].children.length).toEqual(1);
-
-      const subError = errors[0].children[0];
-      expect(subError.constraints).toEqual({
+      expect(errors[0].children.length).toEqual(0);
+      expect(errors[0].constraints).toEqual({
         [ValidationTypes.NESTED_VALIDATION]: 'nested property mySubClass must be either object or array',
       });
     });
@@ -307,4 +305,31 @@ describe('nested validation', () => {
       expect(subSubError.value).toEqual('my');
     });
   });
+
+  it('nestedValidation should be defined as an error for the property specifying the decorator when validation fails.', () => {
+    class MySubClass {
+      @MinLength(5)
+      name: string;
+    }
+
+    class MyClass {
+      @ValidateNested()
+      nestedWithClassValue: MySubClass;
+
+      @ValidateNested()
+      nestedWithPrimitiveValue: MySubClass;
+    }
+
+    const model = new MyClass();
+    model.nestedWithClassValue = new MySubClass();
+    model.nestedWithPrimitiveValue = 'invalid' as any;
+
+    return validator.validate(model, { stopAtFirstError: true }).then(errors => {
+      expect(errors[0].property).toEqual('nestedWithClassValue');
+      expect(errors[0].children.length).toEqual(1);
+      expect(errors[0].children[0].constraints).toHaveProperty('minLength');
+      expect(errors[1].property).toEqual('nestedWithPrimitiveValue');
+      expect(errors[1].constraints).toHaveProperty('nestedValidation');
+    });
+  });
 });
diff --git a/test/functional/promise-validation.spec.ts b/test/functional/promise-validation.spec.ts
index 0de1d1a..fdcb746 100644
--- a/test/functional/promise-validation.spec.ts
+++ b/test/functional/promise-validation.spec.ts
@@ -120,10 +120,8 @@ describe('promise validation', () => {
     return validator.validate(model).then(errors => {
       expect(errors[0].target).toEqual(model);
       expect(errors[0].property).toEqual('mySubClass');
-      expect(errors[0].children.length).toEqual(1);
-
-      const subError = errors[0].children[0];
-      expect(subError.constraints).toEqual({
+      expect(errors[0].children.length).toEqual(0);
+      expect(errors[0].constraints).toEqual({
         [ValidationTypes.NESTED_VALIDATION]: 'nested property mySubClass must be either object or array',
       });
     });
diff --git a/test/functional/validation-functions-and-decorators.spec.ts b/test/functional/validation-functions-and-decorators.spec.ts
index 49bc660..dae3640 100644
--- a/test/functional/validation-functions-and-decorators.spec.ts
+++ b/test/functional/validation-functions-and-decorators.spec.ts
@@ -184,6 +184,7 @@ import {
   isPostalCode,
   IsSemVer,
   isSemVer,
+  IsTimeZone,
 } from '../../src/decorator/decorators';
 import { Validator } from '../../src/validation/Validator';
 import { ValidatorOptions } from '../../src/validation/ValidatorOptions';
@@ -3829,6 +3830,28 @@ describe('IsMilitaryTime', () => {
   });
 });
 
+describe('IsTimeZone', () => {
+  class MyClass {
+    @IsTimeZone()
+    someProperty: string;
+  }
+
+  it('should not fail for a valid IANA timezones', () => {
+    const validValues = ['Asia/Kathmandu', 'America/New_York', 'Europe/Paris', 'Europe/Berlin'];
+    return checkValidValues(new MyClass(), validValues);
+  });
+
+  it('should fail for invalid timezone format', () => {
+    const invalidValues = ['Asia/Pokhara', 'America', 'New_York', '/Paris'];
+    return checkInvalidValues(new MyClass(), invalidValues);
+  });
+
+  it('should fail for invalid values', () => {
+    const invalidValues = [undefined, null, 'Asia-Kathmandu'];
+    return checkInvalidValues(new MyClass(), invalidValues);
+  });
+});
+
 describe('isPhoneNumber', () => {
   describe('with region', () => {
     const validValues = [
diff --git a/test/functional/validation-options.spec.ts b/test/functional/validation-options.spec.ts
index 9e57669..d363668 100644
--- a/test/functional/validation-options.spec.ts
+++ b/test/functional/validation-options.spec.ts
@@ -3,6 +3,7 @@ import {
   IsDefined,
   Matches,
   MinLength,
+  IsArray,
   Validate,
   ValidateNested,
   ValidatorConstraint,
@@ -1196,6 +1197,13 @@ describe('context', () => {
   });
 
   it('should stop at first error.', () => {
+    class MySubClass {
+      @IsDefined({
+        message: 'isDefined',
+      })
+      name: string;
+    }
+
     class MyClass {
       @IsDefined({
         message: 'isDefined',
@@ -1204,14 +1212,32 @@ describe('context', () => {
         message: 'String is not valid. You string must contain a hello word',
       })
       sameProperty: string;
+
+      @ValidateNested()
+      @IsArray()
+      nestedWithPrimitiveValue: MySubClass[];
     }
 
     const model = new MyClass();
-    return validator.validate(model, { stopAtFirstError: true }).then(errors => {
-      console.log();
-      expect(errors.length).toEqual(1);
+    model.nestedWithPrimitiveValue = 'invalid' as any;
+
+    const hasStopAtFirstError = validator.validate(model, { stopAtFirstError: true }).then(errors => {
+      expect(errors.length).toEqual(2);
       expect(Object.keys(errors[0].constraints).length).toBe(1);
       expect(errors[0].constraints['isDefined']).toBe('isDefined');
+      expect(Object.keys(errors[1].constraints).length).toBe(1);
+      expect(errors[1].constraints).toHaveProperty('isArray');
     });
+    const hasNotStopAtFirstError = validator.validate(model, { stopAtFirstError: false }).then(errors => {
+      expect(errors.length).toEqual(2);
+      expect(Object.keys(errors[0].constraints).length).toBe(2);
+      expect(errors[0].constraints).toHaveProperty('contains');
+      expect(errors[0].constraints).toHaveProperty('isDefined');
+      expect(Object.keys(errors[1].constraints).length).toBe(2);
+      expect(errors[1].constraints).toHaveProperty('isArray');
+      expect(errors[1].constraints).toHaveProperty('nestedValidation');
+    });
+
+    return Promise.all([hasStopAtFirstError, hasNotStopAtFirstError]);
   });
 });