diff --git a/packages/express-cargo/src/decorators.ts b/packages/express-cargo/src/decorators.ts index 8fc4ec5..07f778c 100644 --- a/packages/express-cargo/src/decorators.ts +++ b/packages/express-cargo/src/decorators.ts @@ -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) } } @@ -32,7 +32,7 @@ export function List(elementType: ArrayElementType): TypedPropertyDecorator(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( diff --git a/packages/express-cargo/src/rules/basic.ts b/packages/express-cargo/src/rules/basic.ts new file mode 100644 index 0000000..a317c03 --- /dev/null +++ b/packages/express-cargo/src/rules/basic.ts @@ -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] diff --git a/packages/express-cargo/src/rules/crossField.ts b/packages/express-cargo/src/rules/crossField.ts new file mode 100644 index 0000000..ce5e56a --- /dev/null +++ b/packages/express-cargo/src/rules/crossField.ts @@ -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] + 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] diff --git a/packages/express-cargo/src/rules/eachUsage.ts b/packages/express-cargo/src/rules/eachUsage.ts index 680ffef..5562b9c 100644 --- a/packages/express-cargo/src/rules/eachUsage.ts +++ b/packages/express-cargo/src/rules/eachUsage.ts @@ -1,3 +1,5 @@ +import { Each } from '../validator' +import { isKnownNonArray } from './utils' import { FieldRuleFn } from './types' const eachWrapsSource: FieldRuleFn = s => { @@ -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] diff --git a/packages/express-cargo/src/rules/registry.ts b/packages/express-cargo/src/rules/registry.ts index 50a4882..f12e269 100644 --- a/packages/express-cargo/src/rules/registry.ts +++ b/packages/express-cargo/src/rules/registry.ts @@ -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)] diff --git a/packages/express-cargo/src/rules/ruleChecker.ts b/packages/express-cargo/src/rules/ruleChecker.ts index 9bccb51..ba532b5 100644 --- a/packages/express-cargo/src/rules/ruleChecker.ts +++ b/packages/express-cargo/src/rules/ruleChecker.ts @@ -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): FieldState { const appliedSelf = fieldMeta.getAppliedDecorators('self') const appliedEach = fieldMeta.getAppliedDecorators('each') const sources = appliedSelf.filter(d => d.category === 'source') @@ -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, } } @@ -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) { diff --git a/packages/express-cargo/src/rules/typeHelperPlacement.ts b/packages/express-cargo/src/rules/typeHelperPlacement.ts new file mode 100644 index 0000000..b21e502 --- /dev/null +++ b/packages/express-cargo/src/rules/typeHelperPlacement.ts @@ -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] diff --git a/packages/express-cargo/src/rules/types.ts b/packages/express-cargo/src/rules/types.ts index ab9a391..1b05c11 100644 --- a/packages/express-cargo/src/rules/types.ts +++ b/packages/express-cargo/src/rules/types.ts @@ -17,6 +17,8 @@ export interface FieldState { hasSource: boolean hasRequest: boolean hasVirtual: boolean + sourceKey: string | symbol + siblingFields: ReadonlySet } /** A single field-level rule. Returns a violation message, or `null` if the field passes. */ diff --git a/packages/express-cargo/src/rules/utils.ts b/packages/express-cargo/src/rules/utils.ts new file mode 100644 index 0000000..62ab23e --- /dev/null +++ b/packages/express-cargo/src/rules/utils.ts @@ -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 +} diff --git a/packages/express-cargo/src/source.ts b/packages/express-cargo/src/source.ts index ac117dd..80a3042 100644 --- a/packages/express-cargo/src/source.ts +++ b/packages/express-cargo/src/source.ts @@ -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) } diff --git a/packages/express-cargo/src/transform.ts b/packages/express-cargo/src/transform.ts index d4b11dc..f496950 100644 --- a/packages/express-cargo/src/transform.ts +++ b/packages/express-cargo/src/transform.ts @@ -16,7 +16,7 @@ export function Transform(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) } } @@ -36,7 +36,7 @@ export function Request(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) } @@ -61,7 +61,7 @@ export function Virtual(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) } diff --git a/packages/express-cargo/src/types.ts b/packages/express-cargo/src/types.ts index 03069ac..4093905 100644 --- a/packages/express-cargo/src/types.ts +++ b/packages/express-cargo/src/types.ts @@ -191,6 +191,7 @@ export type DecoratorScope = 'self' | 'each' export interface AppliedDecorator { name: string category: DecoratorCategory + args: readonly unknown[] } export type BindSources = { diff --git a/packages/express-cargo/src/validator.ts b/packages/express-cargo/src/validator.ts index bcd33a6..b709a7a 100644 --- a/packages/express-cargo/src/validator.ts +++ b/packages/express-cargo/src/validator.ts @@ -1,4 +1,5 @@ import { + AppliedDecorator, ArrayComparator, cargoErrorMessage, EachValidatorRule, @@ -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) } @@ -705,6 +707,7 @@ export function With(fieldName: string, message?: cargoErrorMessage): PropertyDe (value: unknown, instance?: Record) => !(!!value && !instance?.[fieldName]), message || `${String(propertyKey)} requires ${fieldName}`, ), + { name: With.name, category: 'validator', args: [fieldName] }, ) } } @@ -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] }, ) } } @@ -943,7 +947,7 @@ export function Each(...args: (PropertyDecorator | TypedPropertyDecorator | } }) - fieldMeta.pushAppliedDecorator({ name: Each.name, category: 'validator' }) + fieldMeta.pushAppliedDecorator({ name: Each.name, category: 'validator', args }) classMeta.setFieldMetadata(propertyKey, fieldMeta) } } diff --git a/packages/express-cargo/tests/rules/basic.test.ts b/packages/express-cargo/tests/rules/basic.test.ts new file mode 100644 index 0000000..15a307d --- /dev/null +++ b/packages/express-cargo/tests/rules/basic.test.ts @@ -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() + }) +}) diff --git a/packages/express-cargo/tests/rules/crossField.test.ts b/packages/express-cargo/tests/rules/crossField.test.ts new file mode 100644 index 0000000..ca97a46 --- /dev/null +++ b/packages/express-cargo/tests/rules/crossField.test.ts @@ -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() + }) +}) diff --git a/packages/express-cargo/tests/rules/eachUsage.test.ts b/packages/express-cargo/tests/rules/eachUsage.test.ts index d190357..b1cce8c 100644 --- a/packages/express-cargo/tests/rules/eachUsage.test.ts +++ b/packages/express-cargo/tests/rules/eachUsage.test.ts @@ -1,8 +1,8 @@ -import { Body, Default, Each, Optional } from '../../src' +import { Body, Default, Each, Min, Optional } from '../../src' import { expectViolation, validateCargoSchema } from './testUtils' describe('schema validation — @Each usage rules', () => { - it('@Each 인자로 Source 데코레이터가 들어가면 거부', () => { + it('rejects a source decorator inside @Each (H2)', () => { class EachWrapsSourceDto { @Body() @Each(Body()) @@ -12,7 +12,7 @@ describe('schema validation — @Each usage rules', () => { expectViolation(() => validateCargoSchema(EachWrapsSourceDto), 'foo', '@Each cannot wrap source decorator') }) - it('@Each 인자로 Missing-Handler 데코레이터(@Optional)가 들어가면 거부', () => { + it('rejects @Optional inside @Each (H3)', () => { class EachWrapsOptionalDto { @Body() @Each(Optional()) @@ -22,7 +22,7 @@ describe('schema validation — @Each usage rules', () => { expectViolation(() => validateCargoSchema(EachWrapsOptionalDto), 'foo', '@Each cannot wrap missing-handler decorator') }) - it('@Each 인자로 Missing-Handler 데코레이터(@Default)가 들어가면 거부', () => { + it('rejects @Default inside @Each (H3)', () => { class EachWrapsDefaultDto { @Body() @Each(Default(0)) @@ -31,4 +31,24 @@ describe('schema validation — @Each usage rules', () => { expectViolation(() => validateCargoSchema(EachWrapsDefaultDto), 'foo', '@Each cannot wrap missing-handler decorator') }) + + it('rejects @Each on a non-array field (H1)', () => { + class EachOnNonArrayDto { + @Body() + @Each(Min(0)) + foo!: string + } + + expectViolation(() => validateCargoSchema(EachOnNonArrayDto), 'foo', '@Each can only be applied to array fields') + }) + + it('accepts @Each on an array field', () => { + class EachOnArrayDto { + @Body() + @Each(Min(0)) + foo!: number[] + } + + expect(() => validateCargoSchema(EachOnArrayDto)).not.toThrow() + }) }) diff --git a/packages/express-cargo/tests/rules/typeHelperPlacement.test.ts b/packages/express-cargo/tests/rules/typeHelperPlacement.test.ts new file mode 100644 index 0000000..d1c4dbf --- /dev/null +++ b/packages/express-cargo/tests/rules/typeHelperPlacement.test.ts @@ -0,0 +1,49 @@ +import { Body, List, Type } from '../../src' +import { expectViolation, validateCargoSchema } from './testUtils' + +class Nested { + @Body() + value!: string +} + +describe('schema validation — type-helper placement rules', () => { + it('rejects @List on a non-array field (M1)', () => { + class ListOnNonArrayDto { + @Body() + @List(Nested) + foo!: Nested + } + + expectViolation(() => validateCargoSchema(ListOnNonArrayDto), 'foo', '@List can only be applied to array fields') + }) + + it('accepts @List on an array field', () => { + class ListOnArrayDto { + @Body() + @List(Nested) + foo!: Nested[] + } + + expect(() => validateCargoSchema(ListOnArrayDto)).not.toThrow() + }) + + it('rejects @Type on a primitive field (M2)', () => { + class TypeOnPrimitiveDto { + @Body() + @Type(() => Nested) + foo!: string + } + + expectViolation(() => validateCargoSchema(TypeOnPrimitiveDto), 'foo', '@Type cannot be applied to a primitive field') + }) + + it('accepts @Type on an object field', () => { + class TypeOnObjectDto { + @Body() + @Type(() => Nested) + foo!: Nested + } + + expect(() => validateCargoSchema(TypeOnObjectDto)).not.toThrow() + }) +})