Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/example/src/errors/cargoErrorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Request, Response, NextFunction } from 'express'
setCargoErrorHandler((err: CargoValidationError, req: Request, res: Response, next: NextFunction) => {
res.status(422).json({
code: 'VALIDATION_ERROR',
message: '입력값이 올바르지 않습니다.',
message: 'The provided input is invalid.',
details: err.errors.map(e => ({
name: e.name,
message: e.message,
Expand Down
14 changes: 7 additions & 7 deletions packages/express-cargo/src/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ export function Enum<T>(enumObj: any, message?: cargoErrorMessage): TypedPropert
const enumValues = enumKeys.map(k => enumObj[k as keyof typeof enumObj])
const validInputs = [...enumKeys, ...enumValues]

// 1. enum 타입 정보 저장
// 1. Store the enum type information
fieldMeta.setEnumType(enumObj)
fieldMeta.pushAppliedDecorator({ name: Enum.name, category: 'type-helper', args: [enumObj, message] })

// 2. enum validator 추가
// 2. Add the enum validator
fieldMeta.addValidator(
new ValidatorRule(
propertyKey,
Expand All @@ -45,21 +45,21 @@ export function Enum<T>(enumObj: any, message?: cargoErrorMessage): TypedPropert
),
)

// 3. enum transformer 추가
// 3. Add the enum transformer
const transformer = (value: any): any => {
if (value === null || value === undefined) return value

const enumKeys = Object.keys(enumObj).filter(k => isNaN(Number(k)))

// 1. 입력값이 enum 키('ADMIN') 인 경우
// 1. When the input is an enum key (e.g. 'ADMIN')
if (typeof value === 'string' && enumKeys.includes(value)) {
return enumObj[value as keyof typeof enumObj]
}

// 비교를 위해 숫자형 문자열을 숫자로 변환
// Convert a numeric string to a number for comparison
const comparableValue = typeof value === 'string' && !isNaN(Number(value)) ? Number(value) : value

// 2. 입력값이 enum 값(예: 0 또는 'admin')인 경우
// 2. When the input is an enum value (e.g. 0 or 'admin')
for (const key of enumKeys) {
if (enumObj[key as keyof typeof enumObj] === comparableValue) {
return comparableValue
Expand All @@ -70,7 +70,7 @@ export function Enum<T>(enumObj: any, message?: cargoErrorMessage): TypedPropert
}
fieldMeta.setTransformer(transformer)

// 메타데이터 저장
// Store the metadata
classMeta.setFieldMetadata(propertyKey, fieldMeta)
}
}
16 changes: 8 additions & 8 deletions packages/express-cargo/tests/binding/requestBinding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class RequiredRequestDTO {
}

describe('request binding', () => {
it('request transformer로 값 바인딩', () => {
it('binds values via request transformer', () => {
const middleware = bindingCargo(RequestDTO)

const req = makeMockReq({
Expand All @@ -53,7 +53,7 @@ describe('request binding', () => {
expect(dto.optionalField).toBeNull()
})

it('validator 실패 시 CargoValidationError 발생', () => {
it('throws CargoValidationError when a validator fails', () => {
const middleware = bindingCargo(RequestDTO)

const req = makeMockReq({
Expand All @@ -69,7 +69,7 @@ describe('request binding', () => {
expect(err.errors).toEqual(expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining('score') })]))
})

it('optional 필드가 없으면 null 처리', () => {
it('sets an optional field to null when it is missing', () => {
const middleware = bindingCargo(RequestDTO)

const req = makeMockReq({
Expand All @@ -84,7 +84,7 @@ describe('request binding', () => {
expect(dto.optionalField).toBeNull()
})

it('optional request field가 없으면 validation을 건너뛴다', () => {
it('skips validation when an optional request field is missing', () => {
const middleware = bindingCargo(RequestDTO)

const req = makeMockReq({
Expand All @@ -100,21 +100,21 @@ describe('request binding', () => {
expect(dto.optionalScore).toBeNull()
})

it('Source 데코레이터와 @Request 를 함께 쓰면 B2 위반으로 거부된다', () => {
it('rejects combining a source decorator with @Request', () => {
expect(() => bindingCargo(MixedBindingDTO)).toThrow(CargoSchemaError)
})

it('bindingCargo 미들웨어 없이 getCargo를 호출하면 에러를 던진다', () => {
it('throws when getCargo is called without the bindingCargo middleware', () => {
const req = makeMockReq()
expect(() => getCargo<RequestDTO>(req)).toThrow(/bindingCargo/)
})

it('_cargo가 null이어도 getCargo는 에러를 던진다', () => {
it('throws from getCargo even when _cargo is null', () => {
const req = makeMockReq({ _cargo: null } as any)
expect(() => getCargo<RequestDTO>(req)).toThrow(/bindingCargo/)
})

it('required request field가 없으면 validator를 건너뛴다', () => {
it('skips validators when a required request field is missing', () => {
const middleware = bindingCargo(RequiredRequestDTO)

const req = makeMockReq({
Expand Down
16 changes: 8 additions & 8 deletions packages/express-cargo/tests/binding/sourceBinding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class TestDTO {
}

describe('source decorator binding', () => {
it('요청 데이터가 정확히 바인딩되고 타입 캐스팅됨', () => {
it('binds request data accurately and casts types', () => {
const middleware = bindingCargo(TestDTO)

const req = makeMockReq({
Expand All @@ -35,11 +35,11 @@ describe('source decorator binding', () => {
expect(dto.id).toBe(10)
expect(dto.isAdmin).toBe(true)
expect(dto.loginAt).toEqual(new Date('2025-09-20T17:00:00.000Z'))
expect(dto.nickname).toBeNull() // optional 처리
expect(dto.nickname).toBeNull() // optional handling
expect(dto.score).toBe(5)
})

it('validator 실패 시 CargoValidationError 발생', () => {
it('throws CargoValidationError when a validator fails', () => {
const middleware = bindingCargo(TestDTO)

const req = makeMockReq({
Expand All @@ -59,7 +59,7 @@ describe('source decorator binding', () => {
expect(err.errors).toEqual(expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining('score') })]))
})

it('optional 필드가 없으면 null 처리', () => {
it('sets an optional field to null when it is missing', () => {
const middleware = bindingCargo(TestDTO)

const req = makeMockReq({
Expand All @@ -78,10 +78,10 @@ describe('source decorator binding', () => {
expect(dto.nickname).toBeNull()
})

it('targetClass가 Object인 경우(any 타입 등) 원본 객체 데이터를 보존함', () => {
it('preserves the raw object data when targetClass is Object (e.g. any type)', () => {
class AnyDataDTO {
@Body('data')
data!: any // Object로 리플렉션됨
data!: any // reflected as Object
}

const middleware = bindingCargo(AnyDataDTO)
Expand All @@ -94,11 +94,11 @@ describe('source decorator binding', () => {

expect(next).toHaveBeenCalledWith()
const dto = getCargo<AnyDataDTO>(req)!
// 빈 객체 {}가 아닌 원본 데이터와 일치해야 함
// should match the raw data, not an empty object {}
expect(dto.data).toEqual(rawData)
})

it('required source field가 없으면 validator를 건너뛴다', () => {
it('skips validators when a required source field is missing', () => {
const middleware = bindingCargo(TestDTO)

const req = makeMockReq({
Expand Down
8 changes: 4 additions & 4 deletions packages/express-cargo/tests/binding/virtualBinding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class RequiredVirtualDTO {
}

describe('virtual binding', () => {
it('virtual transformer가 정상 동작', () => {
it('runs the virtual transformer correctly', () => {
const middleware = bindingCargo(VirtualDTO)

const req = makeMockReq({
Expand All @@ -41,7 +41,7 @@ describe('virtual binding', () => {
expect(dto.nameLength).toBe(11)
})

it('validator 실패 시 CargoValidationError 발생', () => {
it('throws CargoValidationError when a validator fails', () => {
const middleware = bindingCargo(VirtualDTO)

const req = makeMockReq({
Expand All @@ -57,7 +57,7 @@ describe('virtual binding', () => {
expect(err.errors).toEqual(expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining('nameLength') })]))
})

it('optional virtual field가 빈 문자열을 반환해도 optional 검증을 건너뛴다', () => {
it('skips optional validation even when an optional virtual field returns an empty string', () => {
const middleware = bindingCargo(VirtualDTO)

const req = makeMockReq({
Expand All @@ -73,7 +73,7 @@ describe('virtual binding', () => {
expect(dto.optionalNameLength).toBeNull()
})

it('required virtual field가 없으면 validator를 건너뛴다', () => {
it('skips validators when a required virtual field is missing', () => {
const middleware = bindingCargo(RequiredVirtualDTO)

const req = makeMockReq({
Expand Down
18 changes: 9 additions & 9 deletions packages/express-cargo/tests/rules/dynamicValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { bindingCargo, Body, Query, Type, CargoSchemaError } from '../../src'
import { makeMockReq, makeMockRes, makeNext } from '../binding/testUtils'

describe('schema validation — dynamic runtime validation', () => {
it('런타임에 결정된 클래스가 규칙을 위반하면 CargoSchemaError 발생', () => {
it('throws CargoSchemaError when a class resolved at runtime violates the rules', () => {
class InvalidDynamicDto {
@Body()
@Query() // 중복 소스 규칙 위반
@Query() // violates the duplicate-source rule
foo!: string
}

Expand All @@ -17,7 +17,7 @@ describe('schema validation — dynamic runtime validation', () => {

const middleware = bindingCargo(RootDto)

// InvalidDynamicDto를 사용하도록 요청
// request that resolves to InvalidDynamicDto
const req = makeMockReq({
body: {
data: { kind: 'invalid', foo: 'bar' },
Expand All @@ -28,16 +28,16 @@ describe('schema validation — dynamic runtime validation', () => {

middleware(req, res, next)

// validateAnalysis가 에러를 던지고, middleware의 try-catch에서 잡혀 next(err)로 전달됨
// validateAnalysis throws, the middleware's try-catch catches it and forwards via next(err)
const err = next.mock.calls[0][0]
expect(err).toBeInstanceOf(CargoSchemaError)
expect(err.message).toContain('InvalidDynamicDto')
expect(err.message).toContain('foo')
})

it('한 번 검증된 동적 클래스는 다음 요청에서 다시 검증하지 않음 (캐시 확인)', () => {
// 이 테스트는 기능적으로는 동일하지만, validateAnalysis 내부의 VALIDATED 캐시가
// 오작동하지 않고 정상적으로 다음 바인딩을 허용하는지 확인하는 의미가 있음
it('does not re-validate a dynamic class that was already validated (cache check)', () => {
// functionally similar to the previous case, but verifies that the VALIDATED cache inside
// validateAnalysis does not misbehave and still allows the next binding to succeed
class ValidDynamicDto {
@Body()
foo!: string
Expand All @@ -52,13 +52,13 @@ describe('schema validation — dynamic runtime validation', () => {
const middleware = bindingCargo(RootDto)
const res = makeMockRes()

// 첫 번째 요청: 검증 및 바인딩 성공
// first request: validation and binding succeed
const req1 = makeMockReq({ body: { data: { foo: 'bar' } } })
const next1 = makeNext()
middleware(req1, res, next1)
expect(next1).toHaveBeenCalledWith()

// 두 번째 요청: 캐시된 검증 결과 사용 및 바인딩 성공
// second request: uses the cached validation result and binding succeeds
const req2 = makeMockReq({ body: { data: { foo: 'baz' } } })
const next2 = makeNext()
middleware(req2, res, next2)
Expand Down
20 changes: 10 additions & 10 deletions packages/express-cargo/tests/rules/kindCategory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Body, Each, Header, List, Min, Optional, Params, Query, Request, Sessio
import { expectViolation, validateCargoSchema } from './testUtils'

describe('schema validation — kind category rules', () => {
it('두 Source 데코레이터 공존 시 거부', () => {
it('rejects two coexisting source decorators', () => {
class MultipleSourcesDto {
@Body()
@Query()
Expand All @@ -12,7 +12,7 @@ describe('schema validation — kind category rules', () => {
expectViolation(() => validateCargoSchema(MultipleSourcesDto), 'foo', 'pick a single source')
})

it('Source + @Request 공존 시 거부', () => {
it('rejects a source decorator coexisting with @Request', () => {
class SourceWithRequestDto {
@Body()
@Request(req => req.headers['x-foo'])
Expand All @@ -22,7 +22,7 @@ describe('schema validation — kind category rules', () => {
expectViolation(() => validateCargoSchema(SourceWithRequestDto), 'foo', 'cannot be combined with @Request')
})

it('Source + @Virtual 공존 시 거부', () => {
it('rejects a source decorator coexisting with @Virtual', () => {
class SourceWithVirtualDto {
@Body()
@Virtual(obj => obj.bar)
Expand All @@ -32,7 +32,7 @@ describe('schema validation — kind category rules', () => {
expectViolation(() => validateCargoSchema(SourceWithVirtualDto), 'foo', 'cannot be combined with @Virtual')
})

it('@Request + @Virtual 공존 시 거부', () => {
it('rejects @Request coexisting with @Virtual', () => {
class RequestWithVirtualDto {
@Request(req => req.ip)
@Virtual(obj => obj.bar)
Expand All @@ -42,7 +42,7 @@ describe('schema validation — kind category rules', () => {
expectViolation(() => validateCargoSchema(RequestWithVirtualDto), 'foo', '@Request cannot be combined with @Virtual')
})

it('top-level: kind 데코레이터 없는 필드 거부', () => {
it('top-level: rejects a field without a kind decorator', () => {
class MissingKindDto {
@Optional()
foo!: string
Expand All @@ -51,7 +51,7 @@ describe('schema validation — kind category rules', () => {
expectViolation(() => validateCargoSchema(MissingKindDto), 'foo', 'field must be decorated')
})

it('nested: @Type 으로 참조된 클래스의 필드도 검증', () => {
it('nested: also validates fields of a class referenced via @Type', () => {
class NestedDto {
@Optional()
foo!: string
Expand All @@ -65,7 +65,7 @@ describe('schema validation — kind category rules', () => {
expectViolation(() => validateCargoSchema(OuterDto), 'foo', 'field must be decorated')
})

it('nested via @List: 배열 요소 클래스의 필드도 검증', () => {
it('nested via @List: also validates fields of the array element class', () => {
class ItemDto {
@Optional()
foo!: string
Expand All @@ -79,7 +79,7 @@ describe('schema validation — kind category rules', () => {
expectViolation(() => validateCargoSchema(ListContainerDto), 'foo', 'field must be decorated')
})

it('정상 케이스: 단일 Source 데코레이터는 통과', () => {
it('happy path: a single source decorator passes', () => {
class HappyDto {
@Body()
foo!: string
Expand All @@ -91,7 +91,7 @@ describe('schema validation — kind category rules', () => {
expect(() => validateCargoSchema(HappyDto)).not.toThrow()
})

it('정상 케이스: 모든 종류의 kind 카테고리가 섞여 있어도 충돌만 없으면 통과', () => {
it('happy path: a mix of every kind category passes as long as there is no conflict', () => {
class MixedHappyDto {
@Body()
a!: string
Expand All @@ -118,7 +118,7 @@ describe('schema validation — kind category rules', () => {
expect(() => validateCargoSchema(MixedHappyDto)).not.toThrow()
})

it('정상 케이스: @Each 가 validator 만 감싸는 경우 통과', () => {
it('happy path: passes when @Each wraps only validators', () => {
class EachHappyDto {
@Body()
@Each(Min(0))
Expand Down
8 changes: 4 additions & 4 deletions packages/express-cargo/tests/rules/primitiveSkip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Body, List, Type } from '../../src'
import { validateCargoSchema } from './testUtils'

describe('schema validation — primitive constructor skip', () => {
it('@List(String)같은 프리미티브 element는 nested 검증 대상에서 제외', () => {
it('excludes primitive elements like @List(String) from nested validation', () => {
class PrimitiveListDto {
@Body()
@List(String)
Expand All @@ -12,7 +12,7 @@ describe('schema validation — primitive constructor skip', () => {
expect(() => validateCargoSchema(PrimitiveListDto)).not.toThrow()
})

it('@List(Array) 처럼 Array가 elementType으로 들어가도 traversal이 그래도 종료된다', () => {
it('terminates traversal even when Array is the elementType, as in @List(Array)', () => {
class ArrayElementDto {
@Body()
@List(Array)
Expand All @@ -22,7 +22,7 @@ describe('schema validation — primitive constructor skip', () => {
expect(() => validateCargoSchema(ArrayElementDto)).not.toThrow()
})

it('@Type(Object)처럼 Object가 typeFn으로 들어가도 traversal이 그래도 종료된다', () => {
it('terminates traversal even when Object is the typeFn, as in @Type(Object)', () => {
class ObjectTypeDto {
@Body()
@Type(Object)
Expand All @@ -32,7 +32,7 @@ describe('schema validation — primitive constructor skip', () => {
expect(() => validateCargoSchema(ObjectTypeDto)).not.toThrow()
})

it('@List(Date)같은 Date도 nested 검증 대상에서 제외', () => {
it('excludes Date elements like @List(Date) from nested validation', () => {
class DateListDto {
@Body()
@List(Date)
Expand Down
Loading