diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ba10234 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org/' + + - name: Install dependencies + run: npm install + + - name: Run tests + run: npm test + + - name: Build project + run: npm run build + + - name: Publish to npm + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fd83a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +node_modules +dist diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..e03afa5 --- /dev/null +++ b/.npmignore @@ -0,0 +1,7 @@ +node_modules +src +tsconfig.json +jest.config.js +tests +build.config.ts +.github \ No newline at end of file diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..a520835 --- /dev/null +++ b/Readme.md @@ -0,0 +1,77 @@ +# Typescript Validator + +`ts-validator` is a TypeScript library for validating data against specified rules. It supports validation for +various data types, nested objects, and arrays. + +## Installation + +You can install the package via npm: + +```bash +npm install ts-validator +``` + +Or with yarn +```bash +yarn add ts-validator +``` + +## Rules + +The validator supports the following rules: + + required: The field must be present. + string: The field must be a string. + number: The field must be a number. + array: The field must be an array. + min:: The field must be a string with a minimum length of . + +More coming soon! + +## Examples +### Nested object validation +```typescript +const validator = Validator.create({ + 'user.id': ['required', 'string'], + 'user.name': ['required', 'string'], + 'user.age': ['required', 'number'], +}) + +const data = {user: {id: '1', name: 'John Doe', age: 20}} + +try { + validator.validateObject<{ + user: { + id: string, + name: string, + age: number, + } + }>(data) + console.log('Validation passed') +} catch (error) { + if (error instanceof ValidationError) { + console.log('Validation failed', error.errors) + } +} + +``` + +### Nested object validation +```typescript +const validator = Validator.create({ + 'tags': ['array'], + 'tags.*': ['string'] +}) + +const data = {tags: ['tag1', 'tag2']} + +try { + validator.validateObject<{ tags: string[] }>(data) + console.log('Validation passed') +} catch (error) { + if (error instanceof ValidationError) { + console.log('Validation failed', error.errors) + } +} + +``` \ No newline at end of file diff --git a/build.config.ts b/build.config.ts new file mode 100644 index 0000000..b04e85d --- /dev/null +++ b/build.config.ts @@ -0,0 +1,13 @@ +import {defineBuildConfig} from 'unbuild' + +export default defineBuildConfig({ + entries: ['./src/index'], + outDir: 'dist', + declaration: true, + rollup: { + esbuild: { + minify: true, + }, + emitCJS: true, + } +}) \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..2e98e71 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,5 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/tests/**/*.test.ts'], +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..ec899c3 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "checkmate-ts", + "version": "1.0.0", + "description": "Checkmate Typescript - An object validator written in Typescript", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "unbuild", + "test": "jest" + }, + "author": { + "name": "Tom Hermsen", + "url": "https://github.com/tomhermsen" + }, + "keywords": [ + "typescript", + "validation", + "typed", + "data", + "dto", + "request" + ], + "license": "MIT", + "devDependencies": { + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "ts-jest": "^29.1.5", + "typescript": "^5.5.2", + "unbuild": "^2.0.0" + } +} diff --git a/src/Validator.ts b/src/Validator.ts new file mode 100644 index 0000000..677d194 --- /dev/null +++ b/src/Validator.ts @@ -0,0 +1,116 @@ +import type {Rule} from './types/Rule' +import type {Rules} from './types/Rules' +import {ValidationError} from './exceptions/ValidationError' + +class Validator { + private rules: Rules + + constructor(rules: Rules) { + this.rules = rules + } + + static create(rules: Rules) { + return new this(rules) + } + + private getNestedValue(obj: any, path: string): any { + return path.split('.').reduce((acc, part) => { + if (part === '*') { + if (!Array.isArray(acc)) { + return undefined + } + return acc + } + return acc && acc[part] + }, obj) + } + + private validateField(key: string, value: any, rules: Rule[], errors: { [key: string]: string[] }) { + rules.forEach(rule => { + if (rule === 'required' && (value === undefined || value === null)) { + if (!errors[key]) { + errors[key] = [] + } + errors[key].push(`${key} is required`) + } + + if (rule === 'string' && typeof value !== 'string') { + if (!errors[key]) { + errors[key] = [] + } + errors[key].push(`${key} is not a string`) + } + + if (rule === 'number' && !Number.isSafeInteger(Number(value))) { + if (!errors[key]) { + errors[key] = [] + } + errors[key].push(`${key} is not a number`) + } + + if (rule === 'array' && !Array.isArray(value)) { + if (!errors[key]) { + errors[key] = [] + } + errors[key].push(`${key} is not an array`) + } + + if (rule.startsWith('min:')) { + const minLength = parseInt(rule.split(':')[1], 10) + if (typeof value === 'string' && value.length < minLength) { + if (!errors[key]) { + errors[key] = [] + } + errors[key].push(`${key} should be at least ${minLength} characters long`) + } + } + }) + } + + private validateArray(key: string, array: any[], errors: { [key: string]: string[] }) { + array.forEach((item, index) => { + Object.entries(this.rules).forEach(([ruleKey, ruleValues]) => { + const arrayRuleKey = ruleKey.replace('.*', `[${index}]`) + if (arrayRuleKey.startsWith(`${key}[${index}]`)) { + const nestedKey = arrayRuleKey.slice(`${key}[${index}].`.length) + const nestedValue = this.getNestedValue(array, `${index}${nestedKey ? '.' + nestedKey : ''}`) + this.validateField(`${key}[${index}]${nestedKey ? '.' + nestedKey : ''}`, nestedValue, ruleValues, errors) + } + }) + }) + } + + private validate(data: object): T { + let errors: { [key: string]: string[] } = {} + Object.entries(this.rules).forEach(([key, rules]) => { + const value = this.getNestedValue(data, key) + + if (key.includes('.*')) { + const arrayKey = key.split('.*')[0] + const arrayValue = this.getNestedValue(data, arrayKey) + if (Array.isArray(arrayValue)) { + this.validateArray(arrayKey, arrayValue, errors) + } else { + if (!errors[arrayKey]) { + errors[arrayKey] = [] + } + errors[arrayKey].push(`${arrayKey} is not an array`) + } + } else { + this.validateField(key, value, rules, errors) + } + }) + + if (Object.keys(errors).length) { + throw new ValidationError(errors) + } + + return data as T + } + + validateObject(data: object): T { + return this.validate(data) + } +} + +export {Validator} diff --git a/src/exceptions/ValidationError.ts b/src/exceptions/ValidationError.ts new file mode 100644 index 0000000..9d3fd54 --- /dev/null +++ b/src/exceptions/ValidationError.ts @@ -0,0 +1,12 @@ +class ValidationError extends Error { + errors: Record = {} + + constructor(errors: Record, message: string = 'Validation error') { + super(message) + this.errors = errors + + Object.setPrototypeOf(this, ValidationError.prototype) + } +} + +export {ValidationError} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..72d079e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +import type { Rule } from './types/Rule' +import type { Rules } from './types/Rules' +import { Validator } from './Validator' + +export { Rule, Rules, Validator } \ No newline at end of file diff --git a/src/types/Rule.ts b/src/types/Rule.ts new file mode 100644 index 0000000..3dcad22 --- /dev/null +++ b/src/types/Rule.ts @@ -0,0 +1 @@ +export type Rule = 'required' | 'string' | `min:${number}` | 'number' | 'array'; \ No newline at end of file diff --git a/src/types/Rules.ts b/src/types/Rules.ts new file mode 100644 index 0000000..732bbba --- /dev/null +++ b/src/types/Rules.ts @@ -0,0 +1,3 @@ +import type {Rule} from './Rule' + +export type Rules = { [key: string]: Rule[] }; \ No newline at end of file diff --git a/tests/validator.test.ts b/tests/validator.test.ts new file mode 100644 index 0000000..6b55ec3 --- /dev/null +++ b/tests/validator.test.ts @@ -0,0 +1,91 @@ +import {Validator} from '../src/Validator' +import {ValidationError} from '../src/exceptions/ValidationError' + +describe('Validator', () => { + test('should validate required fields', () => { + const validator = Validator.create({ + 'name': ['required', 'string'], + 'age': ['required', 'number'], + 'tags': ['array'], + 'tags.*': ['string'] + }) + + const data = {name: 'John Doe', age: 30, tags: ['tag1', 'tag2']} + expect(() => validator.validateObject(data)).not.toThrow() + }) + + test('should throw validation errors for missing required fields', () => { + const validator = Validator.create({ + 'name': ['required', 'string'], + 'age': ['required', 'number'], + }) + + const data = {age: 30} + expect(() => validator.validateObject(data)).toThrow(ValidationError) + }) + + test('should validate nested array fields', () => { + const validator = Validator.create({ + 'name': ['required', 'string'], + 'age': ['required', 'number'], + 'tags': ['array'], + 'tags.*': ['string'] + }) + + const data = {name: 'John Doe', age: 30, tags: ['tag1', 'tag2']} + expect(() => validator.validateObject(data)).not.toThrow() + }) + + test('should validate nested object fields', () => { + const validator = Validator.create({ + 'user.id': ['required', 'string'], + 'user.name': ['required', 'string'], + 'user.age': ['required', 'number'], + }) + + const data = {user: {id: '1', name: 'John Doe', age: 20}} + expect(() => validator.validateObject(data)).not.toThrow() + }) + + test('should validate deep nested object fields', () => { + const validator = Validator.create({ + 'data.user.id': ['required', 'string'], + 'data.user.name': ['required', 'string'], + 'data.user.age': ['required', 'number'], + }) + + const data = {data: {user: {id: '1', name: 'John Doe', age: 20}}} + expect(() => validator.validateObject(data)).not.toThrow() + }) + + test('should throw validation errors for invalid field types', () => { + const validator = Validator.create({ + 'name': ['required', 'string'], + 'age': ['required', 'number'], + }) + + const data = {name: 'John Doe', age: 'thirty'} + expect(() => validator.validateObject(data)).toThrow(ValidationError) + }) + + test('should validate array fields', () => { + const validator = Validator.create({ + 'name': ['required', 'string'], + 'tags': ['array'], + 'tags.*': ['string'] + }) + const data = {name: 'John Doe', tags: ['tag1', 'tag2']} + expect(() => validator.validateObject(data)).not.toThrow() + }) + + test('should throw validation errors for invalid array elements', () => { + const validator = Validator.create({ + 'name': ['required', 'string'], + 'tags': ['array'], + 'tags.*': ['string'] + }) + + const data = {name: 'John Doe', tags: [1, 2]} + expect(() => validator.validateObject(data)).toThrow(ValidationError) + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9c9c554 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES5", + "module": "ESNext", + "lib": [ + "ESNext", + "DOM" + ], + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "src" + ], + "exclude": [ + "node_modules", + "dist" + ] +}