├── .eslintignore ├── .eslintrc.js ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md ├── .gitignore ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── gulpfile.js ├── jest.config.js ├── jest.setup.js ├── package.json ├── src ├── index.d.ts ├── index.js └── index.test.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules/** 2 | /dist/** 3 | /*.js 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "jest": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:node/recommended" 10 | ], 11 | "parserOptions": { 12 | "ecmaVersion": 12, 13 | "sourceType": "module" 14 | }, 15 | "rules": { 16 | 'semi': ['error', 'never'], 17 | 'quotes': ['error', 'single', { avoidEscape: true }], 18 | 'no-unused-vars': 'warn' 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Reproduction Steps** 14 | Provide a self-contained, concise snippet of code that can be used to reproduce the issue. 15 | For more complex issues provide a repo with the smallest sample that reproduces the bug. 16 | 17 | Avoid including business logic or unrelated code, it makes diagnosis more difficult. 18 | The code sample should be an SSCCE. See http://sscce.org/ for details. 19 | In short, please provide a code sample that we can copy/paste, run and reproduce. 20 | 21 | **Expected Behavior** 22 | What did you expect to happen? 23 | 24 | **Current Behavior** 25 | What actually happened? 26 | 27 | Please include full errors, uncaught exceptions, stack traces, and relevant logs. 28 | If service/functions responses are relevant, please include wire logs. 29 | 30 | **Possible Solution** 31 | Suggest a fix/reason for the bug 32 | 33 | **Additional Information/Context** 34 | Anything else that might be relevant for troubleshooting this bug. 35 | Providing context helps us come up with a solution that is most useful in the real world. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | test.* 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/src 3 | *.d.ts -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "none", 6 | "quoteProps": "preserve" 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## V 3.2.0 2 | 3 | - added "floor" fixed 4 | - compress output 5 | 6 | ## V 3.1.0-beta 7 | 8 | - added "sugar syntax" 9 | 10 | ## V 3.0.0-beta 11 | 12 | - remove parser() function 13 | - remove join() and schema() functions 14 | - update type definitions 15 | - rename string(), number(), boolean() and array() to text(), float(), bool() and list() to prevent collisions with reserved words 16 | - fix() never fails. It can always fix data 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2021, Gonzalo Chumillas 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Schema-Fixer 2 | 3 | **What is Schema-Fixer?** 4 | 5 | Schema-Fixer is a lightweight utility designed to sanitize data while preserving its types. 6 | 7 | **What is it not?** 8 | 9 | Schema-Fixer is not a transformer or a validator—it doesn't validate correctness or enforce structure. It simply "fixes" data to align with a given schema. 10 | 11 | **Why use Schema-Fixer?** 12 | 13 | Handling `null`, `undefined`, or unexpected data formats can lead to application crashes or unintended behavior. Schema-Fixer ensures that your data conforms to the expected schema, allowing your application to run smoothly without errors caused by malformed input. 14 | 15 | ## Key Features 16 | 17 | - **Reliable and Fail-Safe**
Schema-Fixer can process any data against a given schema without failing. 18 | 19 | - **Type Inference**
Automatically infers data types based on the provided schema. 20 | 21 | - **Graceful Handling of `null` and `undefined`**
Replaces `null` or `undefined` values with predefined default values, ensuring data integrity 22 | 23 | - **Prevents Unwanted Properties**
Excludes extraneous or unexpected properties, mitigating risks like code injection. 24 | 25 | ## Install 26 | 27 | ```bash 28 | # install from your project directory 29 | npm install @schema-fixer/core 30 | ``` 31 | 32 | ```js 33 | // and import it from your file.js or file.ts 34 | import sf from '@schema-fixer/core' 35 | 36 | console.log(sf.fix(100, 'string')) // returns '100' 37 | ``` 38 | 39 | ## Example 40 | 41 | In the following example we obtain the data from an external API and ensure that it conforms to the expected format: 42 | 43 | ```ts 44 | import sf from '@schema-fixer/core' 45 | 46 | // unwanted `null` and `undefined` values are transformed to '', 0, [], etc. 47 | // no need to indicate the return type, since it is inferred from the schema 48 | function getAuthor = async (authorId: string) => { 49 | const res = await axios.get(`/api/v1/authors/${authorId}`) 50 | 51 | // 'fixes' the data against a given 'schema' 52 | return sf.fix(res.data, { 53 | name: sf.text(), 54 | middleName: sf.text(), 55 | lastName: sf.text(), 56 | age: sf.float(), 57 | isMarried: sf.bool(), 58 | // an array of strings 59 | children: sf.list({ of: sf.text() }), 60 | // an object 61 | address: { 62 | street: sf.text(), 63 | city: sf.text(), 64 | state: sf.text() 65 | }, 66 | // array of objects 67 | books: sf.list({ of: { 68 | title: sf.text(), 69 | year: sf.float(), 70 | // combine multiple 'fixers' 71 | id: [sf.trim(), sf.upper()] 72 | }}) 73 | }) 74 | } 75 | ``` 76 | 77 | ## Sugar syntax 78 | 79 | For brevity, you can also use the "sugar syntax", which replaces "aliases" with the corresponding fixers: 80 | 81 | ```js 82 | import sf from '@schema-fixer/core' 83 | 84 | const fixedData = sf.fix(data, { 85 | name: 'string', // sf.text() 86 | age: 'number', // sf.float() 87 | isMarried: 'boolean', // sf.bool() 88 | children: 'string[]', // sf.list({ of: sf.text() }) 89 | years: 'number[]', // sf.list({ of: sf.float() }) 90 | items: 'boolean[]' // sf.list({ of: sf.bool() }) 91 | }) 92 | ``` 93 | 94 | ## API 95 | 96 | ```js 97 | fix(data, schema) // fixes 'any data' againts a given schema 98 | createFixer(def, fixer) // creates a custom fixer 99 | 100 | // built-in fixers 101 | text({ def = '', required = true, coerce = true }) // fixes a string 102 | float({ def = 0, required = true, coerce = true }) // fixes a number 103 | bool({ def = false, required = true, coerce = true }) // fixes a boolean 104 | list({ of: Schema, def = [], required = true }) // fixes an array 105 | 106 | // additional built-in fixers 107 | trim({ def = '', required = true }) // trims a string 108 | lower({ def = '', required = true }) // lowercases a string 109 | upper({ def = '', required = true }) // uppercases a string 110 | floor({ def = 0, required = true }) // math floor 111 | ``` 112 | 113 | - A 'fixer' is the function that fixes the incoming data. 114 | 115 | - A `Schema` can be a 'fixer', a list of 'fixers' or a record of 'fixers'. For example: 116 | 117 | ```js 118 | fix(1, bool()) // true 119 | fix('Hello!', [text(), upper()]) // 'HELLO!' 120 | fix({ name: 'John' }, { name: text(), age: float() }) // { name: 'John, age: 0 } 121 | ``` 122 | 123 | - The `def` parameter indicates the value to return when the 'fixer' cannot fix the incoming data or the data is `null` or `undefined`. For example: 124 | 125 | ```js 126 | fix(null, text()) // '' 127 | fix(undefined, text({ def: 'aaa' })) // 'aaa' 128 | fix({}, float({ def: 100 })) // 100, since {} cannot be fixed 129 | ``` 130 | 131 | - The `coerce` parameter indicates that you want to "force" the conversion (default is `true`). For example: 132 | 133 | ```js 134 | fix(100, text()) // '100' 135 | fix(100, text({ coerce: false })) // '', since 100 is not a string 136 | ``` 137 | 138 | - The `required` parameter indicates that an incoming value is required. For example: 139 | 140 | ```js 141 | fix(undefined, float()) // 0, as we expect a number 142 | fix(null, float({ required: false })) // undefined 143 | fix(undefined, text({ required: false })) // undefined 144 | ``` 145 | 146 | > [!NOTE] 147 | > You'll probably never have to use `required`, `def` or `coerce`. But they're there! 148 | > 149 | > Take a look at the [TEST FILE](./src/index.test.ts) for more examples. 150 | 151 | ## Combine fixers 152 | 153 | You can apply different "fixers" to the same incoming data by combining them. For example: 154 | 155 | ```js 156 | fix('Hello!', [text(), upper()]) // 'HELLO!' 157 | ``` 158 | 159 | ## Create your own fixer 160 | 161 | This is a simple fixer: 162 | 163 | ```ts 164 | import sf from '@schema-fixer/core' 165 | 166 | // fixes a color 167 | const colorFixer = sf.createFixer('#000000', (value) => { 168 | const color = `${value}`.trim().toUpperCase() 169 | 170 | if (color.match(/^#([0-9,A-F]{6})$/)) { 171 | // nice! 172 | return color 173 | } else { 174 | const matches = color.match(/^#([0-9,A-F]{3})$/) 175 | 176 | if (matches) { 177 | return `#${matches[1].split('').map(d => d.repeat(2)).join('')}` 178 | } 179 | } 180 | 181 | // a default value is provided 182 | throw new TypeError('not a color') 183 | }) 184 | 185 | sf.fix('#f6f', colorFixer()) // '#FF66FF' 186 | sf.fix('#f6ef6f', colorFixer()) // '#F6EF6F' 187 | sf.fix('red', colorFixer()) // '#000000', as 'red' is not a valid color 188 | ``` 189 | 190 | ## Contributing 191 | 192 | Do you want to contribute? Fantastic! Take a look at [open issues](https://github.com/gchumillas/schema-fixer/issues) or help me find and fix bugs. 193 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const babel = require('gulp-babel'); 3 | const fs = require('fs-extra'); 4 | 5 | const clean = () => { 6 | return fs.rm('./dist', { recursive: true, force: true }) 7 | }; 8 | 9 | const compile = () => { 10 | return gulp.src(['src/**/*.js', '!src/**/*.test.js']) 11 | .pipe(babel({ 12 | presets: ['@babel/env'], 13 | minified: true, 14 | })) 15 | .pipe(gulp.dest('dist')); 16 | }; 17 | 18 | const copyDefTypes = () => { 19 | return fs.copy('./src/index.d.ts', './dist/index.d.ts') 20 | } 21 | 22 | exports.build = gulp.series( 23 | clean, 24 | compile, 25 | copyDefTypes 26 | ); 27 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | setupFilesAfterEnv: ['/jest.setup.js'], 6 | }; -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | global.console = { 2 | ...console, 3 | // uncomment to ignore a specific log level 4 | // log: jest.fn(), 5 | // debug: jest.fn(), 6 | // info: jest.fn(), 7 | // warn: jest.fn(), 8 | error: jest.fn(), 9 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@schema-fixer/core", 3 | "license": "ISC", 4 | "version": "3.2.1", 5 | "type": "commonjs", 6 | "description": "Schema fixer", 7 | "repository": "github:gchumillas/schema-fixer", 8 | "author": "Gonzalo Chumillas", 9 | "main": "./dist/index.js", 10 | "types": "./dist/index.d.ts", 11 | "files": [ 12 | "/dist" 13 | ], 14 | "engines": { 15 | "node": ">=14.18.1" 16 | }, 17 | "keywords": [ 18 | "schema", 19 | "fixer" 20 | ], 21 | "scripts": { 22 | "test": "jest ./src", 23 | "lint": "eslint 'src/**/*.js'", 24 | "build": "gulp build", 25 | "prepare": "yarn build" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.15.8", 29 | "@babel/preset-env": "^7.15.8", 30 | "@types/jest": "^26.0.24", 31 | "eslint": "^7.32.0", 32 | "eslint-plugin-node": "^11.1.0", 33 | "fs-extra": "^10.0.0", 34 | "gulp": "^5.0.0", 35 | "gulp-babel": "^8.0.0", 36 | "jest": "^29.7.0", 37 | "ts-jest": "^29.1.2", 38 | "typescript": "^5.4.2" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | type Prettify = { [K in keyof T]: T[K] } & {} 2 | 3 | export type FixerAlias = 'string' | 'number' | 'boolean' | 'string[]' | 'number[]' | 'boolean[]' 4 | export type Fixer = (value: any) => S 5 | 6 | interface SchemaRecord extends Record {} 7 | export type Schema = FixerAlias | Fixer | Fixer[] | SchemaRecord 8 | 9 | export type FixerAliasType = 10 | T extends 'string' ? string : 11 | T extends 'number' ? number : 12 | T extends 'boolean' ? boolean : 13 | T extends 'string[]' ? string[] : 14 | T extends 'number[]' ? number[] : 15 | T extends 'boolean[]' ? boolean[] : 16 | never 17 | export type SchemaType = 18 | T extends FixerAlias ? FixerAliasType : 19 | T extends Fixer ? S : 20 | T extends Fixer[] ? S : 21 | T extends Record ? { [Prop in keyof T]: SchemaType } & {} : 22 | never 23 | 24 | // main functions 25 | export function fix(value: any, schema: T): SchemaType 26 | 27 | // create custom fixers 28 | declare function fixer(options: Prettify<{ required: false } & S>): (value: any) => T | undefined 29 | declare function fixer(options?: Prettify<{ def?: T } & S>): (value: any) => T 30 | declare function createFixer>( 31 | def: T, 32 | fn: (value: any, options: S) => T 33 | ): typeof fixer 34 | 35 | // fixers 36 | export const text: ReturnType> 37 | export const float: ReturnType> 38 | export const bool: ReturnType> 39 | export const trim: ReturnType> 40 | export const lower: ReturnType> 41 | export const upper: ReturnType> 42 | export const floor: ReturnType> 43 | 44 | export function list(_: { required: false; of: T }): (value: any) => Array> | undefined 45 | export function list(_: { def?: Array>; of: T }): (value: any) => Array> 46 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // UTILITIES 2 | const concat = (texts, glue = '') => texts.filter((x) => !!x).join(glue) 3 | const isNullOrUndefined = (value) => value === null || value === undefined 4 | const isPlainObject = (value) => value !== null && typeof value == 'object' && !Array.isArray(value) 5 | 6 | const createFixer = (def, fn) => { 7 | return ({ def: defValue = def, required = true, ...options1 } = {}) => { 8 | return (value, options2) => { 9 | const params = { ...options1, ...options2 } 10 | const { path } = params 11 | 12 | if (isNullOrUndefined(value)) { 13 | if (required) { 14 | return defValue 15 | } 16 | 17 | return undefined 18 | } 19 | 20 | try { 21 | return fn(value, params) 22 | } catch (e) { 23 | console.error([path, e.message ?? `${e}`].filter((x) => !!x).join(': ')) 24 | return required ? defValue : undefined 25 | } 26 | } 27 | } 28 | } 29 | 30 | // BUILD-IN FIXERS 31 | const text = createFixer('', (value, params) => { 32 | const { coerce = true } = params 33 | 34 | if (typeof value == 'string') { 35 | return value 36 | } else if (coerce && ['boolean', 'number'].includes(typeof value)) { 37 | return `${value}` 38 | } 39 | 40 | throw new TypeError('not a string') 41 | }) 42 | 43 | const float = createFixer(0, (value, params) => { 44 | const { coerce = true } = params 45 | 46 | if (typeof value == 'number') { 47 | return value 48 | } else if (coerce && ['boolean', 'string'].includes(typeof value)) { 49 | const val = +value 50 | if (isNaN(val)) { 51 | throw new TypeError('not a number') 52 | } 53 | 54 | return +value 55 | } 56 | 57 | throw new TypeError('not a number') 58 | }) 59 | 60 | const bool = createFixer(false, (value, params) => { 61 | const { coerce = true } = params 62 | 63 | if (typeof value == 'boolean') { 64 | return value 65 | } else if (coerce) { 66 | return !!value 67 | } 68 | 69 | throw new TypeError('not a boolean') 70 | }) 71 | 72 | const list = createFixer([], (value, params) => { 73 | const { of: type, path } = params 74 | 75 | if (Array.isArray(value)) { 76 | const val = value.reduce((prevVal, item, i) => { 77 | const val = fix(item, type, { path: `${path}[${i}]` }) 78 | return [...prevVal, val] 79 | }, []) 80 | 81 | return val 82 | } 83 | 84 | throw new TypeError('not an array') 85 | }) 86 | 87 | const trim = createFixer('', (value) => { 88 | if (typeof value != 'string') { 89 | throw new TypeError('not a string') 90 | } 91 | 92 | return value.trim() 93 | }) 94 | 95 | const lower = createFixer('', (value) => { 96 | if (typeof value != 'string') { 97 | throw new TypeError('not a string') 98 | } 99 | 100 | return value.toLocaleLowerCase() 101 | }) 102 | 103 | const upper = createFixer('', (value) => { 104 | if (typeof value != 'string') { 105 | throw new TypeError('not a string') 106 | } 107 | 108 | return value.toLocaleUpperCase() 109 | }) 110 | 111 | const floor = createFixer(0, (value) => { 112 | if (typeof value != 'number') { 113 | throw new TypeError('not a number') 114 | } 115 | 116 | return Math.floor(value) // [number(), floor()] 117 | }) 118 | 119 | // MAIN FUNCTIONS 120 | const fixerByAlias = { 121 | 'string': text(), 122 | 'number': float(), 123 | 'boolean': bool(), 124 | 'string[]': list({ of: text() }), 125 | 'number[]': list({ of: float() }), 126 | 'boolean[]': list({ of: bool() }) 127 | } 128 | 129 | const fix = (value, schema, { path = '' } = {}) => { 130 | if (['string', 'function'].includes(typeof schema)) { 131 | schema = [schema] 132 | } 133 | 134 | if (Array.isArray(schema)) { 135 | return fixArray(value, schema, path) 136 | } 137 | 138 | if (!isPlainObject(value)) { 139 | value = {} 140 | } 141 | 142 | return fixObject(value, schema, path) 143 | } 144 | 145 | const fixArray = (value, schema, path) => { 146 | return schema.reduce((acc, fixer) => { 147 | const f = typeof fixer == 'string' ? fixerByAlias[fixer] : fixer 148 | return f(acc, { path }) 149 | }, value) 150 | } 151 | 152 | const fixObject = (value, schema, path) => { 153 | return Object.entries(schema).reduce( 154 | (props, [prop, fixer]) => ({ 155 | ...props, 156 | [prop]: fix(value[prop], typeof fixer == 'string' ? fixerByAlias[fixer] : fixer, { 157 | path: concat([path, prop], '.') 158 | }) 159 | }), 160 | {} 161 | ) 162 | } 163 | 164 | module.exports = { 165 | // main functions 166 | fix, 167 | // utilities 168 | createFixer, 169 | // built-in fixers 170 | text, 171 | float, 172 | bool, 173 | list, 174 | trim, 175 | lower, 176 | upper, 177 | floor 178 | } 179 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { fix, createFixer, text, upper, lower, trim, floor, float, bool, list } from './index' 2 | 3 | describe('README examples', () => { 4 | test('main example should fix input data', () => { 5 | const data = { 6 | name: 'Stephen', 7 | middleName: undefined, 8 | lastName: 'King', 9 | age: '75', 10 | isMarried: 1, 11 | children: ['Joe Hill', 'Owen King', 'Naomi King'], 12 | address: { 13 | street: '107-211 Parkview Ave, Bangor, ME 04401, USA', 14 | city: 'Portland', 15 | state: 'Oregon' 16 | }, 17 | books: [ 18 | { title: 'The Stand', year: 1978, id: 'isbn-9781444720730' }, 19 | { title: "Salem's lot", year: '1975', id: 'isbn-0385007515' } 20 | ], 21 | // this property was accidentally passed and 22 | // will be removed from the fixed data 23 | metadata: "console.log('please ignore me')" 24 | } 25 | 26 | const fixedData = fix(data, { 27 | name: text(), 28 | middleName: text(), 29 | lastName: text(), 30 | age: float(), 31 | isMarried: bool(), 32 | // an array of strings 33 | children: list({ of: text() }), 34 | // an object 35 | address: { 36 | street: text(), 37 | city: text(), 38 | state: text() 39 | }, 40 | // array of objects 41 | books: list({ 42 | of: { 43 | title: text(), 44 | year: float(), 45 | // combine multiple 'fixers' 46 | id: [trim(), upper()] 47 | } 48 | }) 49 | }) 50 | 51 | expect(fixedData).toEqual({ 52 | name: 'Stephen', 53 | middleName: '', 54 | lastName: 'King', 55 | age: 75, // '74' has been replaced by 74 56 | isMarried: true, // 1 has been replaced by true 57 | children: ['Joe Hill', 'Owen King', 'Naomi King'], 58 | address: { 59 | street: '107-211 Parkview Ave, Bangor, ME 04401, USA', 60 | city: 'Portland', 61 | state: 'Oregon' 62 | }, 63 | books: [ 64 | { title: 'The Stand', year: 1978, id: 'ISBN-9781444720730' }, 65 | { title: "Salem's lot", year: 1975, id: 'ISBN-0385007515' } 66 | ] 67 | // metadata was ignored 68 | }) 69 | }) 70 | 71 | test('sugar syntax example should fix input data', () => { 72 | const data = { 73 | name: 'John', 74 | age: '35', 75 | isMarried: 0, 76 | children: {}, 77 | years: [1954, '2023', 1987], 78 | items: [1, 0, {}, [], 'false'] 79 | } 80 | 81 | const fixedData = fix(data, { 82 | name: 'string', // sf.text() 83 | age: 'number', // sf.float() 84 | isMarried: 'boolean', // sf.bool() 85 | children: 'string[]', // sf.list({ of: sf.text() }) 86 | years: 'number[]', // sf.list({ of: sf.float() }) 87 | items: 'boolean[]' // sf.list({ of: sf.bool() }) 88 | }) 89 | 90 | expect(fixedData).toEqual({ 91 | name: 'John', 92 | age: 35, 93 | isMarried: false, 94 | children: [], 95 | years: [1954, 2023, 1987], 96 | items: [true, false, true, true, true] 97 | }) 98 | }) 99 | 100 | test('API examples should fix input data', () => { 101 | // A `Schema` can be a 'fixer', a list of 'fixers' or a record of 'fixers' 102 | expect(fix(1, bool())).toBe(true) 103 | expect(fix('Hello!', [text(), upper()])).toBe('HELLO!') 104 | expect(fix({ name: 'John' }, { name: text(), age: float() })).toEqual({ 105 | name: 'John', 106 | age: 0 107 | }) 108 | 109 | // The `def` parameter indicates the value to return when the 'fixer' 110 | // cannot fix the incoming data or the data is `null` or `undefined` 111 | expect(fix(null, text())).toBe('') 112 | expect(fix(undefined, text({ def: 'aaa' }))).toBe('aaa') 113 | expect(fix({}, float({ def: 100 }))).toBe(100) 114 | 115 | // The `coerce` parameter indicates that you want to "force" the 116 | // conversion (default is `true`) 117 | expect(fix(100, text())).toBe('100') 118 | expect(fix(100, text({ coerce: false }))).toBe('') 119 | 120 | // The `required` parameter indicates that an incoming value is required 121 | expect(fix(undefined, float())).toBe(0) 122 | expect(fix(null, float({ required: false }))).toBeUndefined() 123 | expect(fix(undefined, text({ required: false }))).toBeUndefined() 124 | }) 125 | }) 126 | 127 | describe('Custom fixers', () => { 128 | test('floor fixer should fix input data', () => { 129 | const floor = createFixer(0, (value) => { 130 | if (typeof value != 'number') { 131 | throw TypeError('not a float') 132 | } 133 | 134 | return Math.floor(value) 135 | }) 136 | 137 | expect(fix('105.48', [float(), floor()])).toBe(105) 138 | expect(fix('105.48', floor())).toBe(0) 139 | }) 140 | 141 | test('color fixer should fix input data', () => { 142 | const color = createFixer('#000000', (value: any) => { 143 | if (typeof value != 'string' || !value.match(/^#[0-9A-F]{6}$/i)) { 144 | throw new TypeError('not a color') 145 | } 146 | 147 | return value 148 | }) 149 | 150 | // note that we are using multiple fixers before applying our custom fixer 151 | const fixedColor = fix('#ab783F', [upper(), trim(), color()]) 152 | expect(fixedColor).toBe('#AB783F') 153 | }) 154 | }) 155 | 156 | describe('Build-in fixers', () => { 157 | test('text fixer should fix input data', () => { 158 | expect(fix('hello there!', text())).toBe('hello there!') 159 | expect(fix(true, text())).toBe('true') 160 | expect(fix(false, text())).toBe('false') 161 | expect(fix(125.48, text())).toBe('125.48') 162 | expect(fix(undefined, text())).toBe('') 163 | expect(fix(null, text())).toBe('') 164 | 165 | // with def option 166 | expect(fix(undefined, text({ def: 'John Smith' }))).toBe('John Smith') 167 | expect(fix(null, text({ def: 'John Smith' }))).toBe('John Smith') 168 | 169 | // with coerce option 170 | expect(fix(true, text({ coerce: false }))).toBe('') 171 | expect(fix(125.48, text({ coerce: false, def: 'xxx' }))).toBe('xxx') 172 | }) 173 | 174 | test('float fixer should fix input data', () => { 175 | expect(fix(undefined, float())).toBe(0) 176 | expect(fix(null, float())).toBe(0) 177 | expect(fix(false, float())).toBe(0) 178 | expect(fix(true, float())).toBe(1) 179 | expect(fix(125.48, float())).toBe(125.48) 180 | expect(fix('125.48', float())).toBe(125.48) 181 | expect(fix('lorem ipsum', float())).toBe(0) 182 | 183 | // with def option 184 | expect(fix({}, float({ def: 100 }))).toBe(100) 185 | expect(fix(undefined, float({ def: 125.48 }))).toBe(125.48) 186 | expect(fix(null, float({ def: 125.48 }))).toBe(125.48) 187 | 188 | // with coerce option 189 | expect(fix('125.48', float({ coerce: false }))).toBe(0) 190 | expect(fix('125.48', float({ coerce: false, def: 100 }))).toBe(100) 191 | }) 192 | 193 | test('bool fixer should fix input data', () => { 194 | expect(fix(true, bool())).toBe(true) 195 | expect(fix(false, bool())).toBe(false) 196 | expect(fix(1, bool())).toBe(true) 197 | expect(fix(0, bool())).toBe(false) 198 | expect(fix('', bool())).toBe(false) 199 | expect(fix('lorem ipsum', bool())).toBe(true) 200 | expect(fix({}, bool())).toBe(true) 201 | expect(fix(undefined, bool())).toBe(false) 202 | expect(fix(null, bool())).toBe(false) 203 | 204 | // with def option 205 | expect(fix(undefined, bool({ def: true }))).toBe(true) 206 | expect(fix(null, bool({ def: true }))).toBe(true) 207 | 208 | // with coerce option 209 | expect(fix(1, bool({ coerce: false }))).toBe(false) 210 | expect(fix(1, bool({ coerce: false, def: true }))).toBe(true) 211 | }) 212 | 213 | test('list fixer should fix input data', () => { 214 | expect(fix([true, false], list({ of: text() }))).toEqual(['true', 'false']) 215 | expect(fix([0, 1], list({ of: bool() }))).toEqual([false, true]) 216 | expect(fix([1, '2', 3], list({ of: float() }))).toEqual([1, 2, 3]) 217 | expect(fix(undefined, list({ of: text() }))).toEqual([]) 218 | expect(fix(null, list({ of: text() }))).toEqual([]) 219 | 220 | // with def option 221 | expect(fix(undefined, list({ of: float(), def: [1, 2, 3] }))).toEqual([1, 2, 3]) 222 | expect(fix(null, list({ of: float(), def: [1, 2, 3] }))).toEqual([1, 2, 3]) 223 | 224 | // with required option 225 | expect(fix(null, list({ required: false, of: text() }))).toBeUndefined() 226 | }) 227 | 228 | test('multiple fixers should fix input data', () => { 229 | expect(fix(' hello there! ', [text(), trim()])).toBe('hello there!') 230 | expect(fix(125.48, trim())).toBe('') 231 | 232 | // lower 233 | expect(fix('Hello There!', [text(), lower()])).toBe('hello there!') 234 | expect(fix(125.48, lower())).toBe('') 235 | 236 | // upper 237 | expect(fix('hello there!', [text(), upper()])).toBe('HELLO THERE!') 238 | expect(fix(125.48, upper())).toBe('') 239 | 240 | // trim 241 | expect(fix(' Hello There! ', [text(), trim(), lower()])).toBe('hello there!') 242 | expect(fix(' Hello There! ', [text(), trim(), upper()])).toBe('HELLO THERE!') 243 | 244 | // floor 245 | expect(fix('100.5', [float(), floor()])).toBe(100) 246 | }) 247 | 248 | test('should fix input data against schema records', () => { 249 | // none objects 250 | expect(fix(100, { id: text() })).toEqual({ id: '' }) 251 | expect(fix(true, { id: text() })).toEqual({ id: '' }) 252 | expect(fix('lorem ipsum', { id: text() })).toEqual({ id: '' }) 253 | 254 | // object 255 | expect( 256 | fix( 257 | { 258 | name: 125.48, 259 | pseudonym: 78945, 260 | age: 'old', 261 | single: 1, 262 | location: 102, 263 | novels: [ 264 | { title: 'Book 1', year: 2011 }, 265 | { title: 'Book 2', year: 2012 } 266 | ] 267 | }, 268 | { 269 | name: text({ coerce: false }), 270 | pseudonym: [lower(), trim()], 271 | age: float(), 272 | single: bool({ coerce: false }), 273 | location: { latitude: float(), longitude: float() }, 274 | novels: list({ of: text() }) 275 | } 276 | ) 277 | ).toEqual({ 278 | name: '', 279 | pseudonym: '', 280 | age: 0, 281 | single: false, 282 | location: { latitude: 0, longitude: 0 }, 283 | novels: ['', ''] 284 | }) 285 | 286 | expect( 287 | fix( 288 | { 289 | name: 'John Smith', 290 | address: { 291 | street: 'Clover alley, 123', 292 | postalCode: 35000 293 | } 294 | }, 295 | { 296 | name: text(), 297 | address: { 298 | street: text(), 299 | postalCode: text(), 300 | city: text({ def: 'Portland' }) 301 | } 302 | } 303 | ) 304 | ).toEqual({ 305 | name: 'John Smith', 306 | address: { 307 | street: 'Clover alley, 123', 308 | postalCode: '35000', 309 | city: 'Portland' 310 | } 311 | }) 312 | }) 313 | 314 | test('should fix invalid input data', () => { 315 | // invalid strings 316 | expect(fix({}, text())).toBe('') 317 | expect(fix({}, text({ def: 'hello!' }))).toBe('hello!') 318 | expect(fix(100, trim({ def: 'zzz' }))).toBe('zzz') 319 | expect(fix(100, lower({ def: 'vvv' }))).toBe('vvv') 320 | expect(fix(100, upper({ def: 'www' }))).toBe('www') 321 | 322 | // invalid numbers 323 | expect(fix('aaa', float())).toBe(0) 324 | expect(fix('aaa', float({ def: 100 }))).toBe(100) 325 | 326 | // invalid booleans 327 | expect(fix({}, bool({ coerce: false }))).toBe(false) 328 | expect(fix({}, bool({ coerce: false, def: true }))).toBe(true) 329 | 330 | expect(fix('aaa', list({ of: text() }))).toEqual([]) 331 | expect(fix({}, list({ of: float(), def: [1, 2, 3] }))).toEqual([1, 2, 3]) 332 | expect(fix(undefined, list({ required: false, of: float() }))).toBeUndefined() 333 | 334 | // invalid objects 335 | expect(fix(100, { name: text(), age: float() })).toEqual({ name: '', age: 0 }) 336 | expect(fix(100, { name: text({ def: 'John' }), age: float({ def: 35 }) })).toEqual({ name: 'John', age: 35 }) 337 | }) 338 | }) 339 | 340 | describe('Sugar syntax', () => { 341 | test('should fix input data against basic schemas', () => { 342 | expect(fix(100, 'string')).toBe('100') 343 | expect(fix('100', 'number')).toBe(100) 344 | expect(fix(1, 'boolean')).toBe(true) 345 | expect(fix([100, 200, 300], 'string[]')).toEqual(['100', '200', '300']) 346 | expect(fix(['100', '200', '300'], 'number[]')).toEqual([100, 200, 300]) 347 | expect(fix([1, 0, 1], 'boolean[]')).toEqual([true, false, true]) 348 | }) 349 | 350 | test('should fix input data against schema record', () => { 351 | const data = { 352 | name: 'Stephen', 353 | lastName: 'King', 354 | age: '75', 355 | isMarried: 1, 356 | children: ['Joe Hill', 'Owen King', 'Naomi King'], 357 | address: { 358 | street: '107-211 Parkview Ave, Bangor, ME 04401, USA', 359 | city: 'Portland', 360 | state: 'Oregon' 361 | }, 362 | books: [ 363 | { title: 'The Stand', year: 1978, id: 'isbn-9781444720730' }, 364 | { title: "Salem's lot", year: '1975', id: 'isbn-0385007515' } 365 | ], 366 | items1: [1.5, '2.5', 3.5], 367 | items2: [1, false, true] 368 | } 369 | 370 | const fixedData = fix(data, { 371 | name: 'string', 372 | lastName: 'string', 373 | age: 'number', 374 | isMarried: 'boolean', 375 | children: 'string[]', 376 | address: { 377 | street: 'string', 378 | city: 'string', 379 | state: 'string' 380 | }, 381 | // list of complex objects 382 | books: list({ 383 | of: { 384 | title: 'string', 385 | year: 'number', 386 | // we can combine multiple 'fixers' 387 | id: [text(), upper()] 388 | } 389 | }), 390 | items1: 'number[]', 391 | items2: 'boolean[]' 392 | }) 393 | 394 | expect(fixedData).toEqual({ 395 | name: 'Stephen', 396 | lastName: 'King', 397 | age: 75, // '74' has been replaced by 74 398 | isMarried: true, // 1 has been replaced by true 399 | children: ['Joe Hill', 'Owen King', 'Naomi King'], 400 | address: { 401 | street: '107-211 Parkview Ave, Bangor, ME 04401, USA', 402 | city: 'Portland', 403 | state: 'Oregon' 404 | }, 405 | books: [ 406 | { title: 'The Stand', year: 1978, id: 'ISBN-9781444720730' }, 407 | { title: "Salem's lot", year: 1975, id: 'ISBN-0385007515' } 408 | ], 409 | items1: [1.5, 2.5, 3.5], 410 | items2: [true, false, true] 411 | }) 412 | }) 413 | }) 414 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | "target": "es2016", 5 | "module": "commonjs", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true 10 | } 11 | } --------------------------------------------------------------------------------