├── .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 | [](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 |
--------------------------------------------------------------------------------