├── .gitignore ├── .npmignore ├── .travis.yml ├── Gulpfile.coffee ├── Readme.md ├── build └── .gitignore ├── coffeelint.json ├── examples └── profile │ ├── index.coffee │ ├── index.html │ └── vendor.js ├── index.js ├── package.json ├── src ├── prop_schema.coffee └── react_props.coffee └── test ├── index.js ├── prop_check_spec.coffee ├── prop_sample_spec.coffee └── react_props.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | build/ 4 | dist/ 5 | *.sublime-project -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | examples/ 2 | test/ 3 | build/ 4 | docs/ 5 | *.sublime-project -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" -------------------------------------------------------------------------------- /Gulpfile.coffee: -------------------------------------------------------------------------------- 1 | # # Frontend Build Process 2 | 3 | gulp = require('gulp') 4 | gutil = require('gulp-util') 5 | plumber = require('gulp-plumber') 6 | source = require('vinyl-source-stream') 7 | 8 | # ## CONSTS 9 | 10 | PATHS = 11 | app: 12 | src: './examples/profile' 13 | entry: './examples/profile/index.coffee' 14 | dest: './build' 15 | name: 'le-app.js' 16 | libs: 17 | entry: './examples/profile/vendor.js' 18 | name: 'vendor.js' 19 | dest: './build' 20 | src: 21 | src: './src/**/*.coffee' 22 | dest: './dist' 23 | 24 | LIBS = require(PATHS.libs.entry) 25 | 26 | # ## Helpers 27 | 28 | ENV = name: process.env.NODE_ENV or 'development' 29 | ENV.compress = ENV.name is 'production' 30 | ENV.watch = ENV.name is 'development' 31 | ENV.debug = ENV.name isnt 'production' 32 | 33 | log = (task, level) -> 34 | return (_msg) -> 35 | if level is 'err' 36 | msg = gutil.colors.red(_msg) 37 | else 38 | msg = _msg 39 | 40 | gutil.log(gutil.colors.cyan("[#{task}]"), msg) 41 | 42 | # ## Processes 43 | 44 | ### 45 | # @method Compile Vendor Scripts 46 | # @description Create a file with required js libraries 47 | ### 48 | compileVendorScripts = ({name, dest, env, libs}) -> 49 | TASK = 'browserify:vendor' 50 | 51 | bundler = require('browserify') 52 | entries: libs.entry 53 | extensions: ['.js', '.coffee'] 54 | debug: env.debug 55 | 56 | bundler.transform require('coffeeify') 57 | 58 | if env.compress 59 | bundler.transform {global: true}, 'envify' 60 | bundler.transform {global: true}, 'uglifyify' 61 | 62 | libs.forEach (lib) -> 63 | bundler.require(lib) 64 | 65 | bundler.bundle() 66 | .on 'error', log(TASK, 'err') 67 | .pipe source(name) 68 | .pipe gulp.dest(dest) 69 | .pipe plumber() 70 | .on 'end', -> log(TASK)("recompiled") 71 | 72 | ### 73 | # @method Compile Application Scripts 74 | # @description Bundle application files 75 | ### 76 | compileScripts = ({src, name, dest, libs, env, watch}) -> 77 | TASK = "browserify:app#{if watch then ':watch' else ''}" 78 | 79 | bundler = require('browserify') 80 | cache: {}, packageCache: {}, fullPaths: true 81 | entries: src 82 | extensions: ['.js', '.json', '.coffee'] 83 | debug: env.debug 84 | 85 | if watch 86 | bundler = require('watchify')(bundler) 87 | 88 | bundler.transform require('coffeeify') 89 | 90 | if env.compress 91 | bundler.transform {global: true}, 'envify' 92 | bundler.transform {global: true}, 'uglifyify' 93 | 94 | libs.forEach (lib) -> 95 | bundler.external(lib) 96 | 97 | rebundle = -> 98 | bundler.bundle() 99 | .on 'error', log(TASK, 'err') 100 | .pipe source(name) 101 | .pipe plumber() 102 | .pipe gulp.dest(dest) 103 | .on 'end', -> log(TASK)("recompiled") 104 | 105 | bundler.on 'update', rebundle 106 | 107 | return rebundle() 108 | .on 'error', log(TASK, 'err') 109 | 110 | # ## Tasks 111 | 112 | gulp.task 'clean', -> 113 | gulp.src("#{PATHS.app.dest}/**/*", read: false) 114 | .pipe require('gulp-rimraf')() 115 | 116 | gulp.task 'copy:assets', -> 117 | gulp.src("#{PATHS.app.src}/**/*.html") 118 | .pipe gulp.dest PATHS.app.dest 119 | 120 | gulp.task 'copy:assets:watch', ['copy:assets'], -> 121 | gulp.watch("#{PATHS.app.src}/**/*.html", ['copy:assets']) 122 | 123 | gulp.task 'scripts:vendor', -> 124 | compileVendorScripts 125 | env: ENV 126 | name: PATHS.libs.name 127 | dest: PATHS.libs.dest 128 | libs: LIBS 129 | 130 | gulp.task 'scripts:app', -> 131 | compileScripts 132 | env: ENV 133 | src: PATHS.app.entry 134 | name: PATHS.app.name 135 | dest: PATHS.app.dest 136 | libs: LIBS 137 | watch: false 138 | 139 | gulp.task 'scripts:app:watch', -> 140 | compileScripts 141 | env: ENV 142 | src: PATHS.app.entry 143 | name: PATHS.app.name 144 | dest: PATHS.app.dest 145 | libs: LIBS 146 | watch: true 147 | 148 | gulp.task 'precompile', -> 149 | gulp.src(PATHS.src.src) 150 | .pipe require('gulp-coffee')() 151 | .pipe gulp.dest(PATHS.src.dest) 152 | 153 | gulp.task 'default', ['clean', 'copy:assets', 'scripts:vendor', 'scripts:app'] 154 | 155 | gulp.task 'watch', ['clean', 'copy:assets:watch', 'scripts:vendor', 'scripts:app:watch'] 156 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # React Prop Schema 2 | 3 | A library to validate a data structure and create fake content at the same time. Works great with React.js and can be used to replace `React.PropTypes`. 4 | 5 | [![Build Status](https://travis-ci.org/killercup/react-prop-schema.svg)](https://travis-ci.org/killercup/react-prop-schema) 6 | 7 | ## Installation / Usage 8 | 9 | This has been created to be used as a CommonJS module, using node, [browserify] or something similar. If you need a good starting point for using browserify, have a look at this project's `Gulpfile.coffee`. 10 | 11 | Even though this library is written in CoffeeScript, a JS version can be created using `npm run precompile`. Versions uploaded to _npm_ will contain the JS files in `dist`. 12 | 13 | Basically, you just need to run 14 | 15 | ```bash 16 | $ npm install --save react-prop-schema 17 | ``` 18 | 19 | and then you should be able to `require('react-prop-schema')` in your code. 20 | 21 | ### Use in Production 22 | 23 | If you use [browserify], you should compile your code with `envify` and `uglifyify` for production (so you can get rid of dead code). 24 | 25 | Please note that if you set `NODE_ENV=production`, this: 26 | 27 | - prevents loading and embedding [faker.js], saving you about 140kB of bandwidth 28 | - mutes all validation warnings (but does not remove validation code) 29 | 30 | [browserify]: http://browserify.org/ 31 | 32 | ## What's so cool about this? 33 | 34 | Let's say you have a data structure, represented by the following JSON: 35 | 36 | ```json 37 | { 38 | "name": "Pascal", 39 | "level": 42 40 | } 41 | ``` 42 | 43 | Everything is fine, but you are a skeptic, so you want to validate this data. Let's assume you have a tool that can validate this data using the following schema description: 44 | 45 | ```json 46 | { 47 | "name": {"type": "string"}, 48 | "level": {"type": "number", "max": 1337} 49 | } 50 | ``` 51 | 52 | Great. There are tools for that. Nothing new. But then you want to write some tests. What data are you gonna use? Are you going write some fixtures yourself? Create a mock object using some other library? 53 | 54 | That's what this experiment is all about. I wanted to create such a validation tool which can use the same structure to create random fake sample data (better known as samples of fake-random demo-data). 55 | 56 | And: Good news, everyone! It works*! The source lives in `src/`. 57 | 58 | ```js 59 | var ps = require('./prop_schema') 60 | ps.sample({name: {type: "string"}, level: {type: "number", max: 1337}}) 61 | // => { name: 'lorem lor', level: 1278 } 62 | ``` 63 | 64 | * Sometimes, for some cases. 65 | 66 | ## What does it have to do with React.js? 67 | 68 | I'm so glad you asked. You see, React.js has this little, often overlooked feature called [Prop Validation]. It's used during development to validate the data in your component's properties. By default, React has some nice helper methods that should get you started, e.g. `React.PropTypes.string.isRequired`. 69 | 70 | [Prop Validation]: http://facebook.github.io/react/docs/reusable-components.html#prop-validation 71 | 72 | But the thing is, you can easily create your own prop validators -- they are just functions that get the props and output some warning to the console. 73 | 74 | So, of course I had to take the glorious library described above and use it to create a new validation module. I'll call it `ReactProps` for now. Then, to spice things up a bit, let's give each validator an additional method called `fake`. 75 | 76 | You can access a React component's original `propTypes` using `component.originalSpec.propTypes` (at least in React 0.10, this is probably a private API). Using this, it is trivial to call each propType's `.fake()` method and generate a new data set for your test component. 77 | 78 | ### Complete Example 79 | 80 | See also the CoffeeScript source of [the complete profile example](https://github.com/killercup/react-prop-schema/blob/master/examples/profile/index.coffee). 81 | 82 | ```js 83 | var React = require('react'); 84 | var ReactProps = require('./src/react_props'); 85 | 86 | var Person = React.createClass({ 87 | // This is the important bit 88 | propTypes: { 89 | name: ReactProps.require({ 90 | first: {type: 'string', min: 1, max: 42, pattern: 'Name.firstName'}, 91 | last: {type: 'string', min: 1, max: 42, pattern: 'Name.lastName'} 92 | }), 93 | age: ReactProps.require({type: 'number', min: 21, max: 42}), 94 | }, 95 | 96 | render: function () { 97 | return React.DOM.article({key: 0, className: 'person'}, [ 98 | React.DOM.h1({key: 0, className: 'name'}, [ 99 | "Dr. ", this.props.name.first, " ", this.props.name.last 100 | ]), 101 | React.DOM.p({key: 1, className: 'age'}, ["Age: ", this.props.age]) 102 | ]); 103 | } 104 | }); 105 | 106 | var fakePerson = ReactProps.fake(Person, {key: 0}, []); 107 | React.renderComponent(fakePerson, document.getElementById('container')); 108 | ``` 109 | 110 | Also note that elements with `type: string` support a special `pattern` property which can be set to a valid [faker.js] method (e.g. `'Internet.email'`), which will then be used to generate the fake data. 111 | 112 | ### Precise Validation Error 113 | 114 | Imaging we use the example `Person` component from above with the following props: 115 | 116 | ```js 117 | Person({key: 0, age: -1, name: {first: 42}}, []); 118 | ``` 119 | 120 | It is obvious, that neither `age` nor `name` are valid. But what exactly is wrong with them? Here is the console output you get in development mode: 121 | 122 | > Invalid prop `age` supplied to `Person`: ["-1 should at least be 21"] [CheckError] 123 | 124 | > Invalid prop `name` supplied to `Person`: ["42 is not a string.", "last is required but not in [object Object]"] [Array[1], CheckError] 125 | 126 | (The arrays at the end of the lines contain the actual, nested JS Errors that can be inspected in developer tools like Chrome's inspector and that contain further information, like a reference to the value that failed to validate.) 127 | 128 | ## Getting this Experiment Started 129 | 130 | Uses `gulp` and `browserify`, but you don't have to concern yourself with that. If you enjoy fiddling with that kind of stuff, though, I hope you enjoy reading it. I spend at least an hour to get it working (generating two JS files, one for my JS and one for external libraries). 131 | 132 | ```sh 133 | $ npm install 134 | $ npm run compile # or `build` to skip uglify and add sourcemaps 135 | $ open build/index.html # works on os x at least 136 | ``` 137 | 138 | Now you should see a boring page that displays some random data on each reload using the not-so-boring idea described above. 139 | 140 | You can use `npm run watch` to automatically recompile stuff when you change some files. 141 | 142 | ## Run the Tests 143 | 144 | I wrote some tests to determine that the validator/faker works as expected. 145 | 146 | Believe it or not, I even wrote an amazing new test utility for this. I'll probably call it 'try-catch-foreach' or something and will replace it with _mocha_ later on. 147 | 148 | Run the tests with: 149 | 150 | ```sh 151 | $ npm test 152 | ``` 153 | 154 | ## Code Style 155 | 156 | Uses _CoffeeScript_ and _lodash_, because then I can get stuff done wicked fast. 157 | 158 | ## Further Ideas 159 | 160 | - Integrate more sophisticated random data using [faker.js] and a pattern attribute 161 | - See all the TODOs in the code. 162 | 163 | ## Prior Art/Inspiration 164 | 165 | - [faker.js] 166 | - [genie] 167 | - React's [Prop Validation] 168 | 169 | [faker.js]: https://github.com/FotoVerite/Faker.js 170 | [genie]: https://github.com/Trimeego/genie 171 | 172 | ## License 173 | 174 | MIT 175 | -------------------------------------------------------------------------------- /build/.gitignore: -------------------------------------------------------------------------------- 1 | * -------------------------------------------------------------------------------- /coffeelint.json: -------------------------------------------------------------------------------- 1 | { 2 | "coffeescript_error": { 3 | "level": "error" 4 | }, 5 | "arrow_spacing": { 6 | "name": "arrow_spacing", 7 | "level": "ignore" 8 | }, 9 | "no_tabs": { 10 | "name": "no_tabs", 11 | "level": "error" 12 | }, 13 | "no_trailing_whitespace": { 14 | "name": "no_trailing_whitespace", 15 | "level": "error", 16 | "allowed_in_comments": false, 17 | "allowed_in_empty_lines": true 18 | }, 19 | "max_line_length": { 20 | "name": "max_line_length", 21 | "value": 80, 22 | "level": "warn", 23 | "limitComments": false 24 | }, 25 | "line_endings": { 26 | "name": "line_endings", 27 | "level": "warn", 28 | "value": "unix" 29 | }, 30 | "no_trailing_semicolons": { 31 | "name": "no_trailing_semicolons", 32 | "level": "error" 33 | }, 34 | "indentation": { 35 | "name": "indentation", 36 | "value": 2, 37 | "level": "error" 38 | }, 39 | "camel_case_classes": { 40 | "name": "camel_case_classes", 41 | "level": "error" 42 | }, 43 | "colon_assignment_spacing": { 44 | "name": "colon_assignment_spacing", 45 | "level": "ignore", 46 | "spacing": { 47 | "left": 0, 48 | "right": 0 49 | } 50 | }, 51 | "no_implicit_braces": { 52 | "name": "no_implicit_braces", 53 | "level": "ignore", 54 | "strict": true 55 | }, 56 | "no_plusplus": { 57 | "name": "no_plusplus", 58 | "level": "warn" 59 | }, 60 | "no_throwing_strings": { 61 | "name": "no_throwing_strings", 62 | "level": "error" 63 | }, 64 | "no_backticks": { 65 | "name": "no_backticks", 66 | "level": "error" 67 | }, 68 | "no_implicit_parens": { 69 | "name": "no_implicit_parens", 70 | "level": "ignore" 71 | }, 72 | "no_empty_param_list": { 73 | "name": "no_empty_param_list", 74 | "level": "warn" 75 | }, 76 | "no_stand_alone_at": { 77 | "name": "no_stand_alone_at", 78 | "level": "warn" 79 | }, 80 | "space_operators": { 81 | "name": "space_operators", 82 | "level": "ignore" 83 | }, 84 | "duplicate_key": { 85 | "name": "duplicate_key", 86 | "level": "error" 87 | }, 88 | "empty_constructor_needs_parens": { 89 | "name": "empty_constructor_needs_parens", 90 | "level": "warn" 91 | }, 92 | "cyclomatic_complexity": { 93 | "name": "cyclomatic_complexity", 94 | "value": 10, 95 | "level": "warn" 96 | }, 97 | "newlines_after_classes": { 98 | "name": "newlines_after_classes", 99 | "value": 3, 100 | "level": "warn" 101 | }, 102 | "no_unnecessary_fat_arrows": { 103 | "name": "no_unnecessary_fat_arrows", 104 | "level": "warn" 105 | }, 106 | "missing_fat_arrows": { 107 | "name": "missing_fat_arrows", 108 | "level": "ignore" 109 | }, 110 | "non_empty_constructor_needs_parens": { 111 | "name": "non_empty_constructor_needs_parens", 112 | "level": "ignore" 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /examples/profile/index.coffee: -------------------------------------------------------------------------------- 1 | ### 2 | # # React.js With Fake Properties 3 | # 4 | # Create a Person component with specified prop types and render it with 5 | # automatically generated fake data. 6 | ### 7 | 8 | React = require('react') 9 | {article, div, h1, p, ul, li, button} = React.DOM 10 | 11 | # Load the magic 12 | ReactProps = require('../../src/react_props') 13 | 14 | # Create an example component 15 | Person = React.createClass 16 | displayName: 'Person' 17 | 18 | # This is the important bit: Each prop key is described using ReactProps 19 | propTypes: 20 | name: ReactProps.require 21 | dr: {type: 'boolean'} 22 | first: {type: 'string', min: 1, max: 21, pattern: 'name.firstName'} 23 | last: {type: 'string', min: 1, max: 42, pattern: 'name.lastName'} 24 | bio: ReactProps.require 25 | type: 'string', min: 20, max: 140 26 | age: ReactProps.require 27 | type: 'number', min: 21, max: 42 28 | updates: ReactProps.require 29 | type: 'array', min: 1, max: 5, 30 | schema: 31 | body: {type: 'string', min: 1, max: 21} 32 | created: {type: 'date'} 33 | 34 | render: -> 35 | (article {key: 0, className: 'person panel panel-default'}, [ 36 | (div {key: 0, className: 'name panel-heading'}, [ 37 | (h1 {key: 0, className: 'panel-title'}, [ 38 | (if @props.name.dr then "Dr. " else "") 39 | @props.name.first, " ", @props.name.last 40 | ]) 41 | ]) 42 | (div {key: 1, className: 'panel-body'}, [ 43 | (p {key: 1, className: 'age'}, ["Age: ", @props.age]) 44 | (p {key: 2, className: 'bio'}, [@props.bio]) 45 | ]) 46 | (ul {key: 3, className: 'updates list-group'}, 47 | @props.updates.map (update, index) -> 48 | (li {key: index, className: 'update list-group-item'}, [ 49 | update.body 50 | " (#{update.created.toDateString()})" 51 | ]) 52 | ) 53 | ]) 54 | 55 | generatePeople = (n=3) -> 56 | [1..n].map (i) -> 57 | (div {key: i, className: 'col-sm-4'}, 58 | ReactProps.fake(Person, {key: 0}) 59 | ) 60 | 61 | # Show a few random people next to each other 62 | People = React.createClass 63 | displayName: 'People' 64 | 65 | getDefaultProps: -> 66 | list: generatePeople() 67 | 68 | render: -> 69 | shuffle = => 70 | @setProps list: generatePeople() 71 | 72 | (div {key: 0}, [ 73 | (div {key: 0, className: 'row'}, [ 74 | (h1 {key: 0}, [ 75 | "The People of Randomia " 76 | (button {key: 1, className: 'btn btn-primary', onClick: shuffle}, 77 | "Shuffle" 78 | ) 79 | ]) 80 | ]) 81 | (div {key: 1, className: 'people row'}, @props.list) 82 | ]) 83 | 84 | # Render stuff to the DOM 85 | React.renderComponent( 86 | (People {key: 0}, []) 87 | document.getElementById('container') 88 | ) 89 | -------------------------------------------------------------------------------- /examples/profile/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Fake Props! 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /examples/profile/vendor.js: -------------------------------------------------------------------------------- 1 | // browserify will take all these modules and put them in a `vendor.js` file. 2 | 3 | module.exports = [ 4 | 'lodash', 5 | 'react' 6 | ]; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var propSchema = require('./dist/prop_schema'); 2 | var reactProps = require('./dist/react_props'); 3 | 4 | module.exports = { 5 | // Validator 6 | check: propSchema.check, 7 | sample: propSchema.sample, 8 | 9 | // React.js PropTypes 10 | require: reactProps.require, 11 | optional: reactProps.optional, 12 | fakeProps: reactProps.fakeProps, 13 | fake: reactProps.fake 14 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-prop-schema", 3 | "version": "1.0.1", 4 | "description": "A library to validate a data structure and create fake content at the same time. Works great with React.js.", 5 | "main": "index.js", 6 | "browser": "index.js", 7 | "scripts": { 8 | "lint": "coffeelint src/*.coffee test/*.coffee", 9 | "test": "npm run lint && NODE_ENV=test node test/index.js", 10 | "build": "gulp --require coffee-script/register", 11 | "compile": "NODE_ENV=production gulp --require coffee-script/register", 12 | "watch": "gulp watch --require coffee-script/register", 13 | "prepublish": "gulp precompile --require coffee-script/register", 14 | "docs": "grock" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/killercup/react-prop-schema" 19 | }, 20 | "keywords": [ 21 | "react.js", 22 | "validation", 23 | "faker" 24 | ], 25 | "author": "Pascal Hertleif (http://pascalhertleif.de/)", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/killercup/react-prop-schema/issues" 29 | }, 30 | "homepage": "https://github.com/killercup/react-prop-schema", 31 | "dependencies": { 32 | "envify": "^3.0.0", 33 | "lodash": "^2.4.1", 34 | "faker": "^2.0.1", 35 | "react": ">=0.11.2" 36 | }, 37 | "devDependencies": { 38 | "browserify": "^6.1.0", 39 | "coffeeify": "^0.7.0", 40 | "coffeelint": "^1.6.0", 41 | "coffee-script": "^1.8.0", 42 | "envify": "^3.0.0", 43 | "gulp": "^3.8.8", 44 | "gulp-coffee": "^2.2.0", 45 | "gulp-plumber": "^0.6.6", 46 | "gulp-rimraf": "^0.1.1", 47 | "gulp-util": "^3.0.1", 48 | "uglifyify": "^2.5.0", 49 | "vinyl-source-stream": "^1.0.0", 50 | "watchify": "^2.0.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/prop_schema.coffee: -------------------------------------------------------------------------------- 1 | ###! 2 | # # Schema-based Porperty Validation 3 | # 4 | # @author Pascal Hertleif 5 | # @license MIT 6 | ### 7 | 8 | l = require('lodash') 9 | 10 | if process.env.NODE_ENV is 'production' 11 | f = Lorem: sentence: -> "Lorem ipsum dolor sit amet." 12 | else 13 | f = require('faker') 14 | 15 | class CheckError extends Error 16 | constructor: (vals) -> 17 | @message = vals 18 | @stack = super.stack 19 | super 20 | 21 | TYPES = 22 | 'object': 23 | check: (val, schema) -> 24 | unless l.isObject(val) 25 | return [new CheckError [val, "is not an object"]] 26 | 27 | errs = [] 28 | 29 | Object.keys(schema).forEach (key) -> 30 | subschema = schema[key] 31 | subval = val[key] 32 | 33 | if subval 34 | es = check(val[key], schema[key]) 35 | errs.push(es) if es.length 36 | else 37 | if subschema.required 38 | errs.push new CheckError [key, "is required but not in", val] 39 | return 40 | 41 | return errs 42 | sample: (schema) -> 43 | # TODO add support for `required` (omit optional fields randomly) 44 | l.mapValues schema, (val) -> sample(val) 45 | 46 | 'array': 47 | check: (val, {min, max, schema}) -> 48 | return [new CheckError [val, "is not an array."]] unless l.isArray(val) 49 | 50 | errs = [] 51 | if l.isNumber(min) and val.length < min 52 | errs.push new CheckError [val, "of length", val.length, \ 53 | "should at least have", min, "entries"] 54 | if l.isNumber(max) and val.length > max 55 | errs.push new CheckError [val, "of length", val.length, \ 56 | "should at most have", max, "entries"] 57 | 58 | if schema 59 | entriesErrs = l.compact l.map val, (entry) -> 60 | es = check(entry, schema) 61 | return es if es.length 62 | 63 | if entriesErrs.length 64 | errs.push entriesErrs 65 | 66 | return errs 67 | 68 | sample: ({min, max, schema}) -> 69 | _min = min or 0 70 | _max = max or 5 71 | length = l.sample([_min.._max]) 72 | return [1..length].map -> 73 | sample(schema) 74 | 75 | 'boolean': 76 | check: (val) -> 77 | if l.isBoolean(val) 78 | [] 79 | else 80 | [new Error [val, "is not a boolean"]] 81 | sample: -> l.sample [true, false] 82 | 83 | 'date': 84 | check: (val, {min, max}) -> 85 | return [new CheckError [val, "is not a date."]] unless l.isDate(val) 86 | errs = [] 87 | # TODO: add min/max 88 | return errs 89 | 90 | sample: ({min, max, pattern}) -> 91 | rightNow = new Date() 92 | 93 | if min or max 94 | f.date.between(min or 0, max or +rightNow) 95 | else 96 | return new Date Math.random() * +rightNow 97 | 98 | 'function': 99 | check: (val) -> 100 | if l.isFunction(val) 101 | [] 102 | else 103 | [new Error [val, "is not a function"]] 104 | sample: -> -> 105 | 106 | 'null': 107 | check: (val) -> 108 | if l.isNull(val) 109 | [] 110 | else 111 | [new Error [val, "is not null"]] 112 | sample: -> null 113 | 114 | 'number': 115 | check: (val, {min, max}) -> 116 | unless l.isNumber(val) 117 | return [new CheckError("#{val} is not a number.")] 118 | 119 | errs = [] 120 | if l.isNumber(min) and val < min 121 | errs.push new CheckError [val, "should at least be", min] 122 | if l.isNumber(max) and val > max 123 | errs.push new CheckError [val, "should at most be", max] 124 | 125 | return errs 126 | 127 | sample: ({min, max, float}) -> 128 | n = Math.max (min or 0), (Math.random() * (max or 100)) 129 | if not float 130 | Math.ceil n 131 | else 132 | n 133 | 134 | 'regexp': 135 | check: (val) -> 136 | if l.isRegExp(val) 137 | [] 138 | else 139 | [new Error [val, "is not a RegExp"]] 140 | sample: -> /^42$/ 141 | 142 | 'string': 143 | check: (val, {min, max}) -> 144 | unless l.isString(val) 145 | return [new CheckError [val, "is not a string."]] 146 | errs = [] 147 | _len = val.length 148 | if l.isNumber(min) and l.isNumber(max) and min >= max 149 | errs.push new CheckError ["Prop min is larger than prop max"] 150 | if l.isNumber(min) and _len < min 151 | errs.push new CheckError [val, "should at least be", min, "characters"] 152 | if l.isNumber(max) and _len > max 153 | errs.push new CheckError [val, "should at most be", max, "characters"] 154 | return errs 155 | 156 | sample: ({pattern, max, min}) -> 157 | # Expect `pattern` to be something like `address.zipCode` or 158 | # `internet.domainName`. 159 | if l.isString(pattern) and pattern.split('.').length is 2 160 | contentType = pattern.split('.') 161 | fakeCategory = f[contentType[0]] 162 | contentFaker = fakeCategory?[contentType[1]] 163 | 164 | if l.isFunction(contentFaker) 165 | # Faker.js uses `this.otherFaker` a lot. 166 | contentFaker = contentFaker.bind(fakeCategory) 167 | else 168 | error = new Error("Can't fake string pattern #{pattern}") 169 | console?.warn?(error.stack or error) 170 | 171 | # If Faker.js does not offer this content type, fall back to Lorem Ipsum. 172 | unless l.isFunction(contentFaker) 173 | contentFaker = f.lorem.sentence.bind(f.lorem) 174 | 175 | lorem = contentFaker() 176 | 177 | if l.isNumber(min) 178 | while min > lorem.length 179 | lorem += contentFaker() 180 | 181 | min = if l.isNumber(min) then min else 0 182 | if l.isNumber(max) and max >= min 183 | length = l.sample l.range(min, max) 184 | return lorem[0..length] 185 | else 186 | return lorem 187 | 188 | 'undefined': 189 | check: (val) -> 190 | [] 191 | # TODO: add stuff if necessary 192 | # return [] if l.isUndefined(val) 193 | # return [new Error [val, "is not undefined"]] 194 | sample: -> 195 | 196 | ### 197 | # @method Validate Data Structure 198 | # @param {Any} val The value to validate 199 | # @param {Object} schema The validation schema 200 | # @return {Array} A list of errors, possible empty. 201 | ### 202 | check = (val, schema) -> 203 | if l.isUndefined(val) 204 | return [new CheckError ["Value is undefined", JSON.stringify schema]] 205 | 206 | unless l.isObject(schema) 207 | return [new CheckError [schema, "is not a valid schema"]] 208 | 209 | if schema.type? 210 | _type = schema.type.toLowerCase() 211 | else 212 | # assume this is a (sub-)schema 213 | _type = 'object' 214 | 215 | type = TYPES[_type] 216 | unless type? 217 | return [new CheckError ["Can't check unknown type", type]] 218 | 219 | checker = type['check'] 220 | unless l.isFunction(checker) 221 | return [new CheckError ["No check method for type", type]] 222 | 223 | errs = checker(val, schema) 224 | # console.log "checking", val, "using checker", "for", _type 225 | unless l.isArray(errs) 226 | return [new CheckError [errs, "is not an array result checking", _type]] 227 | 228 | return errs 229 | 230 | ### 231 | # @method Generate Sample Data Structure 232 | # @param {Object} schema The data struture 233 | # @return {Any} Sample/fake/mock data 234 | ### 235 | sample = (schema) -> 236 | _type = schema.type or 'object' 237 | 238 | type = TYPES[_type] 239 | unless type? 240 | throw new CheckError(["Can't create a sample for unknown type", _type]) 241 | 242 | sampler = type['sample'] 243 | unless l.isFunction(sampler) 244 | return [new CheckError ["No sample method for type", _type]] 245 | 246 | # console.log "sampling", schema, "using sampler for", _type 247 | 248 | return sampler(schema) 249 | 250 | module.exports = 251 | check: check 252 | sample: sample 253 | -------------------------------------------------------------------------------- /src/react_props.coffee: -------------------------------------------------------------------------------- 1 | ###! 2 | # # React.js Compatible Data Structure Validation 3 | # 4 | # @author Pascal Hertleif 5 | # @license MIT 6 | ### 7 | 8 | l = require('lodash') 9 | PropSchema = require('./prop_schema') 10 | 11 | warn = -> 12 | if process.env.NODE_ENV isnt 'production' and console?.warn? 13 | warn = (args...) -> 14 | console.warn.apply(console, args) 15 | 16 | ### 17 | # @method Create Prop Checks 18 | # 19 | # The resulting validator function will be called with these arguments 20 | # (cf. [1]): props, propName, componentName, location. 21 | # 22 | # [1]: https://github.com/facebook/react/blob/e10d10e31e0a1dfba16880f8f065de8329c896dc/src/core/ReactPropTypes.js#L262 23 | ### 24 | createPropChecks = (required, schema) -> 25 | _schema = schema 26 | validator = (val) -> PropSchema.check(val, schema) 27 | 28 | checker = (props, propName, componentName, location) -> 29 | value = props[propName] 30 | if not value? 31 | if required 32 | return new Error( 33 | "Required prop `#{propName}` was not specified in " + 34 | "`#{componentName or 'anonymous component'}`." 35 | ) 36 | else 37 | errors = validator(value) 38 | if errors.length 39 | return new Error([ 40 | "Invalid prop `#{propName}` supplied to " + 41 | "`#{componentName or 'anonymous component'}` at #{location}:", 42 | (l.flatten(errors).map (e) -> e.message?.join?(' ') or e) 43 | ]) 44 | 45 | checker.fake = -> PropSchema.sample(schema) 46 | 47 | return checker 48 | 49 | ### 50 | # @method Create Fake Props for a Component 51 | ### 52 | fakeProps = (_component) -> 53 | component = _component.originalSpec or _component 54 | props = component?.propTypes 55 | 56 | if not props 57 | warn new Error "Can't retrieve prop types for component " + 58 | "#{component.displayName or component}." 59 | return 60 | 61 | l.mapValues props, (type) -> 62 | type?.fake?() 63 | 64 | module.exports = 65 | require: createPropChecks.bind(null, true) 66 | optional: createPropChecks.bind(null, false) 67 | fakeProps: fakeProps 68 | fake: (component, props={}, childs=[]) -> 69 | component l.extend({}, props, fakeProps(component)), childs 70 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | require('coffee-script/register'); 2 | 3 | require('./prop_check_spec'); 4 | require('./prop_sample_spec'); 5 | require('./react_props'); 6 | -------------------------------------------------------------------------------- /test/prop_check_spec.coffee: -------------------------------------------------------------------------------- 1 | assert = require('assert') 2 | l = require('lodash') 3 | 4 | {check, sample} = require('../src/prop_schema') 5 | 6 | tests = 7 | boolean1: -> 8 | es = check false, {type: 'boolean'} 9 | assert (es.length is 0), "Should validate boolean. #{es}" 10 | boolean2: -> 11 | es = check true, {type: 'boolean'} 12 | assert (es.length is 0), "Should validate boolean. #{es}" 13 | booleanInvalid: -> 14 | es = check "false", {type: 'boolean', min: 21, max: 42} 15 | assert (es.length > 0), "Should notice when boolean is incorrect." 16 | null: -> 17 | es = check null, {type: 'null'} 18 | assert (es.length is 0), "Should validate null. #{es}" 19 | nullInvalid: -> 20 | es = check "null", {type: 'null', min: 21, max: 42} 21 | assert (es.length > 0), "Should notice when null is incorrect." 22 | number: -> 23 | es = check 42, {type: 'number'} 24 | assert (es.length is 0), "Should validate number. #{es}" 25 | numberMinMax: -> 26 | es = check 42, {type: 'number', min: 21, max: 42} 27 | assert (es.length is 0), "Should validate number range. #{es}" 28 | numberMinMaxInvalid1: -> 29 | es = check 0, {type: 'number', min: 21, max: 42} 30 | assert (es.length > 0), "Should notice when number is too small." 31 | numberMinMaxInvalid2: -> 32 | es = check 666, {type: 'number', min: 21, max: 42} 33 | assert (es.length > 0), "Should notice when number is too large." 34 | string: -> 35 | es = check "hi there", {type: 'string'} 36 | assert (es.length is 0), "Should validate strings. #{es}" 37 | stringMinMax: -> 38 | es = check "hi there", {type: 'string', min: 5, max: 10} 39 | assert (es.length is 0), "Should validate string length." 40 | stringMinMaxInvalid1: -> 41 | es = check "hi", {type: 'string', min: 5, max: 10} 42 | assert (es.length > 0), "Should notice when string is too short." 43 | stringMinMaxInvalid2: -> 44 | es = check "hi there my dear friend", {type: 'string', min: 5, max: 10} 45 | assert (es.length > 0), "Should notice when string is too long." 46 | stringMinMaxWrong: -> 47 | es = check "hi there my dear friend", {type: 'string', min: 42, max: 21} 48 | assert (es.length > 0), "Should notice when string is too long." 49 | invalidString: -> 50 | es = check 42, {type: "string"} 51 | assert (es.length > 0), "Should notice invalid string." 52 | array: -> 53 | schema = {type: 'array', min: 1, max: 5, schema: {a: {type: 'number'}}} 54 | es = check [{a: 1}, {a: 2}], schema 55 | assert (es.length is 0), "Should validate arrays. #{es}" 56 | arrayItemType: -> 57 | schema = {type: 'array', min: 1, max: 5, schema: {a: {type: 'text'}}} 58 | es = check [{a: 1}, {a: 2}], schema 59 | assert (es.length > 0), "Should notice invalid array items." 60 | arrayItemCount: -> 61 | schema = {type: 'array', min: 1, max: 2, schema: {a: {type: 'text'}}} 62 | es = check [{a: '1'}, {a: '2'}, {a: '2'}], schema 63 | assert (es.length > 0), "Should notice invalid number of array items." 64 | object: -> 65 | es = check { 66 | name: 'Pascal' 67 | level: 42 68 | }, { 69 | name: {type: 'string'} 70 | level: {type: 'number'} 71 | } 72 | 73 | assert (es.length is 0), "Should validate nested object." 74 | objectInvalid: -> 75 | es = check { 76 | name: 'Pascal' 77 | level: ['1337'] 78 | }, { 79 | name: {type: 'string'} 80 | level: {type: 'number'} 81 | } 82 | 83 | assert (es.length > 0), "Should notice invalid object." 84 | objectObject: -> 85 | es = check { 86 | name: 87 | first: 'Pascal' 88 | last: 'Hertleif' 89 | }, { 90 | name: 91 | first: {type: 'string'} 92 | last: {type: 'string'} 93 | } 94 | 95 | assert (es.length is 0), "Should validate nested object. #{es}" 96 | objectObjectInvalid: -> 97 | es = check { 98 | name: 99 | first: 'Pascal' 100 | last: -1 101 | }, { 102 | name: 103 | first: {type: 'string'} 104 | last: {type: 'string'} 105 | } 106 | 107 | assert (es.length > 0), "Should notice invalid nested object" 108 | objectArray: -> 109 | es = check { 110 | name: 'Pascal' 111 | awards: [ 112 | {name: 'a'} 113 | {name: 'b'} 114 | ] 115 | }, { 116 | name: {type: 'string'} 117 | awards: 118 | type: 'array' 119 | min: 1 120 | max: 5 121 | schema: 122 | name: {type: 'string'} 123 | } 124 | assert(es.length is 0, "Should validate nested array. #{es}") 125 | objectArrayInvalidCount: -> 126 | es = check { 127 | name: 'Pascal' 128 | awards: [ 129 | {name: 'a'} 130 | {name: 'b'} 131 | ] 132 | }, { 133 | name: {type: 'string'} 134 | awards: 135 | type: 'array' 136 | min: 1 137 | max: 1 138 | schema: 139 | name: {type: 'string'} 140 | } 141 | assert(es.length > 0, "Should notice invalid nested array.") 142 | missingRequired: -> 143 | es = check {name: 'Pascal', level: 42}, { 144 | name: {type: 'string', required: true} 145 | level: {type: 'number'} 146 | job: {type: 'string', required: true} 147 | } 148 | assert(es.length > 0, "Should notice missing required field.") 149 | 150 | l.each tests, (test, name) -> 151 | try 152 | test() 153 | catch e 154 | console.log "Test #{name} failed.", (e.message or e) 155 | throw e 156 | 157 | console.log "Prop check: Successfully ran #{Object.keys(tests).length} tests." -------------------------------------------------------------------------------- /test/prop_sample_spec.coffee: -------------------------------------------------------------------------------- 1 | assert = require('assert') 2 | l = require('lodash') 3 | 4 | {check, sample} = require('../src/prop_schema') 5 | 6 | testSample = (schema) -> -> 7 | s = sample(schema) 8 | es = check s, schema 9 | errs = l.flatten(es).map (e) -> e.message?.join?(' ') or e 10 | assert (es.length is 0), errs 11 | 12 | tests = 13 | number: testSample {type: 'number'} 14 | numberMinMax: testSample {type: 'number', min: 21, max: 42} 15 | string: testSample {type: 'string'} 16 | stringMin: testSample {type: 'string', min: 20} 17 | stringMax: testSample {type: 'string', max: 20} 18 | stringMinMax: testSample {type: 'string', min: 21, max: 42} 19 | stringAddress: -> 20 | s = sample type: 'string', pattern: 'internet.email' 21 | assert (l.contains s, '@'), "No @, no email." 22 | array: testSample 23 | type: 'array', 24 | min: 1, max: 1, 25 | schema: 26 | a: {type: 'number'} 27 | object: testSample { 28 | name: {type: 'string'} 29 | awesomeness: {type: 'number'} 30 | } 31 | objectObject: testSample { 32 | name: 33 | first: {type: 'string'} 34 | last: {type: 'string'} 35 | } 36 | objectArray: testSample { 37 | name: {type: 'string'} 38 | awards: 39 | type: 'array' 40 | min: 1 41 | max: 5 42 | schema: 43 | name: {type: 'string'} 44 | } 45 | 46 | l.each tests, (test, name) -> 47 | try 48 | test() 49 | catch e 50 | console.log "Test #{name} failed.", (e.message or e) 51 | throw e 52 | 53 | console.log "Prop sample: Successfully ran #{Object.keys(tests).length} tests." 54 | -------------------------------------------------------------------------------- /test/react_props.coffee: -------------------------------------------------------------------------------- 1 | assert = require('assert') 2 | l = require('lodash') 3 | R = require('react') 4 | 5 | Props = require('../src/react_props') 6 | 7 | tests = 8 | validateRequiredProp: -> 9 | check = Props.require({type: 'boolean'}) 10 | 11 | val = check({a: false, b: 42}, 'c', 'demo', '') 12 | assert (val instanceof Error), "Should notice missing required prop" 13 | 14 | val = check({a: false, b: 42}, 'b', 'demo', '') 15 | assert (val instanceof Error), "Should notice invalid required prop" 16 | 17 | val = check({a: false, b: 42}, 'a', 'demo', '') 18 | assert (val is undefined), "Should validate required prop" 19 | 20 | validateOptionalProp: -> 21 | check = Props.optional({type: 'boolean'}) 22 | 23 | val = check({a: false, b: 42}, 'c', 'demo', '') 24 | assert (val is undefined), "Should ignore missing optional prop" 25 | 26 | val = check({a: false, b: 42}, 'b', 'demo', '') 27 | assert (val instanceof Error), "Should notice invalid optional prop" 28 | 29 | val = check({a: false, b: 42}, 'a', 'demo', '') 30 | assert (val is undefined), "Should validate optional prop" 31 | 32 | l.each tests, (test, name) -> 33 | try 34 | test() 35 | catch e 36 | console.log "Test #{name} failed.", (e.message or e) 37 | throw e 38 | 39 | console.log "Prop check: Successfully ran #{Object.keys(tests).length} tests." 40 | --------------------------------------------------------------------------------