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
8 changes: 4 additions & 4 deletions packages/express-cargo/src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function Optional(): PropertyDecorator {
const classMeta = new CargoClassMetadata(target)
const fieldMeta = classMeta.getFieldMetadata(propertyKey)
fieldMeta.setOptional(true)
fieldMeta.pushAppliedDecorator({ name: Optional.name, category: 'missing-handler' })
fieldMeta.pushAppliedDecorator({ name: Optional.name, category: 'missing-handler', args: [] })
classMeta.setFieldMetadata(propertyKey, fieldMeta)
}
}
Expand All @@ -32,7 +32,7 @@ export function List(elementType: ArrayElementType): TypedPropertyDecorator<Arra
const fieldMeta = classMeta.getFieldMetadata(propertyKey)
const actualType = typeof elementType === 'string' ? TYPE_MAP[elementType] : elementType
fieldMeta.setArrayElementType(actualType)
fieldMeta.pushAppliedDecorator({ name: List.name, category: 'type-helper' })
fieldMeta.pushAppliedDecorator({ name: List.name, category: 'type-helper', args: [elementType] })
classMeta.setFieldMetadata(propertyKey, fieldMeta)
}
}
Expand All @@ -46,7 +46,7 @@ export function Default(value: any): PropertyDecorator {
const classMeta = new CargoClassMetadata(target)
const fieldMeta = classMeta.getFieldMetadata(propertyKey)
fieldMeta.setDefault(value)
fieldMeta.pushAppliedDecorator({ name: Default.name, category: 'missing-handler' })
fieldMeta.pushAppliedDecorator({ name: Default.name, category: 'missing-handler', args: [value] })
classMeta.setFieldMetadata(propertyKey, fieldMeta)
}
}
Expand Down Expand Up @@ -91,7 +91,7 @@ export function Type(typeFn: TypeThunk | TypeResolver, options?: TypeOptions): P
const fieldMeta = classMeta.getFieldMetadata(propertyKey)

fieldMeta.setTypeInfo(typeFn, options)
fieldMeta.pushAppliedDecorator({ name: Type.name, category: 'type-helper' })
fieldMeta.pushAppliedDecorator({ name: Type.name, category: 'type-helper', args: [typeFn, options] })

const designType = Reflect.getMetadata('design:type', target, propertyKey)
const isArrayType = designType === Array || (typeof designType === 'function' && designType.name === 'Array')
Expand Down
2 changes: 1 addition & 1 deletion packages/express-cargo/src/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function Enum<T>(enumObj: any, message?: cargoErrorMessage): TypedPropert

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

// 2. enum validator 추가
fieldMeta.addValidator(
Expand Down
8 changes: 8 additions & 0 deletions packages/express-cargo/src/rules/basic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { FieldRuleFn } from './types'

const symbolWithoutDescription: FieldRuleFn = s =>
typeof s.propertyKey === 'symbol' && !s.propertyKey.description ? `symbol property must have a description` : null

const emptySourceKey: FieldRuleFn = s => (s.hasSource && s.sourceKey === '' ? `@${s.sources[0].name} key must not be an empty string` : null)

export const BASIC_RULES: readonly FieldRuleFn[] = [symbolWithoutDescription, emptySourceKey]
25 changes: 25 additions & 0 deletions packages/express-cargo/src/rules/crossField.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { With, Without } from '../validator'
import { FieldRuleFn, FieldState } from './types'

function unknownReference(state: FieldState, decoratorName: string): string | null {
for (const applied of state.appliedSelf) {
if (applied.name !== decoratorName) continue
const target = applied.args[0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

런타임 안정성을 높이고 방어적 프로그래밍(Defensive Programming)을 실천하기 위해, applied.args가 존재하지 않거나 비어있을 가능성에 대비하여 옵셔널 체이닝(?.)을 사용하는 것이 안전합니다.

applied.args[0] 대신 applied.args?.[0]을 사용하여 혹시 모를 런타임 에러를 방지하는 것을 제안합니다.

Suggested change
const target = applied.args[0]
const target = applied.args?.[0]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

applied.args는 null혹은 undefined인 경우가 없기 때문에 해당 리뷰를 무시합니다.

if (typeof target === 'string' && !state.siblingFields.has(target)) {
return target
}
}
return null
}

const withReferencesUnknownField: FieldRuleFn = s => {
const target = unknownReference(s, With.name)
return target === null ? null : `@With references unknown field "${target}"`
}

const withoutReferencesUnknownField: FieldRuleFn = s => {
const target = unknownReference(s, Without.name)
return target === null ? null : `@Without references unknown field "${target}"`
}

export const CROSS_FIELD_RULES: readonly FieldRuleFn[] = [withReferencesUnknownField, withoutReferencesUnknownField]
8 changes: 6 additions & 2 deletions packages/express-cargo/src/rules/eachUsage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Each } from '../validator'
import { isKnownNonArray } from './utils'
import { FieldRuleFn } from './types'

const eachWrapsSource: FieldRuleFn = s => {
Expand All @@ -10,5 +12,7 @@ const eachWrapsMissingHandler: FieldRuleFn = s => {
return offenders.length === 0 ? null : `@Each cannot wrap missing-handler decorator(s): ${offenders.map(t => `@${t.name}`).join(', ')}`
}

/** Misuse of `@Each(...)` arguments. */
export const EACH_USAGE_RULES: readonly FieldRuleFn[] = [eachWrapsSource, eachWrapsMissingHandler]
const eachOnNonArray: FieldRuleFn = s =>
s.appliedSelf.some(d => d.name === Each.name) && isKnownNonArray(s.fieldType) ? `@Each can only be applied to array fields` : null

export const EACH_USAGE_RULES: readonly FieldRuleFn[] = [eachWrapsSource, eachWrapsMissingHandler, eachOnNonArray]
11 changes: 10 additions & 1 deletion packages/express-cargo/src/rules/registry.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { makeFieldRuleChecker } from './ruleChecker'
import type { FieldRuleFn, RuleChecker } from './types'
import { BASIC_RULES } from './basic'
import { KIND_CATEGORY_RULES } from './kindCategory'
import { EACH_USAGE_RULES } from './eachUsage'
import { TYPE_HELPER_PLACEMENT_RULES } from './typeHelperPlacement'
import { CROSS_FIELD_RULES } from './crossField'

/** All field-level rule implementations currently active. */
const FIELD_RULES: readonly FieldRuleFn[] = [...KIND_CATEGORY_RULES, ...EACH_USAGE_RULES]
const FIELD_RULES: readonly FieldRuleFn[] = [
...BASIC_RULES,
...KIND_CATEGORY_RULES,
...EACH_USAGE_RULES,
...TYPE_HELPER_PLACEMENT_RULES,
...CROSS_FIELD_RULES,
]

/** Single RuleChecker that runs every active field rule. */
export const ACTIVE_CHECKERS: readonly RuleChecker[] = [makeFieldRuleChecker(FIELD_RULES)]
11 changes: 7 additions & 4 deletions packages/express-cargo/src/rules/ruleChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import type { FieldRuleFn, FieldState, RuleChecker, RuleViolation } from './type
* Inspects one class (`ctx.cargoClass`) and returns every violation it finds.
* Nested-DTO traversal is handled by `validateCargoSchema`, so checkers don't recurse.
*/

function buildFieldState(propertyKey: string | symbol, fieldMeta: CargoFieldMetadata): FieldState {
function buildFieldState(propertyKey: string | symbol, fieldMeta: CargoFieldMetadata, siblingFields: ReadonlySet<string | symbol>): FieldState {
const appliedSelf = fieldMeta.getAppliedDecorators('self')
const appliedEach = fieldMeta.getAppliedDecorators('each')
const sources = appliedSelf.filter(d => d.category === 'source')
Expand All @@ -21,6 +20,8 @@ function buildFieldState(propertyKey: string | symbol, fieldMeta: CargoFieldMeta
hasSource: sources.length > 0,
hasRequest: appliedSelf.some(d => d.category === 'request'),
hasVirtual: appliedSelf.some(d => d.category === 'virtual'),
sourceKey: fieldMeta.getKey(),
siblingFields,
}
}

Expand All @@ -33,9 +34,11 @@ function buildFieldState(propertyKey: string | symbol, fieldMeta: CargoFieldMeta
export function makeFieldRuleChecker(rules: readonly FieldRuleFn[]): RuleChecker {
return ctx => {
const violations: RuleViolation[] = []
for (const propertyKey of ctx.classMeta.getAllFieldsList()) {
const fields = ctx.classMeta.getAllFieldsList()
const siblingFields = new Set(fields)
for (const propertyKey of fields) {
const fieldMeta = ctx.classMeta.getFieldMetadata(propertyKey)
const state = buildFieldState(propertyKey, fieldMeta)
const state = buildFieldState(propertyKey, fieldMeta, siblingFields)
for (const rule of rules) {
const message = rule(state)
if (message !== null) {
Expand Down
11 changes: 11 additions & 0 deletions packages/express-cargo/src/rules/typeHelperPlacement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { List, Type } from '../decorators'
import { isKnownNonArray, isPrimitiveType } from './utils'
import { FieldRuleFn } from './types'

const listOnNonArray: FieldRuleFn = s =>
s.appliedSelf.some(d => d.name === List.name) && isKnownNonArray(s.fieldType) ? `@List can only be applied to array fields` : null

const typeOnPrimitive: FieldRuleFn = s =>
s.appliedSelf.some(d => d.name === Type.name) && isPrimitiveType(s.fieldType) ? `@Type cannot be applied to a primitive field` : null

export const TYPE_HELPER_PLACEMENT_RULES: readonly FieldRuleFn[] = [listOnNonArray, typeOnPrimitive]
2 changes: 2 additions & 0 deletions packages/express-cargo/src/rules/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface FieldState {
hasSource: boolean
hasRequest: boolean
hasVirtual: boolean
sourceKey: string | symbol
siblingFields: ReadonlySet<string | symbol>
}

/** A single field-level rule. Returns a violation message, or `null` if the field passes. */
Expand Down
9 changes: 9 additions & 0 deletions packages/express-cargo/src/rules/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const PRIMITIVE_TYPES: readonly unknown[] = [String, Number, Boolean]

export function isPrimitiveType(fieldType: unknown): boolean {
return PRIMITIVE_TYPES.includes(fieldType)
}

export function isKnownNonArray(fieldType: unknown): boolean {
return typeof fieldType === 'function' && fieldType !== Array && fieldType !== Object
}
2 changes: 1 addition & 1 deletion packages/express-cargo/src/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ function createSourceDecorator(source: Source) {
const fieldMeta = classMeta.getFieldMetadata(propertyKey)
fieldMeta.setKey(key ?? propertyKey)
fieldMeta.setSource(source)
fieldMeta.pushAppliedDecorator({ name: source, category: 'source' })
fieldMeta.pushAppliedDecorator({ name: source, category: 'source', args: [key] })
classMeta.setFieldMetadata(propertyKey, fieldMeta)
classMeta.setFieldList(propertyKey)
}
Expand Down
6 changes: 3 additions & 3 deletions packages/express-cargo/src/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function Transform<T>(transformer: (value: T) => T): TypedPropertyDecorat
const classMeta = new CargoClassMetadata(target)
const fieldMeta = classMeta.getFieldMetadata(propertyKey)
fieldMeta.setTransformer(transformer)
fieldMeta.pushAppliedDecorator({ name: Transform.name, category: 'transform' })
fieldMeta.pushAppliedDecorator({ name: Transform.name, category: 'transform', args: [transformer] })
classMeta.setFieldMetadata(propertyKey, fieldMeta)
}
}
Expand All @@ -36,7 +36,7 @@ export function Request<T>(transformer: (req: Request) => T): TypedPropertyDecor
const fieldMeta = classMeta.getFieldMetadata(propertyKey)

fieldMeta.setRequestTransformer(transformer)
fieldMeta.pushAppliedDecorator({ name: Request.name, category: 'request' })
fieldMeta.pushAppliedDecorator({ name: Request.name, category: 'request', args: [transformer] })
classMeta.setFieldMetadata(propertyKey, fieldMeta)
classMeta.setRequestFieldList(propertyKey)
}
Expand All @@ -61,7 +61,7 @@ export function Virtual<T>(transformer: (obj: any) => T): TypedPropertyDecorator
const fieldMeta = classMeta.getFieldMetadata(propertyKey)

fieldMeta.setVirtualTransformer(transformer)
fieldMeta.pushAppliedDecorator({ name: Virtual.name, category: 'virtual' })
fieldMeta.pushAppliedDecorator({ name: Virtual.name, category: 'virtual', args: [transformer] })
classMeta.setFieldMetadata(propertyKey, fieldMeta)
classMeta.setVirtualFieldList(propertyKey)
}
Expand Down
1 change: 1 addition & 0 deletions packages/express-cargo/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export type DecoratorScope = 'self' | 'each'
export interface AppliedDecorator {
name: string
category: DecoratorCategory
args: readonly unknown[]
}

export type BindSources = {
Expand Down
8 changes: 6 additions & 2 deletions packages/express-cargo/src/validator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
AppliedDecorator,
ArrayComparator,
cargoErrorMessage,
EachValidatorRule,
Expand All @@ -12,10 +13,11 @@ import { CargoClassMetadata } from './metadata'
import { isDeepEqual } from './utils'
import { isValidPhoneNumber, CountryCode } from 'libphonenumber-js'

function addValidator(target: any, propertyKey: string | symbol, rule: ValidatorRule) {
function addValidator(target: any, propertyKey: string | symbol, rule: ValidatorRule, applied?: AppliedDecorator) {
const classMeta = new CargoClassMetadata(target)
const fieldMeta = classMeta.getFieldMetadata(propertyKey)
fieldMeta.addValidator(rule)
if (applied) fieldMeta.pushAppliedDecorator(applied)
classMeta.setFieldMetadata(propertyKey, fieldMeta)
}

Expand Down Expand Up @@ -705,6 +707,7 @@ export function With(fieldName: string, message?: cargoErrorMessage): PropertyDe
(value: unknown, instance?: Record<string | symbol, any>) => !(!!value && !instance?.[fieldName]),
message || `${String(propertyKey)} requires ${fieldName}`,
),
{ name: With.name, category: 'validator', args: [fieldName] },
)
}
}
Expand All @@ -728,6 +731,7 @@ export function Without(fieldName: string, message?: cargoErrorMessage): Propert
},
message || `${String(propertyKey)} cannot exist with ${fieldName}`,
),
{ name: Without.name, category: 'validator', args: [fieldName] },
)
}
}
Expand Down Expand Up @@ -943,7 +947,7 @@ export function Each(...args: (PropertyDecorator | TypedPropertyDecorator<any> |
}
})

fieldMeta.pushAppliedDecorator({ name: Each.name, category: 'validator' })
fieldMeta.pushAppliedDecorator({ name: Each.name, category: 'validator', args })
classMeta.setFieldMetadata(propertyKey, fieldMeta)
}
}
51 changes: 51 additions & 0 deletions packages/express-cargo/tests/rules/basic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Body, CargoSchemaError } from '../../src'
import { expectViolation, validateCargoSchema } from './testUtils'

describe('schema validation — basic rules', () => {
it('rejects a symbol property without a description (A1)', () => {
const sym = Symbol()

class SymbolWithoutDescriptionDto {
@Body()
[sym]!: string
}

try {
validateCargoSchema(SymbolWithoutDescriptionDto)
} catch (e) {
expect(e).toBeInstanceOf(CargoSchemaError)
expect((e as CargoSchemaError).violations.some(v => v.field === sym && v.message.includes('must have a description'))).toBe(true)
return
}
throw new Error('expected CargoSchemaError to be thrown')
})

it('accepts a symbol property with a description', () => {
const sym = Symbol('userId')

class SymbolWithDescriptionDto {
@Body()
[sym]!: string
}

expect(() => validateCargoSchema(SymbolWithDescriptionDto)).not.toThrow()
})

it('rejects an empty-string source key (A2)', () => {
class EmptyKeyDto {
@Body('')
foo!: string
}

expectViolation(() => validateCargoSchema(EmptyKeyDto), 'foo', 'key must not be an empty string')
})

it('accepts @Body() without an explicit key', () => {
class DefaultKeyDto {
@Body()
foo!: string
}

expect(() => validateCargoSchema(DefaultKeyDto)).not.toThrow()
})
})
41 changes: 41 additions & 0 deletions packages/express-cargo/tests/rules/crossField.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Body, With, Without } from '../../src'
import { expectViolation, validateCargoSchema } from './testUtils'

describe('schema validation — cross-field reference rules', () => {
it('rejects @With referencing a non-existent field (G1)', () => {
class WithUnknownDto {
@Body()
@With('ghost')
foo!: string
}

expectViolation(() => validateCargoSchema(WithUnknownDto), 'foo', '@With references unknown field "ghost"')
})

it('rejects @Without referencing a non-existent field (G2)', () => {
class WithoutUnknownDto {
@Body()
@Without('ghost')
foo!: string
}

expectViolation(() => validateCargoSchema(WithoutUnknownDto), 'foo', '@Without references unknown field "ghost"')
})

it('accepts references to existing fields', () => {
class CrossFieldValidDto {
@Body()
bar!: string

@Body()
@With('bar')
@Without('baz')
foo!: string

@Body()
baz!: string
}

expect(() => validateCargoSchema(CrossFieldValidDto)).not.toThrow()
})
})
Loading
Loading