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
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]
Comment thread
laggu marked this conversation as resolved.
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