Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ app.listen(3000)
| `@IsTrue()` | 값이 `true` 여야 함 | `@IsTrue() acceptedTerms!: boolean` |
| `@IsFalse()` | 값이 `false` 여야 함 | `@IsFalse() blocked!: boolean` |
| `@OneOf(options: readonly any[])` | 값이 `options` 중 하나여야 함 | `@OneOf(['credit','debit'] as const) method!: 'credit' \| 'debit'` |
| `@ArrayContains(values: any[])` | 배열이 지정된 모든 값을 포함해야 함 | `@ArrayContains([1, 2]) nums!: number[]` |
| `@Enum(enumObj: object, message?)` | 값이 `enumObj`의 멤버여야 함 | `@Enum(UserRole) role!: UserRole` |
| `@Validate(validateFn, message?)` | 커스텀 검증 함수를 사용 | `@Validate(v => typeof v === 'string' && v.includes('@'), 'invalid email') email!: string` |
| `@Regexp(pattern: RegExp, message?)` | 문자열이 주어진 정규식을 만족해야 함 | `@Regexp(/^[0-9]+$/, 'digits only') phone!: string` |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ Full guide and API reference:
| `@IsTrue()` | Value must be `true`. | `@IsTrue() acceptedTerms!: boolean` |
| `@IsFalse()` | Value must be `false`. | `@IsFalse() blocked!: boolean` |
| `@OneOf(options: readonly any[])` | Value must be one of `options`. | `@OneOf(['credit','debit'] as const) method!: 'credit' \| 'debit'` |
| `@ArrayContains(values: any[])` | Array must contain all specified values. | `@ArrayContains([1, 2]) nums!: number[]` |
| `@Enum(enumObj: object, message?)` | Value must be a member of `enumObj`. | `@Enum(UserRole) role!: UserRole` |
| `@Validate(validateFn, message?)` | Custom validation function. | `@Validate(v => typeof v === 'string' && v.includes('@'), 'invalid email') email!: string` |
| `@Regexp(pattern: RegExp, message?)` | String must match the given regular expression. | `@Regexp(/^[0-9]+$/, 'digits only') phone!: string` |
Expand Down
9 changes: 9 additions & 0 deletions apps/docs/docs/decorators/validators.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ Validates that the input value is one of the specified values.

- **`values`**: The array of allowed values.

### `@ArrayContains(values: any[], message?: string)`

Validates that the array contains all the specified values. Supports primitive values, objects, Date, and mixed types.

- **`values`**: The values that must be present in the array.
- **`message`** (optional): The error message to display when validation fails. If omitted, a default message will be used.

> **Warning**: Object comparison uses deep equality. Performance may degrade when `values` contains many objects or deeply nested structures.

### `@Enum(enumObj: object, message?: string)`

Validates that the input value matches one of the values in the specified enum object.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ Validiert, dass der Eingabewert einer der angegebenen Werte ist.

- **`values`**: Das Array der zulässigen Werte.

### `@ArrayContains(values: any[], message?: string)`

Validiert, dass das Array alle angegebenen Werte enthält. Unterstützt primitive Werte, Objekte, Date und gemischte Typen.

- **`values`**: Die Werte, die im Array vorhanden sein müssen.
- **`message`** (optional): Die Fehlermeldung, die angezeigt wird, wenn die Validierung fehlschlägt. Wenn weggelassen, wird eine Standardmeldung verwendet.

> **Warnung**: Der Objektvergleich verwendet Tiefengleichheit. Die Leistung kann sich verschlechtern, wenn `values` viele Objekte oder tief verschachtelte Strukturen enthält.

### `@Enum(enumObj: object, message?: string)`

Validiert, dass der Eingabewert mit einem der Werte im angegebenen Enum-Objekt übereinstimmt.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ Valide que la valeur d'entrée est l'une des valeurs spécifiées.

- **`values`** : Le tableau des valeurs autorisées.

### `@ArrayContains(values: any[], message?: string)`

Valide que le tableau contient toutes les valeurs spécifiées. Prend en charge les valeurs primitives, les objets, les Date et les types mixtes.

- **`values`**: Les valeurs qui doivent être présentes dans le tableau.
- **`message`** (optionnel) : Le message d'erreur à afficher lorsque la validation échoue. S'il est omis, un message par défaut sera utilisé.

> **Avertissement** : La comparaison d'objets utilise l'égalité profonde. Les performances peuvent se dégrader lorsque `values` contient de nombreux objets ou des structures profondément imbriquées.

### `@Enum(enumObj: object, message?: string)`

Valide que la valeur d'entrée correspond à l'une des valeurs de l'objet enum spécifié.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@ title: 유효성 검사 데코레이터

---

### `@ArrayContains(values: any[], message?: string)`

배열이 지정된 모든 값을 포함하고 있는지 검증합니다. 원시값, 객체, Date 등 복합 타입도 지원합니다.

- **`values`**: 배열에 반드시 포함되어야 하는 값들입니다.
- **`message`** (선택 사항): 검증 실패 시 표시할 메시지. 생략하면 기본 메시지가 사용됩니다.

> **주의**: 객체 비교는 깊은 비교(deep equality)를 사용합니다. `values`에 객체가 많거나 중첩이 깊을수록 성능 저하가 발생할 수 있습니다.

### `@Enum(enumObj: object, message?: string)`

입력 값이 지정된 열거형 객체의 값 중 하나와 일치하는지 검증합니다. 또한 입력 값을 해당하는 열거형 값으로 자동으로 변환합니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ Express-Cargo использует декораторы для валидаци

- **`values`**: Массив допустимых значений.

### `@ArrayContains(values: any[], message?: string)`

Проверяет, что массив содержит все указанные значения. Поддерживает примитивные значения, объекты, Date и смешанные типы.

- **`values`**: Значения, которые должны присутствовать в массиве.
- **`message`** (необязательно): Сообщение об ошибке, которое будет отображаться при сбое валидации. Если опущено, будет использоваться сообщение по умолчанию.

> **Предупреждение**: Сравнение объектов использует глубокое равенство. Производительность может снизиться, если `values` содержит много объектов или глубоко вложенные структуры.

### `@Enum(enumObj: object, message?: string)`

Проверяет, что входное значение соответствует одному из значений в указанном объекте перечисления.
Expand Down
52 changes: 52 additions & 0 deletions apps/example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,58 @@ curl -X POST 'http://localhost:3000/enum' \

---

### @ArrayContains

```typescript
class ArrayContainsNested {
@Body()
name!: string
}

class ArrayContainsExample {
@Body()
@List('number')
@ArrayContains([1, 2])
numbers!: number[]

@Body()
@List(ArrayContainsNested)
@ArrayContains([{ name: 'test1' }])
objects!: ArrayContainsNested[]

@Body()
@List(Date)
@ArrayContains([new Date('2024-01-01')])
dates!: Date[]

@Body()
@Type(data => {
if (typeof data !== 'object' || data === null) return Number
else return ArrayContainsNested
})
@ArrayContains([1, { name: 'test1' }])
mixed!: (number | ArrayContainsNested)[]
}

router.post('/array-contains', bindingCargo(ArrayContainsExample), (req, res) => {
const cargo = getCargo<ArrayContainsExample>(req)
res.json(cargo)
})
```

```shell
curl -X POST 'http://localhost:3000/array-contains' \
-H 'Content-Type: application/json' \
-d '{
"numbers": [1, 2, 3],
"objects": [{ "name": "test1" }, { "name": "test2" }],
"dates": ["2024-01-01T00:00:00.000Z"],
"mixed": [1, { "name": "test1" }]
}'
```

---

### @Validate

```typescript
Expand Down
36 changes: 36 additions & 0 deletions apps/example/src/routers/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
With,
Without,
Enum,
ArrayContains, Type, List,
} from 'express-cargo'

const router: Router = express.Router()
Expand Down Expand Up @@ -315,4 +316,39 @@ router.post('/enum', bindingCargo(EnumExample), (req, res) => {
res.json(cargo)
})

class ArrayContainsNested {
@Body()
name!: string
}

class ArrayContainsExample {
@Body()
@List('number')
@ArrayContains([1, 2])
numbers!: number[]

@Body()
@List(ArrayContainsNested)
@ArrayContains([{ name: 'test1' }])
objects!: ArrayContainsNested[]

@Body()
@List(Date)
@ArrayContains([new Date('2024-01-01')])
dates!: Date[]

@Body()
@Type(data => {
if (typeof data !== 'object' || data === null) return Number
else return ArrayContainsNested
})
@ArrayContains([1, { name: 'test1' }])
mixed!: (number | ArrayContainsNested)[]
}

router.post('/array-contains', bindingCargo(ArrayContainsExample), (req, res) => {
const cargo = getCargo<ArrayContainsExample>(req)
res.json(cargo)
})

export default router
35 changes: 35 additions & 0 deletions packages/express-cargo/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export function isDeepEqual(obj1: any, obj2: any): boolean {
if (obj1 === obj2) return true

const stack: [any, any][] = [[obj1, obj2]]
while (stack.length > 0) {
const [a, b] = stack.pop()!

if (a === b) continue
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
return false
}

// Handle Date objects specifically
if (a instanceof Date && b instanceof Date) {
if (a.getTime() !== b.getTime()) return false
continue
}

// Check if both are arrays or neither (for accuracy and performance)
const isArrayA = Array.isArray(a)
const isArrayB = Array.isArray(b)
if (isArrayA !== isArrayB) return false

const keysA = Object.keys(a)
const keysB = Object.keys(b)
if (keysA.length !== keysB.length) return false

for (const key of keysA) {
if (!Object.prototype.hasOwnProperty.call(b, key)) return false
stack.push([a[key], b[key]])
}
}

return true
}
Comment on lines +1 to +35
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

isDeepEqual 함수는 순환 참조(circular reference)가 있는 객체를 처리할 때 무한 루프에 빠질 수 있는 취약점이 있습니다. 이는 서비스 거부(DoS) 공격으로 이어질 수 있습니다. 예를 들어, const a = {}; a.a = a;와 같은 객체가 입력으로 들어오면 스택에 동일한 비교 쌍이 계속 추가되어 무한 루프가 발생합니다.

이 문제를 해결하기 위해 이미 비교한 객체 쌍을 추적하는 메커니즘을 추가해야 합니다. MapSet을 사용하여 방문한 객체 쌍을 기록하는 방식으로 수정하는 것을 제안합니다.

export function isDeepEqual(obj1: any, obj2: any): boolean {
    if (obj1 === obj2) return true

    // 순환 참조를 처리하기 위해 방문한 객체 쌍을 추적합니다.
    const visited = new Map<object, Set<object>>()
    const stack: [any, any][] = [[obj1, obj2]]

    while (stack.length > 0) {
        const [a, b] = stack.pop()!

        if (a === b) continue
        if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
            return false
        }

        // 이미 비교한 쌍이면 건너뜁니다.
        if (visited.has(a) && visited.get(a)!.has(b)) {
            continue
        }
        if (!visited.has(a)) {
            visited.set(a, new Set())
        }
        visited.get(a)!.add(b)

        // Date 객체를 특별히 처리합니다.
        if (a instanceof Date && b instanceof Date) {
            if (a.getTime() !== b.getTime()) return false
            continue
        }

        // 두 값 모두 배열이거나 아니어야 합니다.
        const isArrayA = Array.isArray(a)
        const isArrayB = Array.isArray(b)
        if (isArrayA !== isArrayB) return false

        const keysA = Object.keys(a)
        const keysB = Object.keys(b)
        if (keysA.length !== keysB.length) return false

        for (const key of keysA) {
            if (!Object.prototype.hasOwnProperty.call(b, key)) return false
            stack.push([a[key], b[key]])
        }
    }

    return true
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTTP 요청의 JSON 데이터는 직렬화된 문자열 형태로 전송되며, 표준 JSON 규격상 순환 참조를 포함할 수 없습니다. 또한, 우리 라이브러리 이전 단계인 express.json()에서 JSON.parse()를 통해 객체화되는데, 이 과정에서 순환 구조가 있다면 이미 파싱 에러가 발생합니다.

그러므로 발생할 수 없는 케이스를 위해 모든 객체 노드를 순회하며 순환 참조를 체크하는 로직을 추가하는 것은 라이브러리의 성능에 불필요한 비용을 발생시킨다고 생각하여 해당 리뷰는 반영하지 않도록 하겠습니다.

51 changes: 51 additions & 0 deletions packages/express-cargo/src/validator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { cargoErrorMessage, EachValidatorRule, TypedPropertyDecorator, UuidVersion, ValidatorRule } from './types'
import { CargoClassMetadata } from './metadata'
import { isDeepEqual } from './utils'

function addValidator(target: any, propertyKey: string | symbol, rule: ValidatorRule) {
const classMeta = new CargoClassMetadata(target)
Expand Down Expand Up @@ -441,6 +442,56 @@ export function Without(fieldName: string, message?: cargoErrorMessage): Propert
}
}

/**
* Checks if the array contains all the specified values.
* @param values - The values that must be present in the array.
* @param message - Optional custom error message.
*/
export function ArrayContains(values: any[], message?: cargoErrorMessage): TypedPropertyDecorator<any[]> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

equal 여부 판단을 위한 함수도 optional 로 개발자가 입력할수 있게 하면 어떨가요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기본적으로 저희가 제공하는 함수를 사용하되, 추가적인 옵션을 받자는 말씀이신가요?

const expectedPrimitives = values.filter(v => v === null || typeof v !== 'object')
const expectedObjects = values.filter(v => v !== null && typeof v === 'object')

return (target: Object, propertyKey: string | symbol): void => {
addValidator(
target,
propertyKey,
new ValidatorRule(
propertyKey,
'arrayContains',
(value: unknown) => {
if (!Array.isArray(value)) {
return false
}
const actualPrimitiveSet = new Set()
const actualObjects: any[] = []

for (const item of value) {
if (item === null || typeof item !== 'object') {
actualPrimitiveSet.add(item)
} else {
actualObjects.push(item)
}
}

// Verify all expected primitive values exist in the actual array
for (const req of expectedPrimitives) {
if (!actualPrimitiveSet.has(req)) return false
}

// Verify all expected objects exist in the actual array using deep equality
for (const reqObj of expectedObjects) {
const found = actualObjects.some(actObj => isDeepEqual(actObj, reqObj))
if (!found) return false
}

return true
},
message || `${String(propertyKey)} must contain all specified values`,
),
)
}
}

/**
* Applies validation rules to each element of an array.
* @param args - Validation decorators or functions to apply to each element.
Expand Down
Loading
Loading