├── .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 | }
--------------------------------------------------------------------------------