├── .eslintrc ├── .github └── dependabot.yml ├── .gitignore ├── .mocharc.json ├── .npmignore ├── .travis.yml ├── LICENSE.md ├── MATCHERS.md ├── Makefile ├── README.md ├── changelog.md ├── lib ├── compile.js ├── factory.js ├── index.js ├── matcher.js ├── matchers │ ├── array.js │ ├── boolean.js │ ├── duration.js │ ├── email.js │ ├── enum.js │ ├── func.js │ ├── hashmap.js │ ├── integer.js │ ├── ip.js │ ├── isoDate.js │ ├── number.js │ ├── object.js │ ├── objectWithOnly.js │ ├── oneOf.js │ ├── optional.js │ ├── regex.js │ ├── string.js │ ├── url.js │ ├── uuid.js │ └── value.js ├── strummer.js └── utils.js ├── package-lock.json ├── package.json ├── performance-test └── performance.spec.js └── test ├── compile.spec.js ├── matcher.spec.js ├── matchers ├── array.spec.js ├── boolean.spec.js ├── duration.spec.js ├── email.spec.js ├── enum.spec.js ├── func.spec.js ├── hashmap.spec.js ├── integer.spec.js ├── ip.spec.js ├── isoDate.spec.js ├── number.spec.js ├── object.spec.js ├── objectWithOnly.spec.js ├── oneof.spec.js ├── optional.spec.js ├── regex.spec.js ├── string.spec.js ├── url.spec.js ├── uuid.spec.js └── value.spec.js ├── non-constructor-api.spec.js ├── spec-helpers.js ├── strummer.spec.js ├── syntactic-sugar.spec.js └── util.spec.js /.eslintrc: -------------------------------------------------------------------------------- 1 | env: 2 | node: true 3 | mocha: true 4 | 5 | rules: 6 | comma-style: [2, "last"] 7 | default-case: 2 8 | func-style: [2, "declaration"] 9 | no-floating-decimal: 2 10 | no-nested-ternary: 2 11 | no-undefined: 2 12 | radix: 2 13 | keyword-spacing: [2, { "before": true, "after": true}] 14 | space-before-blocks: 2 15 | spaced-comment: [2, "always", { "exceptions": ["-"]}] 16 | valid-jsdoc: [2, { prefer: { "return": "returns"}}] 17 | wrap-iife: 2 18 | quotes: [2, "single"] 19 | curly: [2, "all"] 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | timezone: Australia/Sydney 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | *.swp 3 | .vscode 4 | .nyc_output -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "diff": true, 3 | "extension": ["js"], 4 | "opts": false, 5 | "package": "./package.json", 6 | "reporter": "spec", 7 | "require": "./test/spec-helpers.js", 8 | "slow": 10000, 9 | "timeout": 30000, 10 | "watch-files": ["src/**/*.js", "test/**/*.js"] 11 | } 12 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .npmignore 3 | .travis.yml 4 | .eslintrc 5 | test 6 | Makefile 7 | .nyc_output 8 | .github 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10' 4 | - '12' 5 | - '14' 6 | script: "make travis" 7 | jobs: 8 | include: 9 | - stage: npm release 10 | script: echo "Deploying to npm ..." 11 | deploy: 12 | provider: npm 13 | email: npm@tabdigital.com.au 14 | api_key: $NPM_TOKEN 15 | on: 16 | tags: true 17 | branch: master 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Tabcorp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MATCHERS.md: -------------------------------------------------------------------------------- 1 | # Built-in matchers 2 | 3 | - [array](#array) 4 | - [boolean](#boolean) 5 | - [duration](#duration) 6 | - [enum](#enum) 7 | - [func](#func) 8 | - [hashmap](#hashmap) 9 | - [integer](#integer) 10 | - [ip](#ip) 11 | - [isoDate](#isodate) 12 | - [number](#number) 13 | - [object](#object) 14 | - [objectWithOnly](#objectwithonly) 15 | - [regex](#regex) 16 | - [string](#string) 17 | - [url](#url) 18 | - [uuid](#uuid) 19 | - [value](#value) 20 | - [email](#email) 21 | - [oneOf](#oneof) 22 | 23 | ## array 24 | 25 | ```js 26 | // match any array 27 | nicknames: 'array' 28 | 29 | // match an array of matchers 30 | nicknames: ['number'] 31 | nicknames: [s.number({min: 5})] 32 | nicknames: [s.object({text: 'string'})] 33 | 34 | // match an array of matchers (full syntax) 35 | nicknames: s.array('number') 36 | nicknames: s.array(s.number({min: 5})); 37 | 38 | // optional min and max number of elements 39 | nicknames: s.array({min: 3, of: 'number'}) 40 | nicknames: s.array({max: 7, of: s.number({min: 5})}) 41 | nicknames: s.array({max: 7, of: s.object({name: 'string'})}) 42 | ``` 43 | 44 | ## boolean 45 | 46 | ```js 47 | // match a boolean 48 | enabled: 'boolean' 49 | enabled: s.boolean() 50 | 51 | // optionally parse a string into a boolean e.g. "true"/"false" 52 | enabled: s.boolean({parse: true}) 53 | ``` 54 | 55 | ## duration 56 | 57 | Match a duration in the following format: `10s`, `5m`, `24h`... 58 | 59 | ```js 60 | timespan: 'duration' 61 | timespan: s.duration() 62 | timespan: s.duration({min: '10s', max: '5m'}) 63 | ``` 64 | 65 | ## enum 66 | 67 | ```js 68 | // list of possible values 69 | state: s.enum({values: ['NSW', 'VIC']}) 70 | 71 | // displays "should be a valid state" 72 | state: s.enum({values: ['NSW', 'VIC'], name:'state'}) 73 | 74 | // displays "should be a valid state (NSW,VIC)" 75 | state: s.enum({values: ['NSW', 'VIC'], name:'state', verbose: true}) 76 | 77 | // you can pass in the enum type, which will be used in the jsonschema output 78 | state: s.enum({type: 'string', values: ['NSW', 'VIC']}) 79 | ``` 80 | 81 | ## func 82 | 83 | ```js 84 | // match any function 85 | cb: 'func' 86 | cb: s.func() 87 | 88 | // match a function with 3 parameters 89 | cb: s.func({arity: 3}) 90 | ``` 91 | 92 | ## hashmap 93 | 94 | ```js 95 | // only match the value type 96 | map: s.hashmap('number') 97 | map: s.hashmap(s.number({max: 10})) 98 | 99 | // match keys and value types 100 | map: s.hashmap({ 101 | keys: 'string' 102 | values: 'number' 103 | }) 104 | 105 | // or using more complex matchers 106 | map: s.hashmap({ 107 | keys: /^[a-z]{5}$/ 108 | values: s.array(s.number({max: 10})) 109 | }) 110 | ``` 111 | 112 | ## integer 113 | 114 | ```js 115 | // match any integer 116 | numberOfKids: 'integer' 117 | numberOfKids: s.integer() 118 | 119 | // optional min and max value 120 | numberOfKids: s.integer({min: 0, max: 100}) 121 | 122 | // optionally parse a string into an integer e.g. "120" 123 | numberOfKids: s.integer({parse: true, min: 0, max: 100}) 124 | ``` 125 | 126 | ## ip (does not yet support IPv6) 127 | 128 | ```js 129 | // match any IPv4 address in dot-decimal notation, eg. 192.168.0.1 130 | ip: s.ip({version: 4}) 131 | ``` 132 | 133 | ## isoDate 134 | 135 | Match a date in ISO8601 format (string). 136 | For example: `2014-12-31T23:59:59.999Z` 137 | 138 | ```js 139 | updated: 'isoDate' 140 | updated: s.isoDate({time: true}) 141 | ``` 142 | 143 | ## number 144 | 145 | ```js 146 | // match any number 147 | age: 'number' 148 | age: s.number() 149 | 150 | // optional min and max value 151 | age: s.number({min: 0, max: 100}) 152 | 153 | // optionally parse a string into a number e.g. "1"/"1.2" 154 | age: s.number({parse: true}) 155 | ``` 156 | 157 | ## object 158 | 159 | Match an object, with an optional list of properties. 160 | This matcher will ignore any extra properties that are not mentioned. 161 | 162 | Each property must have a corresponding matcher, and they will be called recursively. 163 | 164 | ```js 165 | // match any object 166 | person: 'object' 167 | person: s.object() 168 | 169 | // match an object with given properties 170 | person: s.object({ 171 | name: 'string' 172 | age: 'number' 173 | }) 174 | 175 | // can be simplified to 176 | person: { 177 | name: 'string' 178 | age: 'number' 179 | } 180 | 181 | // match a nested object 182 | person: { 183 | name: 'string' 184 | address: { 185 | city: 'string' 186 | postcode: 'number' 187 | } 188 | } 189 | ``` 190 | 191 | ## objectWithOnly 192 | 193 | Match an exact object. 194 | This matcher will not error if any optional properties are left out. 195 | This matcher will error if any extra properties are passed in. 196 | 197 | 198 | ```js 199 | 200 | // match an object with given properties 201 | person: s.objectWithOnly({ 202 | name: 'string' 203 | age: 'number' 204 | }) 205 | 206 | // match a nested object 207 | person: s.objectWithOnly({ 208 | name: 'string' 209 | address: s.objectWithOnly({ 210 | city: 'string' 211 | postcode: 'number' 212 | }) 213 | }) 214 | ``` 215 | 216 | ## regex 217 | 218 | ```js 219 | // match a given regex 220 | name: /^[a-z]+$/i 221 | name: s.regex(/^[a-z]+$/i) 222 | ``` 223 | 224 | ## string 225 | 226 | ```js 227 | // match any string 228 | name: 'string' 229 | name: s.string() 230 | 231 | // optional min and max value 232 | name: s.string({min: 2, max: 50}) 233 | ``` 234 | 235 | ## url 236 | 237 | ```js 238 | //match a valid url 239 | address: 'url' 240 | address: s.url() 241 | ``` 242 | 243 | ## uuid 244 | 245 | Match a [universally unique identifier](http://en.wikipedia.org/wiki/Universally_unique_identifier). 246 | The standard defines the format as `xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx`. 247 | 248 | ```js 249 | // match any valid UUID 250 | id: 'uuid' 251 | id: s.uuid() 252 | 253 | // only match a specific version 254 | id: s.uuid({version: 4}) 255 | ``` 256 | 257 | ## value 258 | 259 | ```js 260 | // test for strict equality on primitives 261 | promotionSelected: s.value(false) 262 | ``` 263 | 264 | ## email 265 | 266 | Match a valid email address. 267 | 268 | ```js 269 | // match any valid email 270 | id: 'email' 271 | id: s.email() 272 | 273 | // only match a specific domain 274 | id: s.email({domain: 'example.org'}) 275 | ``` 276 | 277 | ## oneOf 278 | 279 | Match one of the matchers 280 | 281 | ```js 282 | param: s.oneOf([ 283 | 'string', 284 | s.enum({ values: [1, 2, 3] }), 285 | 'boolean', 286 | s.object({ 287 | name: 'string' 288 | }) 289 | ]); 290 | ``` 291 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | lint: 2 | @./node_modules/.bin/eslint -c .eslintrc lib 3 | 4 | performance: lint 5 | @./node_modules/.bin/mocha performance-test 6 | 7 | test: lint performance 8 | @./node_modules/.bin/mocha 9 | 10 | coverage: lint 11 | @./node_modules/.bin/nyc node_modules/mocha/bin/_mocha 12 | 13 | travis: lint coverage performance 14 | 15 | .PHONY: lint test performance coverage travis 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Strummer 2 | 3 | > Structural-matching for JavaScript. 4 | 5 | [![NPM](http://img.shields.io/npm/v/strummer.svg?style=flat-square)](https://npmjs.org/package/strummer) 6 | [![License](http://img.shields.io/npm/l/strummer.svg?style=flat-square)](https://github.com/Tabcorp/strummer) 7 | 8 | [![Build Status](https://travis-ci.org/Tabcorp/strummer.svg?branch=master)](https://travis-ci.org/Tabcorp/strummer) 9 | [![Test coverage](https://img.shields.io/badge/coverage-100-brightgreen.svg?style=flat-square)](http://travis-ci.org/Tabcorp/strummer) 10 | 11 | [![Dependencies](http://img.shields.io/david/Tabcorp/strummer.svg?style=flat-square)](https://david-dm.org/Tabcorp/strummer) 12 | [![Dev dependencies](http://img.shields.io/david/dev/Tabcorp/strummer.svg?style=flat-square)](https://david-dm.org/Tabcorp/strummer) 13 | 14 | ## Main uses cases 15 | 16 | - validating user input / config files 17 | - validating inbound HTTP request payloads 18 | - writing expressive unit tests 19 | 20 | ## Table of contents 21 | 22 | - [Getting started](#getting-started) 23 | - [Syntactic sugar](#syntactic-sugar) 24 | - [A more complex example](#a-more-complex-example) 25 | - [Optional values](#optional-values) 26 | - [Defining custom matchers](#defining-custom-matchers) 27 | - [Asserting on matchers](#asserting-on-matchers) 28 | - [Custom constraints](#custom-constraints) 29 | - [A note on performance](#a-note-on-performance) 30 | 31 | ## Getting started 32 | 33 | ``` 34 | npm install strummer 35 | ``` 36 | 37 | ```js 38 | var s = require('strummer'); 39 | 40 | var person = s({ 41 | name: 'string', 42 | age: 'number', 43 | address: { 44 | city: 'string', 45 | postcode: 'number' 46 | }, 47 | nicknames: ['string'] 48 | }); 49 | 50 | console.log(person.match(bob)); 51 | 52 | // [ 53 | // { path: 'name', value: null, message: 'should be a string' } 54 | // { path: 'address.postcode', value: 'NY', message: 'should be a number' } 55 | // ] 56 | ``` 57 | 58 | ## Syntactic sugar 59 | 60 | The example above is actually syntactic sugar for: 61 | 62 | ```js 63 | var person = new s.object({ 64 | name: new s.string(), 65 | age: new s.number(), 66 | address: new s.object({ 67 | city: new s.string(), 68 | postcode: new s.number() 69 | }), 70 | nicknames: new s.array({of: new s.string()}) 71 | }); 72 | ``` 73 | 74 | This means all matchers are actually instances of `s.Matcher`, 75 | and can potentially take extra parameters. 76 | 77 | ```js 78 | new s.number({min:1, max:100}) 79 | ``` 80 | 81 | Built-in matchers include(all classes) 82 | 83 | - `s.array({min, max, of, description})` 84 | - `s.boolean({parse, description})` 85 | - `s.duration({min, max, description})` 86 | - `s.enum({name, values, verbose, description})` 87 | - `s.func({arity})` 88 | - `s.hashmap({keys, values})` 89 | - `s.integer({min, max, description})` 90 | - `s.ip({version: 4, description})` 91 | - `s.isoDate({time, description})` 92 | - `s.number({min, max, parse, description})` 93 | - `s.object(fields, {description})` 94 | - `s.objectWithOnly(fields, {description})` 95 | - `s.regex(reg, {description})` 96 | - `s.string({min, max, description})` 97 | - `s.url({description})` 98 | - `s.uuid({version, description})` 99 | - `s.value(primitive, {description})` 100 | - `s.email({domain, description})` 101 | - `s.oneOf([matcher], {description})` 102 | 103 | They all come with [several usage examples](https://github.com/Tabcorp/strummer/blob/master/MATCHERS.md). 104 | Matchers usually support both simple / complex usages, with nice syntactic sugar. 105 | 106 | ## A more complex example 107 | 108 | Here's an example that mixes nested objects, arrays, 109 | and matches on different types with extra options. 110 | 111 | ```js 112 | var person = new s.object({ 113 | id: new s.uuid({version: 4}), 114 | name: 'string', 115 | age: new s.number({min: 1, max: 100}), 116 | address: { 117 | city: 'string', 118 | postcode: 'number' 119 | }, 120 | nicknames: [{max: 3, of: 'string'}], 121 | phones: [{of: { 122 | type: new s.enum({values: ['MOBILE', 'HOME']}), 123 | number: 'number' 124 | }}] 125 | }); 126 | ``` 127 | 128 | You can of course extract matchers to reuse them, 129 | or to make the hierarchy more legible. 130 | 131 | ```js 132 | var age = new s.number({min: 1, max: 100}) 133 | 134 | var address = new s.object({ 135 | city: 'string', 136 | postcode: 'number' 137 | }); 138 | 139 | var person = new s.object({ 140 | name: 'string', 141 | age: age, 142 | home: address 143 | }); 144 | ``` 145 | 146 | ## Optional values 147 | 148 | By default, all matchers expect the value to exist. 149 | In other words every field is required in your schema definition. 150 | 151 | You can make a field optional by using the special `{optional: true}` argument., 152 | 153 | ```js 154 | new s.number({optional: true, min: 1}) 155 | ``` 156 | 157 | ## Defining custom matchers 158 | 159 | To define a customer matcher, simply inherit the `s.Matcher` prototype 160 | and implement the `_match` function. 161 | 162 | ```js 163 | var s = require('strummer'); 164 | 165 | function MyMatcher(opts) { 166 | s.Matcher.call(this, opts); 167 | } 168 | 169 | util.inherits(MyMatcher, s.Matcher); 170 | 171 | MyMatcher.prototype._match = function(path, value) { 172 | // if this is a leaf matcher, we only care about the current value 173 | return null; 174 | return 'should be a string starting with ABC'; 175 | // if this matcher has children, we need to return an array of errors; 176 | return []; 177 | return [ 178 | { path: path + '[0]', value: value[0], message: 'should be > 10' } 179 | { path: path + '[1]', value: value[1], message: 'should be > 20' } 180 | ] 181 | }; 182 | ``` 183 | 184 | Or you can use the helper function to create it: 185 | 186 | ```js 187 | var MyMatcher = s.createMatcher({ 188 | initialize: function() { 189 | // initialize here 190 | // you can use "this" to store local data 191 | }, 192 | match: function(path, value) { 193 | // validate here 194 | // you can also use "this" 195 | } 196 | }); 197 | ``` 198 | 199 | You can use these matchers like any of the built-in ones. 200 | 201 | ```js 202 | new s.object({ 203 | name: 'string', 204 | id: new MyMatcher({max: 3}) 205 | }) 206 | ``` 207 | 208 | ## Asserting on matchers 209 | 210 | Matchers always return the following structure: 211 | 212 | ```js 213 | [ 214 | { path: 'person.name', value: null, message: 'should be a string' } 215 | ] 216 | ``` 217 | 218 | In some cases, you might just want to `throw` an error - for example in the context of a unit test. 219 | Strummer provides the `s.assert` function for that purpose: 220 | 221 | ```js 222 | s.assert(name, 'string'); 223 | // name should be a string (but was null) 224 | 225 | s.assert(nicknames, ['string']); 226 | // name[2] should be a string (but was 123) 227 | // name[3] should be a string (but was 456) 228 | 229 | s.assert(person, { 230 | name: 'string', 231 | age: new s.number({max: 200}) 232 | }); 233 | // person.age should be a number <= 200 (but was 250) 234 | ``` 235 | 236 | ## Custom constraints 237 | 238 | Custom constraints can be applied by passing a function as the second argument when creating the schema. This function will be run on match and you are able to return an array of errors. 239 | 240 | Currently only objectWithOnly is supported. 241 | 242 | An example use case is related optional fields 243 | 244 | ```js 245 | // AND relationship between two optional fields 246 | 247 | var constraintFunc = function (path, value) { 248 | if (value.street_number && !value.post_code) { 249 | return [{ 250 | path: path, 251 | value: value, 252 | error: 'post_code is requried with a street_number' 253 | }] 254 | } 255 | 256 | return [] 257 | } 258 | 259 | var schema = new objectWithOnly({ 260 | email_address: new string(), 261 | street_number: new number({optional: true}), 262 | post_code: new number({optional: true}), 263 | }, { 264 | constraints: constraintFunc 265 | }); 266 | 267 | var value = { 268 | email_address: 'test@strummer.com', 269 | street_number: 12, 270 | } 271 | 272 | const errors = schema.match(value) 273 | // will error with post_code is requried with a street_number 274 | 275 | ## JSON Schema Support 276 | 277 | Strummer can generate some simple JSON Schema from strummer definition. 278 | 279 | ```js 280 | var schema = s({ 281 | foo: 'string', 282 | bar: s.string({ optional: true, description: 'Lorem Ipsum' }), 283 | num: s.number({ max: 100, min: 0 }) 284 | }); 285 | 286 | console.log(schema.toJSONSchema()); 287 | ``` 288 | 289 | which will shows log like this: 290 | 291 | ```js 292 | { 293 | type: 'object', 294 | required: ['foo', 'num'], 295 | properties: { 296 | foo: { 297 | type: 'string' 298 | }, 299 | bar: { 300 | type: 'string', 301 | description: 'Lorem Ipsum' 302 | }, 303 | num: { 304 | type: 'number', 305 | maximum: 100, 306 | minimum: 0 307 | } 308 | } 309 | } 310 | ``` 311 | 312 | When you trying to create your own matcher which supports jsonSchema, then you needs to impement 313 | the `toJSONSchema` option in the `createMatcher`, if `toJSONSchema` is not defined, when you call 314 | `matcher.toJSONSchema()` it will return nothing. 315 | 316 | 317 | ## A note on performance 318 | 319 | The 2 main rules for performance are: 320 | 321 | - If you need to validate many objects of the same kind, 322 | you should declare matchers upfront and reuse them. 323 | 324 | - All syntactic sugar is processed at creation time. 325 | This means shorthand notations don't cause any performance overhead 326 | compared to their canonical equivalents. 327 | 328 | Of course, actual performance depends on the complexity of your matchers / objects. 329 | If you're interested in figures, some stats are printed as part of the unit test suite: 330 | 331 | ```js 332 | new s.object({ 333 | id: new s.uuid({version: 4}), 334 | name: 'string', 335 | age: new s.number({optional: true, min: 1, max: 100}), 336 | addresses: new s.array({of: { 337 | type: 'string', 338 | city: 'string', 339 | postcode: 'number' 340 | }}), 341 | nicknames: [{max: 3, of: 'string'}], 342 | phones: [{of: { 343 | type: new s.enum({values: ['MOBILE', 'HOME']}), 344 | number: /^[0-9]{10}$/ 345 | }}] 346 | }) 347 | 348 | // ┌───────────────────────┬─────────────────┐ 349 | // │ Number of validations │ Total time (ms) │ 350 | // ├───────────────────────┼─────────────────┤ 351 | // │ 10,000 │ 85 │ 352 | // └───────────────────────┴─────────────────┘ 353 | ``` 354 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | 1.0.4 / 2015-03-20 2 | ================== 3 | 4 | * Bug fix ([#20](https://github.com/Tabcorp/strummer/issues/20)): `integer` and `number` matchers fail to catch invalid data when `{min: 0}` or `{max: 0}` is passed as opt. 5 | -------------------------------------------------------------------------------- /lib/compile.js: -------------------------------------------------------------------------------- 1 | var isArray = require('isarray'); 2 | var index = require('./index'); 3 | var Matcher = require('./matcher'); 4 | 5 | exports.spec = function compile(spec) { 6 | var matcher = null; 7 | if (Matcher.is(spec)) { 8 | matcher = spec; 9 | } else if (isArray(spec)) { 10 | matcher = new index.matchers.array(spec[0]); 11 | } else if (spec instanceof RegExp) { 12 | matcher = new index.matchers.regex(spec); 13 | } else if (typeof spec === 'object') { 14 | matcher = new index.matchers.object(spec); 15 | } else if (typeof spec === 'string') { 16 | matcher = new index.matchers[spec](); 17 | } 18 | if (!matcher) { 19 | throw new Error('Invalid matcher: ' + spec); 20 | } 21 | return matcher; 22 | }; 23 | -------------------------------------------------------------------------------- /lib/factory.js: -------------------------------------------------------------------------------- 1 | var inherits = require('./utils').inherits; 2 | var Matcher = require('./matcher'); 3 | 4 | function noop() {} 5 | 6 | function matcherFactory(define) { 7 | var initialize = define.initialize || noop; 8 | 9 | function M(opts, params) { 10 | if (this instanceof M === false) { 11 | return new M(opts, params); 12 | } 13 | Matcher.call(this, opts); 14 | initialize.call(this, opts, params); 15 | } 16 | 17 | inherits(M, Matcher); 18 | 19 | if (!define.match) { 20 | throw new Error('match is not implemented'); 21 | } 22 | 23 | M.prototype._match = define.match; 24 | M.prototype._toJSONSchema = define.toJSONSchema; 25 | 26 | return M; 27 | } 28 | 29 | module.exports = matcherFactory; 30 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /lib/matcher.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var PROPERTY_TO_FLAG_AS_STRUMMER_MATCHER = 'IS-STRUMMER-MATCHER'; 3 | 4 | function Matcher(opts) { 5 | this.optional = opts && (opts.optional === true); 6 | Object.defineProperty(this, PROPERTY_TO_FLAG_AS_STRUMMER_MATCHER, { 7 | value: true, 8 | writable: false, 9 | enumerable: false, 10 | configurable: false 11 | }); 12 | } 13 | 14 | Matcher.is = function(otherMatcher) { 15 | if (!otherMatcher) { 16 | return false; 17 | } 18 | return otherMatcher[PROPERTY_TO_FLAG_AS_STRUMMER_MATCHER]; 19 | }; 20 | 21 | function missing(value) { 22 | return value === null || typeof value === 'undefined'; 23 | } 24 | 25 | Matcher.prototype.setName = function(name) { 26 | this.name = name; 27 | }; 28 | 29 | Matcher.prototype.match = function(path, value, index) { 30 | if (arguments.length === 1) { 31 | value = path; 32 | path = ''; 33 | } 34 | if (this.optional && missing(value)) { return []; } 35 | var errors = this._match(path, value, index); 36 | if (!errors) { return []; } 37 | if (typeof errors === 'string') { return [{path: path, value: value, message: errors}]; } 38 | else { return errors; } 39 | }; 40 | 41 | Matcher.prototype.toJSONSchema = function() { 42 | var basic = {}; 43 | var generated = {}; 44 | 45 | if (this.name) { 46 | basic.name = this.name; 47 | } 48 | 49 | if (this._toJSONSchema) { 50 | generated = this._toJSONSchema(); 51 | } 52 | 53 | return _.assign({}, basic, generated); 54 | }; 55 | 56 | module.exports = Matcher; 57 | -------------------------------------------------------------------------------- /lib/matchers/array.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var isArray = require('isarray'); 3 | var Matcher = require('../matcher'); 4 | var factory = require('../factory'); 5 | var compile = require('../compile'); 6 | 7 | module.exports = factory({ 8 | initialize: function(opts) { 9 | var matcher; 10 | if (typeof opts === 'string') { 11 | matcher = opts; 12 | } else if (opts instanceof Matcher) { 13 | matcher = opts; 14 | } else if (typeof opts === 'object' && opts.of) { 15 | matcher = opts.of; 16 | } else { 17 | throw new Error('Invalid array matcher: missing '); 18 | } 19 | this.of = compile.spec(matcher); 20 | if (opts) { 21 | this.min = opts.min; 22 | this.max = opts.max; 23 | this.description = opts.description; 24 | } 25 | }, 26 | 27 | match: function(path, value) { 28 | if (isArray(value) === false) { 29 | return [{path: path, value: value, message: 'should be an array'}]; 30 | } 31 | 32 | // check number of items 33 | if (this.min && this.max && (value.length < this.min || value.length > this.max)) { 34 | return [{path: path, value: value, message: 'should have between ' + this.min + ' and ' + this.max + ' items'}]; 35 | } 36 | if (this.min && value.length < this.min) { 37 | return [{path: path, value: value, message: 'should have at least ' + this.min + ' items'}]; 38 | } 39 | if (this.max && value.length > this.max) { 40 | return [{path: path, value: value, message: 'should have at most ' + this.max + ' items'}]; 41 | } 42 | 43 | // call the matcher on each item 44 | var self = this; 45 | return _.compact(_.flatten(_.map(value, function(value, index) { 46 | return self.of.match(path + '[' + index + ']', value, index); 47 | }))); 48 | }, 49 | toJSONSchema: function() { 50 | var schema = { type: 'array' }; 51 | schema.items = this.of.toJSONSchema(); 52 | if (this.min) { 53 | schema.minItems = this.min; 54 | } 55 | if (this.max) { 56 | schema.maxItems = this.max; 57 | } 58 | if (this.description) { 59 | schema.description = this.description; 60 | } 61 | return schema; 62 | } 63 | }); 64 | -------------------------------------------------------------------------------- /lib/matchers/boolean.js: -------------------------------------------------------------------------------- 1 | var factory = require('../factory'); 2 | 3 | function parseBool(value) { 4 | if ((typeof value === 'string') && (value.toLowerCase() === 'true')) { 5 | return true; 6 | } 7 | else if ((typeof value === 'string') && (value.toLowerCase() === 'false')) { 8 | return false; 9 | } 10 | else { 11 | return value; 12 | } 13 | } 14 | 15 | module.exports = factory({ 16 | initialize: function(opts) { 17 | this.opts = opts || {}; 18 | this.description = this.opts.description; 19 | }, 20 | match: function(path, value) { 21 | if (this.opts.parse) { 22 | value = parseBool(value); 23 | } 24 | 25 | if (typeof value !== 'boolean') { 26 | return 'should be a boolean'; 27 | } 28 | }, 29 | toJSONSchema: function () { 30 | var schema = { type: 'boolean' }; 31 | if (this.description) { 32 | schema.description = this.description; 33 | } 34 | return schema; 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /lib/matchers/duration.js: -------------------------------------------------------------------------------- 1 | var ms = require('ms'); 2 | var factory = require('../factory'); 3 | 4 | var TYPE_ERROR = 'should be a duration string (e.g. \"10s\")'; 5 | 6 | module.exports = factory({ 7 | initialize: function(opts) { 8 | this.opts = opts || {}; 9 | 10 | this.minValue = (this.opts.min != null) ? ms(this.opts.min) : 0; 11 | this.maxValue = (this.opts.max != null) ? ms(this.opts.max) : Number.MAX_VALUE; 12 | this.description = this.opts.description; 13 | 14 | if (typeof this.minValue !== 'number') { 15 | throw new Error('Invalid minimum duration: ' + this.opts.min); 16 | } 17 | 18 | if (typeof this.maxValue !== 'number') { 19 | throw new Error('Invalid maximum duration: ' + this.opts.max); 20 | } 21 | }, 22 | match: function(path, value) { 23 | if (typeof value !== 'string') { return TYPE_ERROR; } 24 | 25 | var duration = ms(value); 26 | if (typeof duration !== 'number') { return TYPE_ERROR; } 27 | 28 | if (this.opts.min && this.opts.max && (duration < this.minValue || duration > this.maxValue)) { 29 | return 'should be a duration between ' + this.opts.min + ' and ' + this.opts.max; 30 | } 31 | if (this.opts.min && (duration < this.minValue)) { 32 | return 'should be a duration >= ' + this.opts.min; 33 | } 34 | if (this.opts.max && (duration > this.maxValue)) { 35 | return 'should be a duration <= ' + this.opts.max; 36 | } 37 | 38 | return null; 39 | }, 40 | toJSONSchema: function() { 41 | var schema = { 42 | type: 'string', 43 | pattern: '^((?:\\d+)?\.?\\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|years?|yrs?|y)$' 44 | }; 45 | if (this.description) { 46 | schema.description = this.description; 47 | } 48 | return schema; 49 | } 50 | }); 51 | -------------------------------------------------------------------------------- /lib/matchers/email.js: -------------------------------------------------------------------------------- 1 | var escapeStringRegexp = require('escape-string-regexp'); 2 | var utils = require('../utils'); 3 | var factory = require('../factory'); 4 | 5 | var TYPE_ERROR = 'should be a valid email address'; 6 | 7 | var DOMAIN_REGEX_SOURCE = /[a-zA-Z0-9](-?[a-zA-Z0-9])*(\.[a-zA-Z](-?[a-zA-Z0-9])*)+/.source; 8 | var DOMAIN_REGEX = new RegExp('^' + DOMAIN_REGEX_SOURCE + '$'); 9 | var EMAIL_REGEX_SOURCE = /[-!#$%&'*+\/0-9=?A-Z^_a-z{|}~](\.?[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~])*@/.source; 10 | var EMAIL_REGEX = new RegExp('^' + EMAIL_REGEX_SOURCE + DOMAIN_REGEX_SOURCE + '$'); 11 | 12 | // Stolen from http://thedailywtf.com/articles/Validating_Email_Addresses 13 | 14 | module.exports = factory({ 15 | initialize: function(opts) { 16 | this.opts = opts || {}; 17 | 18 | this.hasDomain = utils.hasValue(this.opts.domain); 19 | this.description = this.opts.description; 20 | 21 | if (this.hasDomain && !DOMAIN_REGEX.test(opts.domain)) { 22 | throw new Error('Invalid domain value: ' + opts.domain); 23 | } 24 | 25 | if (this.hasDomain) { 26 | this.domainRegex = this.regex = new RegExp('^' + EMAIL_REGEX_SOURCE + escapeStringRegexp(opts.domain) + '$'); 27 | } else { 28 | this.regex = EMAIL_REGEX; 29 | } 30 | }, 31 | 32 | match: function(path, value) { 33 | if (typeof value !== 'string') { return TYPE_ERROR; } 34 | 35 | if (value.length > 254) { return TYPE_ERROR; } 36 | 37 | if (!EMAIL_REGEX.test(value)) { return TYPE_ERROR; } 38 | 39 | if (this.hasDomain && !this.domainRegex.test(value)) { 40 | return 'should be a valid email address at ' + this.opts.domain; 41 | } 42 | 43 | // Further checking of some things regex can't handle 44 | var parts = value.split('@'); 45 | if (parts[0].length > 64) { return TYPE_ERROR; } 46 | 47 | var longPartOfDomain = parts[1].split('.').some(function(part) { 48 | return part.length > 63; 49 | }); 50 | if (longPartOfDomain) { return TYPE_ERROR; } 51 | 52 | return null; 53 | }, 54 | 55 | toJSONSchema: function () { 56 | var schema = { 57 | type: 'string', 58 | pattern: this.regex.source 59 | } 60 | if (this.description) { 61 | schema.description = this.description; 62 | } 63 | return schema; 64 | } 65 | }); 66 | -------------------------------------------------------------------------------- /lib/matchers/enum.js: -------------------------------------------------------------------------------- 1 | var isArray = require('isarray'); 2 | var factory = require('../factory'); 3 | 4 | module.exports = factory({ 5 | initialize: function(opts) { 6 | this.opts = opts || {}; 7 | this.name = this.opts.name; 8 | this.values = this.opts.values; 9 | this.verbose = this.opts.verbose; 10 | this.description = this.opts.description; 11 | this.type = this.opts.type; 12 | if (isArray(this.values) === false) { 13 | throw new Error('Invalid enum values: ' + this.values); 14 | } 15 | }, 16 | match: function(path, value) { 17 | if (this.values.indexOf(value) === -1) { 18 | var detail = this.verbose ? (' (' + this.values.join(',') + ')') : ''; 19 | var type = this.name || 'enum value'; 20 | return 'should be a valid ' + type + detail; 21 | } 22 | }, 23 | toJSONSchema: function () { 24 | var schema = { 'enum': this.values }; 25 | if (this.description) { 26 | schema.description = this.description; 27 | } 28 | if (this.type) { 29 | schema.type = this.type; 30 | } 31 | 32 | return schema; 33 | } 34 | }); 35 | -------------------------------------------------------------------------------- /lib/matchers/func.js: -------------------------------------------------------------------------------- 1 | var factory = require('../factory'); 2 | 3 | 4 | module.exports = factory({ 5 | initialize: function(opts) { 6 | this.opts = opts || {}; 7 | }, 8 | 9 | match: function(path, value) { 10 | var opts = this.opts; 11 | if (typeof (value) !== 'function') { 12 | return 'should be a function'; 13 | } 14 | if (typeof (opts.arity) === 'number') { 15 | if (value.length !== opts.arity) { 16 | return 'should be a function with ' + opts.arity + ' parameter' + (opts.arity === 1 ? '' : 's'); 17 | } 18 | } 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /lib/matchers/hashmap.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var Matcher = require('../matcher'); 3 | var factory = require('../factory'); 4 | var compile = require('../compile'); 5 | var s = require('../strummer'); 6 | 7 | module.exports = factory({ 8 | initialize: function(opts) { 9 | this.opts = opts || {}; 10 | var matchers = { keys: null, values: null }; 11 | if (opts instanceof Matcher) { 12 | matchers.values = opts; 13 | } else if (typeof opts === 'object') { 14 | matchers.keys = opts.keys ? compile.spec(opts.keys) : null; 15 | matchers.values = opts.values ? compile.spec(opts.values) : null; 16 | } else if (opts) { 17 | matchers.values = compile.spec(opts); 18 | } 19 | 20 | this.matchers = matchers; 21 | this.description = this.opts.description; 22 | }, 23 | 24 | match: function(path, obj) { 25 | if (obj == null || typeof obj !== 'object') { 26 | return [{path: path, value: obj, message: 'should be a hashmap'}]; 27 | } 28 | 29 | var errors = []; 30 | if (this.matchers.keys) { 31 | var keyErrors = s.array({of: this.matchers.keys}).match(path + '.keys', Object.keys(obj)); 32 | errors.push(keyErrors); 33 | } 34 | if (this.matchers.values) { 35 | var self = this; 36 | errors.push(_.map(obj, function(val, key) { 37 | return self.matchers.values.match(path + '[' + key + ']', val); 38 | })); 39 | } 40 | 41 | return _.compact(_.flattenDeep(errors)); 42 | }, 43 | toJSONSchema: function() { 44 | var self = this; 45 | 46 | var schema = { 47 | type: 'object' 48 | }; 49 | 50 | if (self.matchers.values) { 51 | schema.additionalProperties = self.matchers.values.toJSONSchema(); 52 | } 53 | 54 | if (this.description) { 55 | schema.description = this.description; 56 | } 57 | return schema; 58 | } 59 | }); 60 | -------------------------------------------------------------------------------- /lib/matchers/integer.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var utils = require('../utils'); 3 | var factory = require('../factory'); 4 | var hasValue = utils.hasValue; 5 | 6 | var INTEGER_REGEX = /^(\-|\+)?([0-9]+)$/; 7 | 8 | function parseIntFromString(value) { 9 | if (INTEGER_REGEX.test(value)) { return Number(value); } 10 | return value; 11 | } 12 | 13 | module.exports = factory({ 14 | initialize: function(opts) { 15 | this.hasMinValue = false; 16 | this.hasMaxValue = false; 17 | 18 | if (hasValue(opts)) { 19 | this.description = opts.description; 20 | this.hasMinValue = hasValue(opts.min); 21 | this.hasMaxValue = hasValue(opts.max); 22 | if (this.hasMinValue && !_.isNumber(opts.min)) { 23 | throw new Error('Invalid minimum option: ' + opts.min); 24 | } 25 | 26 | if (this.hasMaxValue && !_.isNumber(opts.max)) { 27 | throw new Error('Invalid maximum option: ' + opts.max); 28 | } 29 | } else { 30 | opts = {}; 31 | } 32 | this.opts = opts; 33 | }, 34 | match: function(path, value) { 35 | var opts = this.opts; 36 | if (opts.parse) { 37 | value = parseIntFromString(value); 38 | } 39 | 40 | if (typeof value !== 'number' || value % 1 !== 0) { 41 | return 'should be an integer'; 42 | } 43 | 44 | if (this.hasMinValue && this.hasMaxValue && (value < opts.min || value > opts.max)) { 45 | return 'should be an integer between ' + opts.min + ' and ' + opts.max; 46 | } 47 | if (this.hasMinValue && value < opts.min) { 48 | return 'should be an integer >= ' + opts.min; 49 | } 50 | if (this.hasMaxValue && value > opts.max) { 51 | return 'should be an integer <= ' + opts.max; 52 | } 53 | }, 54 | toJSONSchema: function() { 55 | var schema = { type: 'integer' }; 56 | 57 | if (this.hasMinValue) { 58 | schema.minimum = this.opts.min; 59 | } 60 | if (this.hasMaxValue) { 61 | schema.maximum = this.opts.max; 62 | } 63 | if (this.description) { 64 | schema.description = this.description; 65 | } 66 | return schema; 67 | } 68 | }); 69 | -------------------------------------------------------------------------------- /lib/matchers/ip.js: -------------------------------------------------------------------------------- 1 | var factory = require('../factory'); 2 | 3 | var IP_REGEX = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/; 4 | 5 | module.exports = factory({ 6 | initialize: function(opts) { 7 | if (!opts || opts.version !== 4) { 8 | throw new Error('Must be initialized with version 4'); 9 | } 10 | this.description = opts ? opts.description : null; 11 | }, 12 | toJSONSchema: function () { 13 | var schema = { 14 | type: 'string', 15 | pattern: IP_REGEX.source 16 | }; 17 | 18 | if (this.description) { 19 | schema.description = this.description; 20 | } 21 | return schema; 22 | }, 23 | match: function(path, value) { 24 | if (!IP_REGEX.test(value)) { 25 | return 'should be a valid IPv4 address'; 26 | } 27 | return null; 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /lib/matchers/isoDate.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var factory = require('../factory'); 3 | 4 | var dateTimeRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}|:\d{2}\.\d+)?(Z|[+-]\d{2}:\d{2})?$/; 5 | var dateTimeMessage = 'should be a date with time in ISO8601 format'; 6 | var dateRegex = /^\d{4}-\d{2}-\d{2}$/; 7 | var dateMessage = 'should be a date in ISO8601 format'; 8 | 9 | module.exports = factory({ 10 | initialize: function (opts) { 11 | this.opts = _.defaults(opts, {time: true}); 12 | this.description = this.opts.description; 13 | }, 14 | match: function(path, value) { 15 | if (typeof value !== 'string') { 16 | return dateMessage; 17 | } 18 | if (!this.opts.time && dateRegex.test(value) === false) { 19 | return dateMessage; 20 | } 21 | if (this.opts.time && dateTimeRegex.test(value) === false) { 22 | return dateTimeMessage; 23 | } 24 | }, 25 | toJSONSchema: function () { 26 | var schema = { 27 | type: 'string', 28 | format: 'ISO8601' 29 | }; 30 | if (this.description) { 31 | schema.description = this.description; 32 | } 33 | return schema; 34 | } 35 | }); 36 | -------------------------------------------------------------------------------- /lib/matchers/number.js: -------------------------------------------------------------------------------- 1 | var utils = require('../utils'); 2 | var _ = require('lodash'); 3 | var factory = require('../factory'); 4 | 5 | var hasValue = utils.hasValue; 6 | 7 | function parseFloatFromString(value) { 8 | if (/^(\-|\+)?([0-9]+(\.[0-9]+)?)$/.test(value)) { 9 | return Number(value); 10 | } 11 | return value; 12 | } 13 | 14 | module.exports = factory({ 15 | initialize: function(opts) { 16 | this.opts = opts || {}; 17 | 18 | var hasMinValue = hasValue(this.opts.min); 19 | var hasMaxValue = hasValue(this.opts.max); 20 | 21 | if (hasMinValue && !_.isNumber(this.opts.min)) { 22 | throw new Error('Invalid minimum option: ' + this.opts.min); 23 | } 24 | 25 | if (hasMaxValue && !_.isNumber(this.opts.max)) { 26 | throw new Error('Invalid maximum option: ' + this.opts.max); 27 | } 28 | 29 | this.min = this.opts.min; 30 | this.max = this.opts.max; 31 | this.parse = this.opts.parse || false; 32 | this.description = this.opts.description; 33 | 34 | if (this.min != null && this.max != null && this.min > this.max) { 35 | throw new Error('Invalid option: ' + this.min + ' > ' + this.max); 36 | } 37 | }, 38 | match: function(path, value) { 39 | if (this.parse) { 40 | value = parseFloatFromString(value); 41 | } 42 | if (typeof value !== 'number') { 43 | return 'should be a number'; 44 | } 45 | 46 | if (this.min != null && this.max != null && (value < this.min || value > this.max)) { 47 | return 'should be a number between ' + this.min + ' and ' + this.max; 48 | } 49 | if (this.min != null && value < this.min) { 50 | return 'should be a number >= ' + this.min; 51 | } 52 | if (this.max != null && value > this.max) { 53 | return 'should be a number <= ' + this.max; 54 | } 55 | }, 56 | toJSONSchema: function() { 57 | var schema = { type: 'number' }; 58 | 59 | if (this.min) { 60 | schema.minimum = this.min; 61 | } 62 | if (this.max) { 63 | schema.maximum = this.max; 64 | } 65 | if (this.description) { 66 | schema.description = this.description; 67 | } 68 | return schema; 69 | } 70 | }); 71 | -------------------------------------------------------------------------------- /lib/matchers/object.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var compile = require('../compile'); 3 | var factory = require('../factory'); 4 | 5 | module.exports = factory({ 6 | initialize: function (spec, opts) { 7 | this.opts = opts || {}; 8 | this.description = this.opts.description; 9 | this.fields = _.mapValues(spec, compile.spec); 10 | }, 11 | match: function(path, value, index) { 12 | var errors = []; 13 | var key; 14 | if (value == null || typeof value !== 'object') { 15 | return [{path: path, value: value, message: 'should be an object'}]; 16 | } 17 | for (key in this.fields) { 18 | if ({}.hasOwnProperty.call(this.fields, key)) { 19 | var subpath = path ? (path + '.' + key) : key; 20 | var err = this.fields[key].match(subpath, value[key], index); 21 | if (err) { errors.push(err); } 22 | } 23 | } 24 | return _.compact(_.flatten(errors)); 25 | }, 26 | toJSONSchema: function() { 27 | var self = this; 28 | var propKeys = Object.keys(this.fields); 29 | 30 | var requiredFields = propKeys.filter(function(field) { 31 | return !self.fields[field].optional; 32 | }); 33 | 34 | var properties = propKeys.reduce(function(props, key) { 35 | var subSchema = self.fields[key].toJSONSchema(); 36 | if (!Object.keys(subSchema).length) { 37 | requiredFields = requiredFields.filter(function(k) { return k !== key; }); 38 | return props; 39 | } 40 | props[key] = self.fields[key].toJSONSchema(); 41 | return props; 42 | }, {}); 43 | 44 | var schema = { 45 | type: 'object', 46 | properties: properties 47 | }; 48 | 49 | /** 50 | * http://json-schema.org/latest/json-schema-validation.html#rfc.section.5.15 51 | * The required field cannot be an empty array 52 | */ 53 | if (requiredFields.length > 0) { 54 | schema.required = requiredFields; 55 | } 56 | if (this.description) { 57 | schema.description = this.description; 58 | } 59 | return schema; 60 | } 61 | }); 62 | -------------------------------------------------------------------------------- /lib/matchers/objectWithOnly.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var factory = require('../factory'); 3 | var s = require('../strummer'); 4 | 5 | module.exports = factory({ 6 | initialize: function (spec, opts) { 7 | this.opts = opts || {}; 8 | this.description = this.opts.description; 9 | if (typeof spec !== 'object') { 10 | throw new Error('Invalid spec, must be an object'); 11 | } 12 | 13 | if (opts && opts.constraints) { 14 | if (typeof opts.constraints !== 'function') { 15 | throw new Error('Invalid constraints, must be a function'); 16 | } 17 | this.constraints = opts.constraints 18 | } 19 | 20 | this.spec = spec; 21 | this.matcher = new s.object(this.spec); 22 | }, 23 | 24 | match: function (path, value, index) { 25 | var objError = this.matcher.match(path, value, index); 26 | var key; 27 | if (objError.length > 0) { 28 | return objError; 29 | } else { 30 | var errors = []; 31 | for (key in value) { 32 | if (!this.spec[key]) { 33 | errors.push({ 34 | path: path ? (path + '.' + key) : key, 35 | value: value[key], 36 | message: 'should not exist' 37 | }); 38 | } 39 | } 40 | 41 | if (this.constraints) { 42 | errors = errors.concat( 43 | this.constraints(path, value, index) 44 | ) 45 | } 46 | 47 | return _.flatten(errors); 48 | } 49 | }, 50 | toJSONSchema: function() { 51 | var schema = this.matcher.toJSONSchema(); 52 | schema.additionalProperties = false; 53 | if (this.description) { 54 | schema.description = this.description; 55 | } 56 | return schema; 57 | } 58 | }); 59 | -------------------------------------------------------------------------------- /lib/matchers/oneOf.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var factory = require('../factory'); 3 | var compile = require('../compile'); 4 | 5 | module.exports = factory({ 6 | initialize: function (matchers, opts) { 7 | this.opts = opts || {}; 8 | this.description = this.opts.description; 9 | if (!matchers) { 10 | throw new Error('oneOf: matchers should not be a falsy value'); 11 | } 12 | 13 | if (!matchers.length) { 14 | throw new Error('oneOf: matchers should be a Array with length >= 1'); 15 | } 16 | 17 | this.matchers = matchers.map(function(m) { 18 | return compile.spec(m); 19 | }); 20 | 21 | this.schemas = this.matchers.map(function(m) { 22 | return m.toJSONSchema(); 23 | }); 24 | 25 | this.isSingleType = _.uniq(_.map(this.schemas, _.property('type'))).length === 1; 26 | }, 27 | match: function(path, value) { 28 | var matcher; 29 | var result; 30 | 31 | for (var i = 0, len = this.matchers.length; i < len; ++i) { 32 | matcher = this.matchers[i]; 33 | result = matcher.match(path, value); 34 | 35 | if (result.length === 0) { 36 | return ''; 37 | } 38 | } 39 | 40 | return JSON.stringify(value) 41 | + ' is not valid under any of the given schemas\n' 42 | + this.matchers.map(function(m, nth) { 43 | return nth + '.\n' + JSON.stringify(m.toJSONSchema(), null, 2); 44 | }).join('\n'); 45 | }, 46 | toJSONSchema: function() { 47 | var result = { 48 | oneOf: this.schemas 49 | }; 50 | 51 | if (this.isSingleType) { 52 | result.type = this.schemas[0].type; 53 | } 54 | if (this.description) { 55 | result.description = this.description; 56 | } 57 | return result; 58 | } 59 | }); 60 | -------------------------------------------------------------------------------- /lib/matchers/optional.js: -------------------------------------------------------------------------------- 1 | var factory = require('../factory'); 2 | var compile = require('../compile'); 3 | 4 | module.exports = factory({ 5 | initialize: function(spec) { 6 | this.child = compile.spec(spec); 7 | this.optional = true; 8 | }, 9 | match: function(path, val) { 10 | return this.child.match(path, val); 11 | }, 12 | toJSONSchema: function() { 13 | return this.child.toJSONSchema(); 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /lib/matchers/regex.js: -------------------------------------------------------------------------------- 1 | var factory = require('../factory'); 2 | 3 | module.exports = factory({ 4 | initialize: function(regex, opts) { 5 | this.opts = opts || {}; 6 | this.description = this.opts.description; 7 | this.errorMessage = this.opts.errorMessage; 8 | this.regex = regex; 9 | if (!this.regex || typeof this.regex.test !== 'function') { 10 | throw new Error('Invalid regex matcher'); 11 | } 12 | }, 13 | 14 | match: function(path, value) { 15 | if (typeof value !== 'string') { 16 | return 'should be a string'; 17 | } 18 | if (!this.regex.test(value)) { 19 | return this.errorMessage ? this.errorMessage : 'should match the regex ' + this.regex.toString(); 20 | } 21 | }, 22 | 23 | toJSONSchema: function () { 24 | var schema = { type: 'string', pattern: this.regex.source }; 25 | if (this.description) { 26 | schema.description = this.description; 27 | } 28 | if (this.errorMessage) { 29 | schema.errorMessage = this.errorMessage; 30 | } 31 | return schema 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /lib/matchers/string.js: -------------------------------------------------------------------------------- 1 | var factory = require('../factory'); 2 | 3 | module.exports = factory({ 4 | initialize: function (opts) { 5 | this.opts = opts || {}; 6 | this.min = this.opts.min; 7 | this.max = this.opts.max; 8 | this.description = this.opts.description; 9 | }, 10 | match: function(path, value) { 11 | if (typeof value !== 'string') { 12 | return 'should be a string'; 13 | } 14 | if (this.min && this.max && (value.length < this.min || value.length > this.max)) { 15 | return 'should be a string with length between ' + this.min + ' and ' + this.max; 16 | } 17 | if (this.min && value.length < this.min) { 18 | return 'should be a string with length >= ' + this.min; 19 | } 20 | if (this.max && value.length > this.max) { 21 | return 'should be a string with length <= ' + this.max; 22 | } 23 | }, 24 | toJSONSchema: function() { 25 | var schema = { type: 'string' }; 26 | 27 | if (this.min) { 28 | schema.minLength = this.min; 29 | } 30 | 31 | if (this.max) { 32 | schema.maxLength = this.max; 33 | } 34 | 35 | if (this.description) { 36 | schema.description = this.description; 37 | } 38 | 39 | return schema; 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /lib/matchers/url.js: -------------------------------------------------------------------------------- 1 | var url = require('url'); 2 | var factory = require('../factory'); 3 | 4 | var MESSAGE = 'should be a URL'; 5 | 6 | module.exports = factory({ 7 | initialize: function (opts) { 8 | this.opts = opts || {}; 9 | this.description = this.opts.description; 10 | }, 11 | match: function(path, value) { 12 | var u, valid; 13 | if (typeof value !== 'string') { 14 | return MESSAGE; 15 | } 16 | 17 | u = url.parse(value); 18 | valid = u.protocol && u.host && u.pathname; 19 | if (!valid) { 20 | return MESSAGE; 21 | } 22 | }, 23 | toJSONSchema: function() { 24 | var schema = { 25 | type: 'string', 26 | format: 'url' 27 | }; 28 | if (this.description) { 29 | schema.description = this.description; 30 | } 31 | return schema; 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /lib/matchers/uuid.js: -------------------------------------------------------------------------------- 1 | var factory = require('../factory'); 2 | var regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; 3 | 4 | module.exports = factory({ 5 | initialize: function(opts) { 6 | this.opts = opts || {}; 7 | this.version = this.opts.version; 8 | this.description = this.opts.description; 9 | this.message = 'should be a UUID' + (this.version ? ' version ' + this.version : ''); 10 | }, 11 | match: function(path, value) { 12 | if (typeof value !== 'string') { 13 | return this.message; 14 | } 15 | if (regex.test(value) === false) { 16 | return this.message; 17 | } 18 | // UUID version 19 | var version = value[14]; 20 | if (this.version && this.version.toString() !== version) { 21 | return this.message; 22 | } 23 | }, 24 | toJSONSchema: function () { 25 | var format = 'uuid' 26 | if (this.version) { 27 | format += '-v' + this.version 28 | } 29 | var schema = { 30 | type: 'string', 31 | format: format 32 | }; 33 | if (this.description) { 34 | schema.description = this.description; 35 | } 36 | return schema; 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /lib/matchers/value.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var factory = require('../factory'); 3 | 4 | module.exports = factory({ 5 | initialize: function (expectedValue, opts) { 6 | this.opts = opts || {}; 7 | this.description = this.opts.description; 8 | if (typeof expectedValue === 'undefined' || _.isObject(expectedValue)) { 9 | throw new Error('must provide a primitive value to match'); 10 | } 11 | this.expectedValue = expectedValue; 12 | }, 13 | match: function(path, value) { 14 | if (value !== this.expectedValue) { 15 | return 'should strict equal ' + String(this.expectedValue); 16 | } 17 | }, 18 | toJSONSchema: function () { 19 | var schema = { 'enum': [this.expectedValue] }; 20 | if (this.description) { 21 | schema.description = this.description; 22 | } 23 | return schema; 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /lib/strummer.js: -------------------------------------------------------------------------------- 1 | var inspect = require('util-inspect'); 2 | var factory = require('./factory'); 3 | var index = require('./index'); 4 | var compile = require('./compile'); 5 | var Matcher = require('./matcher'); 6 | 7 | // s(...) compiles the matcher 8 | module.exports = exports = function(name, spec) { 9 | if (arguments.length === 1) { 10 | spec = name; 11 | name = null; 12 | } 13 | 14 | var matcher = compile.spec(spec); 15 | 16 | if (name) { 17 | matcher.setName(name); 18 | } 19 | 20 | return matcher; 21 | }; 22 | 23 | // expose s.Matcher so people can create custom matchers 24 | exports.Matcher = Matcher; 25 | 26 | exports.createMatcher = factory; 27 | 28 | // we also expose s.string, s.number, ... 29 | // they are required inline here and stored in another module to break some cyclic dependencies 30 | index.matchers = { 31 | array: require('./matchers/array'), 32 | boolean: require('./matchers/boolean'), 33 | duration: require('./matchers/duration'), 34 | email: require('./matchers/email'), 35 | enum: require('./matchers/enum'), 36 | func: require('./matchers/func'), 37 | hashmap: require('./matchers/hashmap'), 38 | integer: require('./matchers/integer'), 39 | ip: require('./matchers/ip'), 40 | isoDate: require('./matchers/isoDate'), 41 | number: require('./matchers/number'), 42 | object: require('./matchers/object'), 43 | objectWithOnly: require('./matchers/objectWithOnly'), 44 | oneOf: require('./matchers/oneOf'), 45 | optional: require('./matchers/optional'), 46 | regex: require('./matchers/regex'), 47 | string: require('./matchers/string'), 48 | url: require('./matchers/url'), 49 | uuid: require('./matchers/uuid'), 50 | value: require('./matchers/value') 51 | } 52 | 53 | for (var matcherName in index.matchers) { 54 | exports[matcherName] = index.matchers[matcherName]; 55 | } 56 | 57 | // s.assert() for easy unit tests 58 | exports.assert = function(value, matcher) { 59 | var errors = compile.spec(matcher).match('', value); 60 | if (errors.length > 0) { 61 | var errMessage = errors.map(function(err) { 62 | return err.path + ' ' + err.message + ' (was ' + inspect(err.value) + ')'; 63 | }).join('\n'); 64 | throw new Error(errMessage) 65 | }; 66 | }; 67 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | function hasValue(val) { 2 | return (typeof val !== 'undefined') && (val !== null); 3 | } 4 | 5 | function inherits(ctor, superCtor) { 6 | ctor.super_ = superCtor; 7 | ctor.prototype = Object.create(superCtor.prototype, { 8 | constructor: { 9 | value: ctor, 10 | enumerable: false, 11 | writable: true, 12 | configurable: true 13 | } 14 | }); 15 | } 16 | 17 | module.exports = { 18 | hasValue: hasValue, 19 | inherits: inherits 20 | }; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "strummer", 3 | "version": "2.10.0", 4 | "description": "Structural matching for JavaScript", 5 | "author": "Tabcorp Digital Technology Team", 6 | "license": "MIT", 7 | "main": "lib/strummer.js", 8 | "scripts": { 9 | "cover": "nyc node_modules/mocha/bin/_mocha", 10 | "coverage": "nyc report", 11 | "test": "make test", 12 | "lint": "make lint", 13 | "prepublish": "make test && make lint" 14 | }, 15 | "repository": "Tabcorp/strummer", 16 | "devDependencies": { 17 | "mocha": "~8.3.2", 18 | "should": "~13.2.3", 19 | "cli-table": "~0.3.0", 20 | "format-number": "~3.0.0", 21 | "require-dir": "~1.2.0", 22 | "eslint": "~7.25.0", 23 | "nyc": "^15.0.0" 24 | }, 25 | "dependencies": { 26 | "escape-string-regexp": "^4.0.0", 27 | "isarray": "^2.0.2", 28 | "lodash": "~4.17.2", 29 | "ms": "~2.1.1", 30 | "url": "^0.11.0", 31 | "util-inspect": "^0.1.8" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /performance-test/performance.spec.js: -------------------------------------------------------------------------------- 1 | var Table = require('cli-table'); 2 | var format = require('format-number'); 3 | var s = require('../lib/strummer'); 4 | 5 | describe('Performance', function() { 6 | 7 | this.slow(1000); 8 | this.timeout(2000); 9 | 10 | var schema = new s.object({ 11 | id: new s.uuid({version: 4}), 12 | name: 'string', 13 | age: new s.number({optional: true, min: 1, max: 100}), 14 | addresses: new s.array({of: { 15 | type: 'string', 16 | city: 'string', 17 | postcode: 'number' 18 | }}), 19 | nicknames: [{max: 3, of: 'string'}], 20 | phones: [{of: { 21 | type: new s.enum({values: ['MOBILE', 'HOME']}), 22 | number: /^[0-9]{10}$/ 23 | }}] 24 | }); 25 | 26 | var invalidObject = { 27 | id: '3c8a90dd-11b8-47c3-a88e-67e92b097c7a', 28 | name: 'John Doe', 29 | age: 30, 30 | addresses: [{ 31 | type: 'billing', 32 | city: 'Sydney', 33 | postcode: 2000 34 | },{ 35 | type: 'delivery', 36 | city: 'New York' 37 | }], 38 | nicknames: ['Jon', 'Johnny', false], 39 | phones: [ 40 | { type: 'HOME', number: '0233334444' }, 41 | { type: 'MOBILE', number: '0455556666' }, 42 | { type: 'OTHER', number: '0000000000' } 43 | ] 44 | }; 45 | 46 | it('generates stats for the README', function() { 47 | verifyResults(); 48 | var table = new Table({head: ['Number of validations', 'Total time (ms)']}); 49 | run(table, 100); 50 | run(table, 1000); 51 | run(table, 10000); 52 | console.error('\n' + table.toString() + '\n'); 53 | }); 54 | 55 | function run(table, count) { 56 | var start = new Date(); 57 | for (var i = 0; i < count; ++i) { 58 | var errors = schema.match('', invalidObject); 59 | } 60 | var end = new Date(); 61 | table.push([format()(count), end-start]); 62 | } 63 | 64 | function verifyResults() { 65 | var errors = schema.match('', invalidObject); 66 | errors.should.eql( 67 | [{ 68 | path: 'addresses[1].postcode', 69 | value: undefined, 70 | message: 'should be a number' 71 | }, { 72 | path: 'nicknames[2]', 73 | value: false, 74 | message: 'should be a string' 75 | }, { 76 | path: 'phones[2].type', 77 | value: 'OTHER', 78 | message: 'should be a valid enum value' 79 | }] 80 | ); 81 | } 82 | 83 | }); 84 | -------------------------------------------------------------------------------- /test/compile.spec.js: -------------------------------------------------------------------------------- 1 | var compile = require('../lib/compile'); 2 | var factory = require('../lib/factory'); 3 | var objectMatcher = require('../lib/matchers/object'); 4 | 5 | describe("Compile",function(){ 6 | 7 | describe("spec",function(){ 8 | 9 | it("should throw an exception if the object being passed does not match any of the matchers",function(){ 10 | var someObjectNotAMatcher = { 11 | name: false 12 | }; 13 | 14 | (function(){ 15 | compile.spec(someObjectNotAMatcher); 16 | }).should.throw('Invalid matcher: false'); 17 | }); 18 | 19 | it("should use the object being passed as matcher if it is a strummer matcher",function(){ 20 | var DummyMatcher = factory({ 21 | match: function(){ 22 | return false; 23 | } 24 | }); 25 | var expectedMatcher = new DummyMatcher(); 26 | 27 | var actualMatcher = compile.spec(expectedMatcher); 28 | 29 | actualMatcher.should.equal(expectedMatcher); 30 | }); 31 | 32 | it("should create an object matcher if the object being passed matches the object matcher",function(){ 33 | var objectWithoutAMatchMethod = {}; 34 | 35 | var actualMatcher = compile.spec(objectWithoutAMatchMethod); 36 | 37 | actualMatcher.should.instanceof(objectMatcher) 38 | }); 39 | 40 | }); 41 | 42 | }); 43 | -------------------------------------------------------------------------------- /test/matcher.spec.js: -------------------------------------------------------------------------------- 1 | var Matcher = require('../lib/matcher'); 2 | var factory = require('../lib/factory'); 3 | 4 | describe('Matcher', function() { 5 | 6 | describe('is', function() { 7 | 8 | it('returns false when it is compared to null', function() { 9 | Matcher.is(null).should.eql(false); 10 | }); 11 | 12 | it('returns true when a strummer matcher is passed', function() { 13 | var DummyMatcher = factory({ 14 | match: function() { 15 | return false; 16 | } 17 | }); 18 | 19 | Matcher.is(new DummyMatcher()).should.eql(true); 20 | }); 21 | 22 | }); 23 | 24 | describe('#toJSONSchema', function() { 25 | 26 | it('generates named json schema when it have name', function() { 27 | var DummyMatcher = factory({ 28 | match: function() { 29 | return true; 30 | }, 31 | toJSONSchema: function() { 32 | return { 33 | type: 'number' 34 | }; 35 | } 36 | }); 37 | 38 | var m = new DummyMatcher(); 39 | m.setName('Dummy'); 40 | m.toJSONSchema().should.eql({ 41 | name: 'Dummy', 42 | type: 'number' 43 | }); 44 | }); 45 | 46 | }); 47 | 48 | }); 49 | -------------------------------------------------------------------------------- /test/matchers/array.spec.js: -------------------------------------------------------------------------------- 1 | require('../../lib/strummer'); 2 | var array = require('../../lib/matchers/array'); 3 | var string = require('../../lib/matchers/string'); 4 | var Matcher = require('../../lib/matcher'); 5 | 6 | describe('array matcher', function() { 7 | 8 | it('rejects anything that isnt an array', function() { 9 | var schema = new array({of: new string()}); 10 | schema.match('path', 'bob').should.eql([{ 11 | path: 'path', 12 | value: 'bob', 13 | message: 'should be an array' 14 | }]); 15 | }); 16 | 17 | it('validates arrays of matchers', function() { 18 | var schema = new array({of: new string()}); 19 | schema.match('path', ['bob', 3]).should.eql([{ 20 | path: 'path[1]', 21 | value: 3, 22 | message: 'should be a string' 23 | }]); 24 | }); 25 | 26 | it('validates arrays of objects', function() { 27 | var schema = new array({of: { 28 | name: 'string', 29 | age: 'number' 30 | }}); 31 | schema.match('people', [ 32 | { name: 'alice', age: 30 }, 33 | { name: 'bob', age: 'foo' } 34 | ]).should.eql([{ 35 | path: 'people[1].age', 36 | value: 'foo', 37 | message: 'should be a number' 38 | }]); 39 | }); 40 | 41 | it('can omit the keyword', function() { 42 | var schema = new array(new string()); 43 | schema.match('values', ['bob', 3]).should.eql([{ 44 | path: 'values[1]', 45 | value: 3, 46 | message: 'should be a string' 47 | }]); 48 | }); 49 | 50 | it('can specify the matcher name as a string', function() { 51 | var schema = new array('string'); 52 | schema.match('path', ['bob', 3]).should.eql([{ 53 | path: 'path[1]', 54 | value: 3, 55 | message: 'should be a string' 56 | }]); 57 | }); 58 | 59 | it('can specify the matcher name as a string', function() { 60 | var schema = new array({of: 'string'}); 61 | schema.match('path', ['bob', 3]).should.eql([{ 62 | path: 'path[1]', 63 | value: 3, 64 | message: 'should be a string' 65 | }]); 66 | }); 67 | 68 | it('validates min length of an array', function() { 69 | var schema = new array({of: new string(), min: 2}); 70 | schema.match('path', ['bob']).should.eql([{ 71 | path: 'path', 72 | value: ['bob'], 73 | message: 'should have at least 2 items' 74 | }]); 75 | }); 76 | 77 | it('validates max length of an array', function() { 78 | var schema = new array({of: new string(), max: 2}); 79 | schema.match('path', ['bob', 'the', 'builder']).should.eql([{ 80 | path: 'path', 81 | value: ['bob', 'the', 'builder'], 82 | message: 'should have at most 2 items' 83 | }]); 84 | }); 85 | 86 | it('rejects if min and max lengths options are violated', function() { 87 | var schema = new array({of: new string(), min: 1, max: 2}); 88 | schema.match('path', ['bob', 'the', 'builder']).should.eql([{ 89 | path: 'path', 90 | value: ['bob', 'the', 'builder'], 91 | message: 'should have between 1 and 2 items' 92 | }]); 93 | }); 94 | 95 | it('cannot be called with a litteral object matcher inside', function() { 96 | // because this would make of/min/max special keywords 97 | // and we wouldn't support arrays of objects with these properties 98 | (function() { 99 | new array({name: 'string'}); 100 | }).should.throw(/Invalid array matcher/); 101 | }); 102 | 103 | it('handles falsy return values from value matchers', function() { 104 | var valueMatcher = { 105 | __proto__: new Matcher({}), 106 | match: function() {} 107 | }; 108 | 109 | new array({ 110 | of: valueMatcher 111 | }).match('path', ['bob', 'the', 'builder']).should.eql([]); 112 | }); 113 | 114 | it('creates a simple array json schema', function() { 115 | new array({ of: 'string' }).toJSONSchema().should.eql({ 116 | type: 'array', 117 | items: { 118 | type: 'string' 119 | } 120 | }); 121 | }); 122 | 123 | it('creates a simple array json schema with optional description', function() { 124 | new array({ of: 'string', description: 'Lorem ipsum' }).toJSONSchema().should.eql({ 125 | type: 'array', 126 | description: 'Lorem ipsum', 127 | items: { 128 | type: 'string' 129 | } 130 | }); 131 | }); 132 | 133 | it('creates array json schema with minItems option', function() { 134 | new array({ of: 'string', min: 1 }).toJSONSchema().should.eql({ 135 | type: 'array', 136 | items: { 137 | type: 'string' 138 | }, 139 | minItems: 1 140 | }); 141 | }); 142 | 143 | it('creates array json schema with maxItems option', function() { 144 | new array({ of: 'string', min: 1, max: 999 }).toJSONSchema().should.eql({ 145 | type: 'array', 146 | items: { 147 | type: 'string' 148 | }, 149 | minItems: 1, 150 | maxItems: 999 151 | }); 152 | }); 153 | 154 | it('passes index of item to the matcher', function() { 155 | var valueMatcher = { 156 | __proto__: new Matcher({}), 157 | match: function(path, value, index) { 158 | return [{ path: path, value: value, message: value + " is number " + (index + 1)}] 159 | } 160 | }; 161 | 162 | new array({ 163 | of: valueMatcher 164 | }).match('path', ['bob']).should.eql([{ 165 | path: 'path[0]', 166 | value: 'bob', 167 | message: 'bob is number 1' 168 | }]); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /test/matchers/boolean.spec.js: -------------------------------------------------------------------------------- 1 | var BoolMatcher = require('../../lib/matchers/boolean'); 2 | 3 | describe('boolean matcher', function() { 4 | 5 | it('matches boolean', function() { 6 | new BoolMatcher().match('', true).should.not.have.error(); 7 | new BoolMatcher().match('', false).should.not.have.error(); 8 | }); 9 | 10 | it('fails for other types', function() { 11 | new BoolMatcher().match('', null).should.have.error(/should be a boolean/); 12 | new BoolMatcher().match('', 'foo').should.have.error(/should be a boolean/); 13 | new BoolMatcher().match('', 1).should.have.error(/should be a boolean/); 14 | new BoolMatcher().match('', 'true').should.have.error(/should be a boolean/); 15 | }); 16 | 17 | it('can parse boolean from string', function() { 18 | new BoolMatcher({parse: true}).match('', true).should.not.have.error(); 19 | new BoolMatcher({parse: true}).match('', false).should.not.have.error(); 20 | new BoolMatcher({parse: true}).match('', 'true').should.not.have.error(); 21 | new BoolMatcher({parse: true}).match('', 'false').should.not.have.error(); 22 | new BoolMatcher({parse: true}).match('', 'TRUE').should.not.have.error(); 23 | new BoolMatcher({parse: true}).match('', 'FALSE').should.not.have.error(); 24 | }); 25 | 26 | it('fails if cannot be parsed as a boolean', function() { 27 | new BoolMatcher({parse: true}).match('', 'hello').should.have.error(/should be a boolean/); 28 | new BoolMatcher({parse: true}).match('', 1).should.have.error(/should be a boolean/); 29 | new BoolMatcher({parse: true}).match('', {hello: 'world'}).should.have.error(/should be a boolean/); 30 | new BoolMatcher({parse: true}).match('', null).should.have.error(/should be a boolean/); 31 | new BoolMatcher({parse: true}).match('', undefined).should.have.error(/should be a boolean/); 32 | }); 33 | 34 | it('generates the boolean json schema', function() { 35 | new BoolMatcher().toJSONSchema().should.eql({ 36 | type: 'boolean' 37 | }); 38 | }); 39 | 40 | it('generates the boolean json schema', function() { 41 | new BoolMatcher({ description: 'Lorem ipsum' }).toJSONSchema().should.eql({ 42 | type: 'boolean', 43 | description: 'Lorem ipsum' 44 | }); 45 | }); 46 | 47 | }); 48 | -------------------------------------------------------------------------------- /test/matchers/duration.spec.js: -------------------------------------------------------------------------------- 1 | var duration = require('../../lib/matchers/duration'); 2 | 3 | describe('duration matcher', function() { 4 | 5 | it('matches durations', function() { 6 | new duration().match('', '1s').should.not.have.error(); 7 | new duration().match('', '10m').should.not.have.error(); 8 | new duration().match('', '3h').should.not.have.error(); 9 | }); 10 | 11 | it('fails for other types', function() { 12 | new duration().match('', null).should.have.error(/should be a duration/); 13 | new duration().match('', 50).should.have.error(/should be a duration/); 14 | new duration().match('', 'a long time').should.have.error(/should be a duration/); 15 | }); 16 | 17 | it('can specify a min duration', function() { 18 | new duration({min: '1m'}).match('', '10s').should.have.error(/should be a duration >= 1m/); 19 | new duration({min: '1m'}).match('', '3m').should.not.have.error(); 20 | }); 21 | 22 | it('can specify a max duration', function() { 23 | new duration({max: '1m'}).match('', '10s').should.not.have.error(); 24 | new duration({max: '1m'}).match('', '3m').should.have.error(/should be a duration <= 1m/); 25 | }); 26 | 27 | it('can specify a min and max duration', function() { 28 | new duration({min: '1s', max: '1m'}).match('', '10s').should.not.have.error(); 29 | new duration({min: '1s', max: '1m'}).match('', '3m').should.have.error(/should be a duration between 1s and 1m/); 30 | }); 31 | 32 | it('only accepts valid min durations', function() { 33 | (function() { 34 | new duration({min: 10}); 35 | }).should.throw('Invalid minimum duration: 10'); 36 | (function() { 37 | new duration({min: 'Foo'}); 38 | }).should.throw('Invalid minimum duration: Foo'); 39 | }); 40 | 41 | it('only accepts valid max durations', function() { 42 | (function() { 43 | new duration({max: 20}); 44 | }).should.throw('Invalid maximum duration: 20'); 45 | (function() { 46 | new duration({max: 'Bar'}); 47 | }).should.throw('Invalid maximum duration: Bar'); 48 | }); 49 | 50 | it('generates a string schema with ms pattern', function() { 51 | var d = new duration().toJSONSchema(); 52 | var reg = new RegExp(d.pattern, 'i'); 53 | d.type.should.equal('string'); 54 | reg.test('2 days').should.be.true; 55 | reg.test('1s').should.be.true; 56 | }); 57 | 58 | it('generates a string schema with optional description', function() { 59 | var d = new duration({ description: 'Lorem ipsum' }).toJSONSchema(); 60 | d.type.should.equal('string'); 61 | d.description.should.equal('Lorem ipsum'); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/matchers/email.spec.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash') 2 | var email = require('../../lib/matchers/email'); 3 | 4 | describe('email matcher', function() { 5 | 6 | it('matches email', function() { 7 | email().match('path', 'foo@bar.com').should.not.have.error() 8 | email().match('path', 'FoO@bar.com.au').should.not.have.error() 9 | }); 10 | 11 | it('fails for other types', function() { 12 | var schema = email() 13 | schema.match('path', null).should.have.error('should be a valid email address'); 14 | schema.match('path', 50).should.have.error('should be a valid email address'); 15 | schema.match('path', 'a long time').should.have.error('should be a valid email address'); 16 | }); 17 | 18 | it('can specify a domain', function() { 19 | var schema = email({domain: 'bar.com'}) 20 | schema.match('path', 'foo@bar.com').should.not.have.error(); 21 | schema.match('path', 'foo@baz.com').should.have.error('should be a valid email address at bar.com'); 22 | }); 23 | 24 | it('only accepts valid domains', function() { 25 | (function() { 26 | email({domain: 'aue$aue.com'}); 27 | }).should.throw('Invalid domain value: aue$aue.com'); 28 | }); 29 | 30 | it('only accepts valid sized email ids', function() { 31 | invalidEmailId = randomString(65) + '@something.com'; 32 | email().match('', invalidEmailId).should.have.error('should be a valid email address'); 33 | }) 34 | 35 | it('only accepts valid sized domain parts', function() { 36 | invalidEmailId = 'foo@' + randomString(64) + '.com.au'; 37 | email().match('', invalidEmailId).should.have.error('should be a valid email address'); 38 | }) 39 | 40 | it('only accepts valid sized email addresses', function() { 41 | invalidEmailId = randomString(64) + '@' + randomString(63) + '.' + randomString(63) + '.' + randomString(58) + '.com'; 42 | email().match('', invalidEmailId).should.have.error('should be a valid email address'); 43 | }) 44 | 45 | it('generates a string schema with pattern', function() { 46 | var d = new email().toJSONSchema(); 47 | var reg = new RegExp(d.pattern, 'i'); 48 | d.type.should.equal('string'); 49 | reg.test('foo@bar.com').should.eql(true); 50 | reg.test('FoO.bAr1@baz.com').should.eql(true); 51 | }); 52 | 53 | it('generates a string schema optional description', function() { 54 | var d = new email({ description: 'Lorem ipsum' }).toJSONSchema(); 55 | d.type.should.equal('string'); 56 | d.description.should.equal('Lorem ipsum'); 57 | }); 58 | }); 59 | 60 | var randomString = function(length) { 61 | return _.fill(Array(length), 'a').join('') 62 | } 63 | -------------------------------------------------------------------------------- /test/matchers/enum.spec.js: -------------------------------------------------------------------------------- 1 | var enumer = require('../../lib/matchers/enum'); 2 | 3 | describe('enum matcher', function() { 4 | 5 | var valid = ['blue', 'red', 'green']; 6 | 7 | it('fails to create the match if the arguments are invalid', function() { 8 | (function() { 9 | new enumer({values: null}); 10 | }).should.throw('Invalid enum values: null'); 11 | (function() { 12 | new enumer({values: 'blue'}); 13 | }).should.throw('Invalid enum values: blue'); 14 | }); 15 | 16 | it('matches from a list of values', function() { 17 | new enumer({values: valid}).match('', 'blue').should.not.have.error(); 18 | new enumer({values: valid}).match('', 'red').should.not.have.error(); 19 | new enumer({values: valid}).match('', 'green').should.not.have.error(); 20 | new enumer({values: valid}).match('', 'yellow').should.have.error(/should be a valid enum value/); 21 | }); 22 | 23 | it('can give the enum a name for better errors', function() { 24 | m = new enumer({ 25 | values: valid, 26 | name: 'color' 27 | }); 28 | m.match('', 'yellow').should.have.error(/should be a valid color/); 29 | }); 30 | 31 | it('can return the full list of allowed values', function() { 32 | m = new enumer({ 33 | values: valid, 34 | verbose: true 35 | }); 36 | m.match('', 'yellow').should.have.error(/should be a valid enum value \(blue,red,green\)/); 37 | }); 38 | 39 | it('can combined both name and verbose', function() { 40 | m = new enumer({ 41 | values: valid, 42 | name: 'color', 43 | verbose: true 44 | }); 45 | m.match('', 'yellow').should.have.error(/should be a valid color \(blue,red,green\)/); 46 | }); 47 | 48 | it('generates enum json schema', function() { 49 | new enumer({ 50 | values: ['foo', 'bar', 'brillian', 'kiddkai'] 51 | }).toJSONSchema().should.eql({ 52 | enum: ['foo', 'bar', 'brillian', 'kiddkai'] 53 | }); 54 | }); 55 | 56 | it('generates enum json schema with optional description', function() { 57 | new enumer({ 58 | values: ['foo', 'bar', 'brillian', 'kiddkai'], 59 | description: 'Lorem ipsum' 60 | }).toJSONSchema().should.eql({ 61 | enum: ['foo', 'bar', 'brillian', 'kiddkai'], 62 | description: 'Lorem ipsum' 63 | }); 64 | }); 65 | 66 | it('generates enum json schema with optional type', function() { 67 | new enumer({ 68 | values: ['foo', 'bar', 'brillian', 'kiddkai'], 69 | type: 'string' 70 | }).toJSONSchema().should.eql({ 71 | enum: ['foo', 'bar', 'brillian', 'kiddkai'], 72 | type: 'string' 73 | }); 74 | }); 75 | 76 | }); 77 | -------------------------------------------------------------------------------- /test/matchers/func.spec.js: -------------------------------------------------------------------------------- 1 | var func = require('../../lib/matchers/func'); 2 | 3 | describe('func matcher', function() { 4 | 5 | function zero() {} 6 | function one(a) {} 7 | function two(a, b) {} 8 | 9 | it('matches functions', function() { 10 | new func().match('', zero).should.not.have.error(); 11 | new func().match('', one).should.not.have.error(); 12 | }); 13 | 14 | it('rejects anything else', function() { 15 | new func().match('', 123).should.have.error(/should be a function/); 16 | new func().match('', 'foo').should.have.error(/should be a function/); 17 | }); 18 | 19 | it('can specify the arity', function() { 20 | new func({arity: 0}).match('', zero).should.not.have.error(); 21 | new func({arity: 1}).match('', one).should.not.have.error(); 22 | new func({arity: 2}).match('', two).should.not.have.error(); 23 | new func({arity: 0}).match('', one).should.have.error(/should be a function with 0 parameters/); 24 | new func({arity: 1}).match('', two).should.have.error(/should be a function with 1 parameter/); 25 | new func({arity: 2}).match('', zero).should.have.error(/should be a function with 2 parameters/); 26 | }); 27 | 28 | }); 29 | -------------------------------------------------------------------------------- /test/matchers/hashmap.spec.js: -------------------------------------------------------------------------------- 1 | var s = require('../../lib/strummer'); 2 | var hashmap = require('../../lib/matchers/hashmap'); 3 | var Matcher = require('../../lib/matcher'); 4 | 5 | describe('hashmap matcher', function() { 6 | 7 | var OBJ = {one: 1, two: 2}; 8 | 9 | it('should be an object', function() { 10 | new hashmap().match('x', true).should.have.error('should be a hashmap'); 11 | }); 12 | 13 | describe('key and value types', function() { 14 | 15 | it('matches keys', function() { 16 | new hashmap({ 17 | keys: new s.string() 18 | }).match('x', OBJ).should.not.have.error(); 19 | new hashmap({keys: new s.regex(/n/)}).match('x', OBJ).should.eql([ 20 | {path: 'x.keys[1]', value: 'two', message: 'should match the regex /n/'} 21 | ]); 22 | }); 23 | 24 | it('matches values', function() { 25 | new hashmap({values: new s.number()}).match('x', OBJ).should.not.have.error(); 26 | new hashmap({values: new s.number({max: 1})}).match('x', OBJ).should.eql([ 27 | {path: 'x[two]', value: 2, message: 'should be a number <= 1'} 28 | ]); 29 | }); 30 | 31 | it('matches both keys and value types', function() { 32 | new hashmap({ 33 | keys: /n/, 34 | values: new s.number({max: 1}) 35 | }).match('x', OBJ).should.eql([ 36 | {path: 'x.keys[1]', value: 'two', message: 'should match the regex /n/'}, 37 | {path: 'x[two]', value: 2, message: 'should be a number <= 1'} 38 | ]); 39 | }); 40 | 41 | 42 | }); 43 | 44 | describe('syntactic sugar', function() { 45 | 46 | it('accepts primitive keys and value types', function() { 47 | new hashmap({ 48 | keys: /n/, 49 | values: 'boolean' 50 | }).match('x', OBJ).should.eql([ 51 | {path: 'x.keys[1]', value: 'two', message: 'should match the regex /n/'}, 52 | {path: 'x[one]', value: 1, message: 'should be a boolean'}, 53 | {path: 'x[two]', value: 2, message: 'should be a boolean'} 54 | ]); 55 | }); 56 | 57 | it('can specify just the value type', function() { 58 | new hashmap(new s.number()).match('x', OBJ).should.not.have.error(); 59 | new hashmap(new s.boolean()).match('x', OBJ).should.eql([ 60 | {path: 'x[one]', value: 1, message: 'should be a boolean'}, 61 | {path: 'x[two]', value: 2, message: 'should be a boolean'} 62 | ]); 63 | }); 64 | 65 | it('can specify just the value type as a string', function() { 66 | new hashmap('number').match('x', OBJ).should.not.have.error(); 67 | new hashmap('boolean').match('x', OBJ).should.eql([ 68 | {path: 'x[one]', value: 1, message: 'should be a boolean'}, 69 | {path: 'x[two]', value: 2, message: 'should be a boolean'} 70 | ]); 71 | }); 72 | 73 | it('handles falsy return values from value matchers', function() { 74 | var valueMatcher = { 75 | match: function() {}, 76 | __proto__: new Matcher({}) 77 | }; 78 | new hashmap({ 79 | values: valueMatcher 80 | }).match('x', { 81 | foo: 'bar' 82 | }).should.eql([]); 83 | }); 84 | 85 | }); 86 | 87 | describe('jsonschema', function() { 88 | it('creates json schema', function() { 89 | var matcher = new hashmap(); 90 | 91 | matcher.toJSONSchema().should.eql({ type: 'object' }); 92 | }); 93 | 94 | it('creates json schema when value shema is defined', function() { 95 | var matcher = new hashmap(new s.string()); 96 | 97 | matcher.toJSONSchema().should.eql({ 98 | type: 'object', 99 | additionalProperties: { type: 'string' } 100 | }); 101 | }); 102 | 103 | it('creates json schema when keys and values are defined', function() { 104 | var matcher = new hashmap({ 105 | keys: /n/, 106 | values: new s.number({max: 1}) 107 | }); 108 | 109 | matcher.toJSONSchema().should.eql({ 110 | type: 'object', 111 | additionalProperties: { type: 'number', maximum: 1 } 112 | }); 113 | }); 114 | 115 | it('creates json schema with description', function() { 116 | var matcher = new hashmap({ description: 'Lorem ipsum' }); 117 | 118 | matcher.toJSONSchema().should.eql({ 119 | type: 'object', 120 | description: 'Lorem ipsum' 121 | }); 122 | }); 123 | }); 124 | 125 | }); 126 | -------------------------------------------------------------------------------- /test/matchers/integer.spec.js: -------------------------------------------------------------------------------- 1 | var integer = require('../../lib/matchers/integer'); 2 | 3 | describe('integer matcher', function() { 4 | 5 | it('matches integers', function() { 6 | new integer().match('', 0).should.not.have.error(); 7 | new integer().match('', 3).should.not.have.error(); 8 | new integer().match('', -1).should.not.have.error(); 9 | }); 10 | 11 | it('fails for other types', function() { 12 | new integer().match('', null).should.have.error(/should be an integer/); 13 | new integer().match('', 'foo').should.have.error(/should be an integer/); 14 | new integer().match('', true).should.have.error(/should be an integer/); 15 | new integer().match('', 3.5).should.have.error(/should be an integer/); 16 | }); 17 | 18 | it('supports min and max', function() { 19 | new integer({min: 3}).match('', 0).should.have.error(/should be an integer >= 3/); 20 | new integer({max: 3}).match('', 5).should.have.error(/should be an integer <= 3/); 21 | new integer({min: 3, max: 5}).match('', 7).should.have.error(/should be an integer between 3 and 5/); 22 | new integer({min: 0}).match('', -10).should.have.error(/should be an integer >= 0/); 23 | new integer({max: 0}).match('', 3).should.have.error(/should be an integer <= 0/); 24 | }); 25 | 26 | it('fails for invalid min or max values', function(){ 27 | var shouldFail = function(val) { 28 | (function(){ 29 | new integer({min: val}); 30 | }).should.throw('Invalid minimum option: ' + val); 31 | 32 | (function(){ 33 | new integer({max: val}); 34 | }).should.throw('Invalid maximum option: ' + val); 35 | } 36 | 37 | var invalidValues = ['a', '', {}, []]; 38 | invalidValues.forEach(shouldFail); 39 | }); 40 | 41 | it('can parse integer from string', function() { 42 | new integer({parse: true}).match('', 0).should.not.have.error(); 43 | new integer({parse: true}).match('', 3).should.not.have.error(); 44 | new integer({parse: true}).match('', -1).should.not.have.error(); 45 | new integer({parse: true}).match('', "0").should.not.have.error(); 46 | new integer({parse: true}).match('', "3").should.not.have.error(); 47 | new integer({parse: true}).match('', "-1").should.not.have.error(); 48 | new integer({parse: true}).match('', "+4").should.not.have.error(); 49 | }); 50 | 51 | it('fails if cannot be parsed to integer', function() { 52 | new integer({parse: true}).match('', null).should.have.error(/should be an integer/); 53 | new integer({parse: true}).match('', undefined).should.have.error(/should be an integer/); 54 | new integer({parse: true}).match('', false).should.have.error(/should be an integer/); 55 | new integer({parse: true}).match('', true).should.have.error(/should be an integer/); 56 | new integer({parse: true}).match('', 1.2).should.have.error(/should be an integer/); 57 | new integer({parse: true}).match('', "1.2").should.have.error(/should be an integer/); 58 | new integer({parse: true}).match('', "hello").should.have.error(/should be an integer/); 59 | new integer({parse: true}).match('', {hello: 'world'}).should.have.error(/should be an integer/); 60 | new integer({parse: true}).match('', "4L").should.have.error(/should be an integer/); 61 | }); 62 | 63 | 64 | it('can converts to json-shema', function() { 65 | integer({ min: 1, max: 100 }).toJSONSchema().should.eql({ 66 | type: 'integer', 67 | maximum: 100, 68 | minimum: 1 69 | }); 70 | }); 71 | 72 | it('can have optional maximum in json-schema', function() { 73 | integer({ min: 1 }).toJSONSchema().should.eql({ 74 | type: 'integer', 75 | minimum: 1 76 | }); 77 | }); 78 | 79 | it('can have optional minimum in json-schema', function() { 80 | integer({ max: 100 }).toJSONSchema().should.eql({ 81 | type: 'integer', 82 | maximum: 100 83 | }); 84 | }); 85 | 86 | it('can have no limit integer json-schema', function() { 87 | integer().toJSONSchema().should.eql({ 88 | type: 'integer' 89 | }); 90 | }); 91 | 92 | it('can have optional description json-schema', function() { 93 | integer({ description: 'Lorem ipsum' }).toJSONSchema().should.eql({ 94 | type: 'integer', 95 | description: 'Lorem ipsum' 96 | }); 97 | }); 98 | 99 | }); 100 | -------------------------------------------------------------------------------- /test/matchers/ip.spec.js: -------------------------------------------------------------------------------- 1 | var IPMatcher = require('../../lib/matchers/ip'); 2 | 3 | describe('ip address matcher', function() { 4 | it('matches ip address', function() { 5 | new IPMatcher({version: 4}).match('', '0.0.0.0').should.not.have.error(); 6 | new IPMatcher({version: 4}).match('', '255.255.255.255').should.not.have.error(); 7 | new IPMatcher({version: 4}).match('', '127.0.0.1').should.not.have.error(); 8 | new IPMatcher({version: 4}).match('', '192.168.0.1').should.not.have.error(); 9 | }); 10 | 11 | it('should fail with incorrect format', function() { 12 | new IPMatcher({version: 4}).match('', '2607:f0d0:1002:51::4').should.have.error('should be a valid IPv4 address'); 13 | new IPMatcher({version: 4}).match('', '0.0.0.0.0').should.have.error('should be a valid IPv4 address'); 14 | new IPMatcher({version: 4}).match('', '0.0.0').should.have.error('should be a valid IPv4 address'); 15 | }); 16 | 17 | it('should fail with invalid ip', function() { 18 | new IPMatcher({version: 4}).match('', '256.0.0.0').should.have.error('should be a valid IPv4 address'); 19 | new IPMatcher({version: 4}).match('', '0.0.0.00').should.have.error('should be a valid IPv4 address'); 20 | }); 21 | 22 | it('should fail without correct version argument', function() { 23 | (function() { 24 | new IPMatcher(); 25 | }).should.throw('Must be initialized with version 4'); 26 | (function() { 27 | new IPMatcher({version: 6}); 28 | }).should.throw('Must be initialized with version 4'); 29 | }) 30 | 31 | it('returns pattern as json schema', function() { 32 | new IPMatcher({version: 4}).toJSONSchema().should.eql({ 33 | type: 'string', 34 | pattern: '^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$' 35 | }); 36 | }); 37 | 38 | it('returns optional description as json schema', function() { 39 | new IPMatcher({version: 4, description: 'Lorem ipsum'}).toJSONSchema().should.eql({ 40 | type: 'string', 41 | pattern: '^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$', 42 | description: 'Lorem ipsum' 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/matchers/isoDate.spec.js: -------------------------------------------------------------------------------- 1 | var date = require('../../lib/matchers/isoDate'); 2 | 3 | describe('iso date matcher', function() { 4 | 5 | it('matches full ISO8601 date format', function() { 6 | new date().match('', '1000-00-00T00:00:00.000Z').should.not.have.error(); 7 | new date().match('', '2999-12-31T23:59:59.999Z').should.not.have.error(); 8 | new date().match('', '1000-00-00T00:00:00.000+00:00').should.not.have.error(); 9 | new date().match('', '2999-12-31T23:59:59.999-00:00').should.not.have.error(); 10 | new date().match('', '1000-00-00T00:00:00.000+10:24').should.not.have.error(); 11 | new date().match('', '2999-12-31T23:59:59.999-01:28').should.not.have.error(); 12 | new date().match('', '2999-12-31T23:59:59.9999-01:28').should.not.have.error(); 13 | }); 14 | 15 | it('supports optional GMT sign', function() { 16 | new date().match('', '2999-12-31T23:59:59.999').should.not.have.error(); 17 | }); 18 | 19 | it('supports optional seconds', function() { 20 | new date().match('', '2999-12-31T23:59').should.not.have.error(); 21 | }); 22 | 23 | it('supports optional milliseconds', function() { 24 | new date().match('', '2999-12-31T23:59:59').should.not.have.error(); 25 | }); 26 | 27 | it('does not match other date strings', function() { 28 | new date().match('', '31-12-2014').should.have.error(/should be a date with time in ISO8601 format/); 29 | new date().match('', '2014-12-31 23:59').should.have.error(/should be a date with time in ISO8601 format/); 30 | }); 31 | 32 | it('does not match values that are not strings', function() { 33 | new date().match('', 20141231).should.have.error(/should be a date in ISO8601 format/); 34 | new date().match('', null).should.have.error(/should be a date in ISO8601 format/); 35 | }); 36 | 37 | it("matches just the date if that's all is requested", function() { 38 | new date({time: false}).match('', '2999-12-31').should.not.have.error(); 39 | }); 40 | 41 | it("does not match invalid dates when the time is not required", function() { 42 | new date({time: false}).match('', '2999/12/31').should.have.error(/should be a date in ISO8601 format/); 43 | }); 44 | 45 | it('respects the time flag if explicitly used', function() { 46 | new date({time: true}).match('', '2999-12-31').should.have.error(/should be a date with time in ISO8601 format/); 47 | }); 48 | 49 | it('generates json schema with specific format', function() { 50 | new date().toJSONSchema().should.eql({ 51 | type: 'string', 52 | format: 'ISO8601' 53 | }); 54 | }); 55 | 56 | it('generates json schema with specific format', function() { 57 | new date({ description: 'Lorem ipsum' }).toJSONSchema().should.eql({ 58 | type: 'string', 59 | format: 'ISO8601', 60 | description: 'Lorem ipsum' 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/matchers/number.spec.js: -------------------------------------------------------------------------------- 1 | var number = require('../../lib/matchers/number'); 2 | 3 | describe('number matcher', function() { 4 | 5 | it('matches integers and floats', function() { 6 | new number().match('', 0).should.not.have.error(); 7 | new number().match('', 3).should.not.have.error(); 8 | new number().match('', 3.5).should.not.have.error(); 9 | }); 10 | 11 | it('fails for other types', function() { 12 | new number().match('', null).should.have.error(/should be a number/); 13 | new number().match('', 'foo').should.have.error(/should be a number/); 14 | new number().match('', true).should.have.error(/should be a number/); 15 | }); 16 | 17 | it('supports min and max', function() { 18 | new number({min: 3}).match('', 0).should.have.error(/should be a number >= 3/); 19 | new number({min: 0}).match('', -10).should.have.error(/should be a number >= 0/); 20 | new number({max: 0}).match('', 12).should.have.error(/should be a number <= 0/); 21 | new number({max: 3}).match('', 5).should.have.error(/should be a number <= 3/); 22 | new number({min: 3, max: 5}).match('', 7).should.have.error(/should be a number between 3 and 5/); 23 | }); 24 | 25 | it('fails when max less than min', function() { 26 | (function() { 27 | new number({min: 5, max: 3}); 28 | }).should.throw(/Invalid option/); 29 | }); 30 | 31 | it('fails for invalid min or max values', function () { 32 | function shouldFail(val) { 33 | (function () { 34 | new number({min: val}); 35 | }).should.throw('Invalid minimum option: ' + val); 36 | 37 | (function () { 38 | new number({max: val}); 39 | }).should.throw('Invalid maximum option: ' + val); 40 | } 41 | 42 | var invalidValues = ['a', '', {}, []]; 43 | invalidValues.forEach(shouldFail); 44 | }); 45 | 46 | it('can parse string into number', function() { 47 | new number({parse: true}).match('', 0).should.not.have.error(); 48 | new number({parse: true}).match('', 3).should.not.have.error(); 49 | new number({parse: true}).match('', 3.5).should.not.have.error(); 50 | new number({parse: true}).match('', '0').should.not.have.error(); 51 | new number({parse: true}).match('', '3').should.not.have.error(); 52 | new number({parse: true}).match('', '3.5').should.not.have.error(); 53 | new number({parse: true}).match('', '-3.5').should.not.have.error(); 54 | new number({parse: true}).match('', '+3.5').should.not.have.error(); 55 | }); 56 | 57 | it('fails for values that cannot be parsed into a number', function() { 58 | new number({parse: true}).match('', null).should.have.error(/should be a number/); 59 | new number({parse: true}).match('', undefined).should.have.error(/should be a number/); 60 | new number({parse: true}).match('', "hello").should.have.error(/should be a number/); 61 | new number({parse: true}).match('', {hello: 'world'}).should.have.error(/should be a number/); 62 | new number({parse: true}).match('', false).should.have.error(/should be a number/); 63 | new number({parse: true}).match('', true).should.have.error(/should be a number/); 64 | }); 65 | 66 | it('can converts to json-shema', function() { 67 | number({ min: 1, max: 100 }).toJSONSchema().should.eql({ 68 | type: 'number', 69 | maximum: 100, 70 | minimum: 1 71 | }); 72 | }); 73 | 74 | it('can have optional maximum in json-schema', function() { 75 | number({ min: 1 }).toJSONSchema().should.eql({ 76 | type: 'number', 77 | minimum: 1 78 | }); 79 | }); 80 | 81 | it('can have optional minimum in json-schema', function() { 82 | number({ max: 100 }).toJSONSchema().should.eql({ 83 | type: 'number', 84 | maximum: 100 85 | }); 86 | }); 87 | 88 | it('can have optional description in json-schema', function() { 89 | number({ description: 'Lorem ipsum' }).toJSONSchema().should.eql({ 90 | type: 'number', 91 | description: 'Lorem ipsum' 92 | }); 93 | }); 94 | 95 | it('can have no limit number json-schema', function() { 96 | number().toJSONSchema().should.eql({ 97 | type: 'number' 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /test/matchers/object.spec.js: -------------------------------------------------------------------------------- 1 | require('../../lib/strummer'); 2 | var object = require('../../lib/matchers/object'); 3 | var string = require('../../lib/matchers/string'); 4 | var number = require('../../lib/matchers/number'); 5 | var factory = require('../../lib/factory'); 6 | var Matcher = require('../../lib/matcher'); 7 | 8 | describe('object matcher', function() { 9 | 10 | it('rejects null values', function() { 11 | var schema = new object({ 12 | name: new string(), 13 | age: new number() 14 | }); 15 | 16 | schema.match('path.to.something', null).should.eql([{ 17 | path: 'path.to.something', 18 | value: null, 19 | message: 'should be an object' 20 | }]); 21 | }); 22 | 23 | it('rejects anything that isnt an object', function() { 24 | var schema = new object({ 25 | name: new string(), 26 | age: new number() 27 | }); 28 | schema.match('', 'bob').should.eql([{ 29 | path: '', 30 | value: 'bob', 31 | message: 'should be an object' 32 | }]); 33 | }); 34 | 35 | it('validates flat objects', function() { 36 | var schema = new object({ 37 | name: new string(), 38 | age: new number() 39 | }); 40 | 41 | schema.match('', { 42 | name: 'bob', 43 | age: 'foo' 44 | }).should.eql([{ 45 | path: 'age', 46 | value: 'foo', 47 | message: 'should be a number' 48 | }]); 49 | }); 50 | 51 | it('prepends the root path to the error path', function() { 52 | var schema = new object({ 53 | name: new string(), 54 | age: new number() 55 | }); 56 | 57 | schema.match('root', { 58 | name: 'bob', 59 | age: 'foo' 60 | }).should.eql([{ 61 | path: 'root.age', 62 | value: 'foo', 63 | message: 'should be a number' 64 | }]); 65 | }); 66 | 67 | it('validates nested objects', function() { 68 | var schema = new object({ 69 | name: new string(), 70 | address: { 71 | street: new string(), 72 | city: new string(), 73 | postcode: new number() 74 | } 75 | }); 76 | 77 | schema.match('', { 78 | name: 'bob', 79 | address: { 80 | street: 'Pitt St', 81 | city: null, 82 | postcode: 'foo' 83 | } 84 | }).should.eql([{ 85 | path: 'address.city', 86 | value: null, 87 | message: 'should be a string' 88 | }, { 89 | path: 'address.postcode', 90 | value: 'foo', 91 | message: 'should be a number' 92 | }]); 93 | }); 94 | 95 | 96 | it('supports syntactic sugar by calling s() on each matcher', function() { 97 | var schema = new object({ 98 | name: 'string', 99 | address: { 100 | street: 'string', 101 | city: 'string', 102 | postcode: 'number' 103 | } 104 | }); 105 | 106 | schema.match('', { 107 | name: 'bob', 108 | address: { 109 | street: 'Pitt St', 110 | city: null, 111 | postcode: 'foo' 112 | } 113 | }).should.eql([{ 114 | path: 'address.city', 115 | value: null, 116 | message: 'should be a string' 117 | }, 118 | { 119 | path: 'address.postcode', 120 | value: 'foo', 121 | message: 'should be a number' 122 | }]); 123 | }); 124 | 125 | it('handles falsy return values from value matchers', function() { 126 | var valueMatcher = { 127 | __proto__: new Matcher({}), 128 | match: function() {} 129 | }; 130 | 131 | new object({ 132 | name: valueMatcher 133 | }).match('', { 134 | name: 'bob' 135 | }).should.eql([]); 136 | }); 137 | 138 | it('creates json schema with all required fields', function() { 139 | var matcher = new object({ 140 | foo: 'string', 141 | bar: 'number' 142 | }); 143 | 144 | matcher.toJSONSchema().required.should.containEql('foo'); 145 | matcher.toJSONSchema().required.should.containEql('bar'); 146 | }); 147 | 148 | it('creates json schema without optional fields', function() { 149 | var matcher = new object({ 150 | foo: string({ optional: true }), 151 | bar: 'number' 152 | }); 153 | 154 | matcher.toJSONSchema().required.should.not.containEql('foo'); 155 | matcher.toJSONSchema().required.should.containEql('bar'); 156 | }); 157 | 158 | it('creates json schema with all optional fields', function() { 159 | var matcher = new object({ 160 | foo: string({ optional: true }) 161 | }); 162 | 163 | matcher.toJSONSchema().should.not.have.property('required'); 164 | }); 165 | 166 | it('generate json schema properties', function() { 167 | var matcher = new object({ 168 | foo: number({ max: 100, min: 1 }) 169 | }); 170 | 171 | matcher.toJSONSchema().should.eql({ 172 | type: 'object', 173 | required: ['foo'], 174 | properties: { 175 | foo: { 176 | type: 'number', 177 | maximum: 100, 178 | minimum: 1 179 | } 180 | } 181 | }); 182 | }); 183 | 184 | it('generate json schema with description option', function() { 185 | var matcher = new object( 186 | { foo: number({ max: 100, min: 1 }) }, 187 | { description: 'Lorem ipsum' } 188 | ); 189 | 190 | matcher.toJSONSchema().should.eql({ 191 | type: 'object', 192 | required: ['foo'], 193 | description: 'Lorem ipsum', 194 | properties: { 195 | foo: { 196 | type: 'number', 197 | maximum: 100, 198 | minimum: 1 199 | } 200 | } 201 | }); 202 | }); 203 | 204 | it('will not generate json schema for the property which generate nothing', function() { 205 | var custom = factory({ 206 | initialize: function() {}, 207 | match: function() {} 208 | }); 209 | 210 | var matcher = new object({ 211 | foo: number({ max: 100, min: 1 }), 212 | bar: custom() 213 | }); 214 | 215 | matcher.toJSONSchema().should.eql({ 216 | type: 'object', 217 | required: ['foo'], 218 | properties: { 219 | foo: { 220 | type: 'number', 221 | maximum: 100, 222 | minimum: 1 223 | } 224 | } 225 | }); 226 | }); 227 | }); 228 | -------------------------------------------------------------------------------- /test/matchers/objectWithOnly.spec.js: -------------------------------------------------------------------------------- 1 | var objectWithOnly = require('../../lib/matchers/objectWithOnly'); 2 | var array = require('../../lib/matchers/array'); 3 | var string = require('../../lib/matchers/string'); 4 | var number = require('../../lib/matchers/number'); 5 | var optional = require('../../lib/matchers/optional'); 6 | var Matcher = require('../../lib/matcher'); 7 | 8 | describe('objectWithOnly object matcher', function() { 9 | 10 | it('cannot be called with anything but an object matcher', function() { 11 | (function() { 12 | new objectWithOnly('string'); 13 | }).should.throw(/Invalid spec/); 14 | }); 15 | 16 | it('constraints must be a function', function() { 17 | (function() { 18 | new objectWithOnly({}, { constraints: 'asdf' }); 19 | }).should.throw(/Invalid constraints/); 20 | }); 21 | 22 | it('returns error if the object matcher returns one', function() { 23 | var schema = new objectWithOnly({ 24 | name: new string(), 25 | age: new number() 26 | }); 27 | 28 | schema.match('', 'bob').should.eql([{ 29 | path: '', 30 | value: 'bob', 31 | message: 'should be an object' 32 | }]); 33 | }); 34 | 35 | it('matches objects', function() { 36 | var schema = new objectWithOnly({ 37 | name: new string(), 38 | age: new number() 39 | }); 40 | 41 | schema.match('', {name: 'bob', age: 21}).should.not.have.error(); 42 | }); 43 | 44 | it('allows missing keys if they are optional', function() { 45 | var schema = new objectWithOnly({ 46 | name: new optional('string'), 47 | age: new number() 48 | }); 49 | 50 | schema.match('', {age: 21}).should.not.have.error(); 51 | }); 52 | 53 | it('rejects if there are extra keys', function() { 54 | var schema = new objectWithOnly({ 55 | name: new string(), 56 | age: new number() 57 | }); 58 | 59 | schema.match('', {name: 'bob', age: 21, email: 'bob@email.com'}).should.eql([{ 60 | path: 'email', 61 | value: 'bob@email.com', 62 | message: 'should not exist' 63 | }]) 64 | }); 65 | 66 | it('should not validate nested objects', function() { 67 | var schema = new objectWithOnly({ 68 | name: new string(), 69 | age: new number(), 70 | address: { 71 | email: new string() 72 | } 73 | }); 74 | 75 | var bob = { 76 | name: 'bob', 77 | age: 21, 78 | address: { 79 | email: 'bob@email.com', 80 | home: '21 bob street' 81 | } 82 | } 83 | 84 | schema.match('', bob).should.not.have.error() 85 | }); 86 | 87 | it('can be used within nested objects and arrays', function() { 88 | var schema = new objectWithOnly({ 89 | name: 'string', 90 | firstBorn: new objectWithOnly({ 91 | name: 'string', 92 | age: 'number' 93 | }), 94 | address: new array({of: new objectWithOnly({ 95 | city: 'string', 96 | postcode: 'number' 97 | })}) 98 | }) 99 | 100 | var bob = { 101 | name: 'bob', 102 | firstBorn: { 103 | name: 'jane', 104 | age: 3, 105 | email: 'jane@bobismydad.com' 106 | }, 107 | address: [{ 108 | city: 'gosford', 109 | postcode: 2250, 110 | street: 'watt st' 111 | }] 112 | } 113 | schema.match('', bob).should.eql([{ 114 | path: 'firstBorn.email', 115 | value: 'jane@bobismydad.com', 116 | message: 'should not exist' 117 | },{ 118 | path: 'address[0].street', 119 | value: 'watt st', 120 | message: 'should not exist' 121 | }]) 122 | }) 123 | 124 | it('handles falsy return values from value matchers', function() { 125 | var valueMatcher = { 126 | __proto__: new Matcher({}), 127 | match: function() {} 128 | }; 129 | 130 | new objectWithOnly({ 131 | name: valueMatcher 132 | }).match('', { 133 | name: 'bob' 134 | }).should.eql([]) 135 | }); 136 | 137 | it('generates the object json schema but with additionalProperties which sets false', function() { 138 | new objectWithOnly({ 139 | foo: 'string' 140 | }).toJSONSchema().should.eql({ 141 | type: 'object', 142 | properties: { 143 | foo: { 144 | type: 'string' 145 | } 146 | }, 147 | required: ['foo'], 148 | additionalProperties: false 149 | }); 150 | }); 151 | 152 | it('generates the object json schema with description option', function() { 153 | new objectWithOnly( 154 | { foo: 'string' }, 155 | { description: 'Lorem ipsum' } 156 | ).toJSONSchema().should.eql({ 157 | type: 'object', 158 | properties: { 159 | foo: { 160 | type: 'string' 161 | } 162 | }, 163 | description: 'Lorem ipsum', 164 | required: ['foo'], 165 | additionalProperties: false 166 | }); 167 | }) 168 | 169 | it('calls constraint function and returns errors', function() { 170 | var constraintFunc = function (path, value) { 171 | if (value.street_number && !value.post_code) { 172 | return [{ 173 | path: path, 174 | value: value, 175 | error: 'post_code is requried with a street_number' 176 | }] 177 | } 178 | 179 | return [] 180 | } 181 | 182 | var schema = new objectWithOnly({ 183 | email_address: new string(), 184 | street_number: new number({optional: true}), 185 | post_code: new number({optional: true}), 186 | }, { 187 | constraints: constraintFunc 188 | }); 189 | 190 | var value = { 191 | email_address: 'test@strummer.com', 192 | street_number: 12, 193 | } 194 | 195 | schema.match(value).should.eql([{ 196 | path: '', 197 | value: value, 198 | error: 'post_code is requried with a street_number' 199 | }]); 200 | }); 201 | 202 | it('returns empty array if no constraint errors', function() { 203 | var constraintFunc = function (path, val) { 204 | return [] 205 | } 206 | 207 | var schema = new objectWithOnly({ 208 | name: new string(), 209 | }, { 210 | constraints: constraintFunc 211 | }); 212 | 213 | schema.match('/', {name: 'works'}, constraintFunc).should.eql([]); 214 | }); 215 | 216 | }); 217 | -------------------------------------------------------------------------------- /test/matchers/oneof.spec.js: -------------------------------------------------------------------------------- 1 | var s = require('../../lib/strummer'); 2 | var oneOf = require('../../lib/matchers/oneOf'); 3 | 4 | describe('oneOf', function() { 5 | it('throws errors when passing a empty values', function() { 6 | (function() { 7 | oneOf(); 8 | }.should.throw()); 9 | 10 | (function() { 11 | oneOf(null); 12 | }.should.throw()); 13 | 14 | (function() { 15 | oneOf(0); 16 | }.should.throw()); 17 | 18 | (function() { 19 | oneOf(''); 20 | }.should.throw()); 21 | }); 22 | 23 | it('throws when passing a empty array', function() { 24 | (function() { 25 | oneOf([]); 26 | }.should.throw()); 27 | }); 28 | 29 | it('matches any one of the schemas', function() { 30 | oneOf(['string', 'number']).match(1).should.not.have.error(); 31 | }); 32 | 33 | it('will have errors when not matching any of the schemas', function() { 34 | oneOf(['string', 'number']).match({}).should.have.error(/is not valid under any of the given schemas/); 35 | }); 36 | 37 | it('can match either of two object schemas', function() { 38 | var schema = s.oneOf([ 39 | { foo: 'string' }, 40 | { bar: 'string' } 41 | ]) 42 | schema.match({foo: 'hello'}).should.not.have.error(); 43 | schema.match({bar: 'world'}).should.not.have.error(); 44 | schema.match({not: 'good'}).should.have.error(/is not valid under any of the given schemas/); 45 | }) 46 | 47 | it('can be used inside an array matcher', function() { 48 | var schema = s.array({of: s.oneOf([ 49 | { type: s.value('car'), make: s.enum({values: ['Ford', 'Mazda']}) }, 50 | { type: s.value('bike'), make: s.enum({values: ['Honda', 'Yamaha']}) } 51 | ])}) 52 | // every array item is valid 53 | schema.match([ 54 | { type: 'car', make: 'Ford' }, 55 | { type: 'bike', make: 'Honda' } 56 | ]).should.not.have.error(); 57 | // one item doesn't match particular properties 58 | schema.match([ 59 | { type: 'car', make: 'Ford' }, 60 | { type: 'bike', make: 'Ford' } 61 | ]).should.have.error(/bike/); 62 | // one item doesn't match any properties 63 | schema.match([ 64 | { type: 'car', make: 'Ford' }, 65 | { type: 'boat', make: 'Fairline' } 66 | ]).should.have.error(/boat/); 67 | }) 68 | 69 | it('generates oneOf json schema for mixed types', function() { 70 | oneOf(['string', 'number']).toJSONSchema().should.eql({ 71 | oneOf: [ 72 | { type: 'string' }, 73 | { type: 'number' } 74 | ] 75 | }); 76 | }); 77 | 78 | it('generates oneOf json schema for description', function() { 79 | oneOf(['string', 'number'], { description: 'Lorem ipsum' } ).toJSONSchema().should.eql({ 80 | oneOf: [ 81 | { type: 'string' }, 82 | { type: 'number' } 83 | ], 84 | description: 'Lorem ipsum' 85 | }); 86 | }); 87 | 88 | it('generates oneOf json schema for same types', function() { 89 | oneOf([{ foo: 'string' }, { bar: 'number' }]).toJSONSchema().should.eql({ 90 | type: 'object', 91 | oneOf: [ 92 | { 93 | type: 'object', 94 | properties: { 95 | foo: { type: 'string' } 96 | }, 97 | required: ['foo'] 98 | }, 99 | { 100 | type: 'object', 101 | properties: { 102 | bar: { type: 'number' } 103 | }, 104 | required: ['bar'] 105 | } 106 | ] 107 | }); 108 | }); 109 | 110 | it('generates a json schema with description option', function() { 111 | oneOf(['string', 'number'], { description: 'Lorem ipsum' } ).toJSONSchema().should.eql({ 112 | oneOf: [ 113 | { type: 'string' }, 114 | { type: 'number' } 115 | ], 116 | description: 'Lorem ipsum' 117 | }); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /test/matchers/optional.spec.js: -------------------------------------------------------------------------------- 1 | require('../../lib/strummer'); 2 | var object = require('../../lib/matchers/object'); 3 | var optional = require('../../lib/matchers/optional'); 4 | var string = require('../../lib/matchers/string'); 5 | 6 | describe('optional Matcher', function() { 7 | 8 | it('should set the optional flag', function() { 9 | var m = new optional(new string()); 10 | m.should.have.property('optional', true); 11 | }); 12 | 13 | it('should call the wrapped matcher', function() { 14 | var m = new optional(new string()); 15 | m.match(123).should.have.error('should be a string'); 16 | }); 17 | 18 | it('skips null values because of the base Matcher class', function() { 19 | var m = new optional(new string()); 20 | m.match(null).should.not.have.error(); 21 | }); 22 | 23 | it('compiles the wrapped matcher', function() { 24 | var m = new optional('string'); 25 | m.match(123).should.have.error('should be a string'); 26 | }); 27 | 28 | it('generates a optional json schema for property', function() { 29 | new object({ 30 | foo: 'string', 31 | bar: optional('string') 32 | })._toJSONSchema().should.eql({ 33 | type: 'object', 34 | required: ['foo'], 35 | properties: { 36 | foo: { 37 | type: 'string' 38 | }, 39 | bar: { 40 | type: 'string' 41 | } 42 | } 43 | }); 44 | }); 45 | 46 | }); 47 | -------------------------------------------------------------------------------- /test/matchers/regex.spec.js: -------------------------------------------------------------------------------- 1 | var regex = require('../../lib/matchers/regex'); 2 | 3 | describe('regex matcher', function() { 4 | 5 | it('must be passed a valid regex', function() { 6 | (function() { 7 | new regex(123); 8 | }).should.throw('Invalid regex matcher'); 9 | }); 10 | 11 | it('matches a given regex', function() { 12 | new regex(/[a-z]+/).match('', 'hello').should.not.have.error(); 13 | new regex(/[0-9]{2}/).match('', 'hello12world').should.not.have.error(); 14 | new regex(/^[a-z]+\d?$/).match('', 'hello1').should.not.have.error(); 15 | }); 16 | 17 | it('fails if the regex does not match', function() { 18 | new regex(/[a-z]+/).match('', '123').should.have.error('should match the regex /[a-z]+/'); 19 | }); 20 | 21 | it('fails for non string types', function() { 22 | new regex(/[a-z]+/).match('', null).should.have.error(/should be a string/); 23 | new regex(/[a-z]+/).match('', 123).should.have.error(/should be a string/); 24 | new regex(/[a-z]+/).match('', true).should.have.error(/should be a string/); 25 | }); 26 | 27 | it('returns a custom error message if specified', function () { 28 | new regex(/[a-z]+/, {errorMessage: 'Should only contain lower case letters'}) 29 | .match('', '123') 30 | .should.have.error(/Should only contain lower case letters/); 31 | }) 32 | 33 | it('generates string json schema with regex pattern', function() { 34 | new regex(/[a-z]+/)._toJSONSchema().should.eql({ 35 | type: 'string', 36 | pattern: '[a-z]+' 37 | }); 38 | }); 39 | 40 | it('generates a json schema with description option', function() { 41 | new regex(/[a-z]+/, {description: 'Lorem ipsum'})._toJSONSchema().should.eql({ 42 | type: 'string', 43 | pattern: '[a-z]+', 44 | description: 'Lorem ipsum' 45 | }); 46 | }); 47 | 48 | it('generates a json schema with errorMessage option', function() { 49 | new regex(/[a-z]+/, {errorMessage: 'Lorem ipsum'})._toJSONSchema().should.eql({ 50 | type: 'string', 51 | pattern: '[a-z]+', 52 | errorMessage: 'Lorem ipsum' 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/matchers/string.spec.js: -------------------------------------------------------------------------------- 1 | var string = require('../../lib/matchers/string'); 2 | 3 | describe('string matcher', function() { 4 | 5 | it('matches strings', function() { 6 | new string().match('', '').should.not.have.error(); 7 | new string().match('', 'hello').should.not.have.error(); 8 | new string().match('', 'h').should.not.have.error(); 9 | }); 10 | 11 | it('fails for other types', function() { 12 | new string().match('', null).should.have.error(/should be a string/); 13 | new string().match('', {hello: 'world'}).should.have.error(/should be a string/); 14 | new string().match('', true).should.have.error(/should be a string/); 15 | new string().match('', 3.5).should.have.error(/should be a string/); 16 | }); 17 | 18 | it('supports min and max', function() { 19 | new string({min: 3}).match('', "he").should.have.error(/should be a string with length >= 3/); 20 | new string({max: 3}).match('', "hello").should.have.error(/should be a string with length <= 3/); 21 | new string({min: 3, max: 5}).match('', "hello world").should.have.error(/should be a string with length between 3 and 5/); 22 | new string({min: 3, max: 5}).match('', "hell").should.not.have.an.error(); 23 | }); 24 | 25 | it('generate basic string json schema', function() { 26 | new string().toJSONSchema().should.eql({ 27 | type: 'string' 28 | }); 29 | }); 30 | 31 | it('generates a json schema with minLength option', function() { 32 | new string({ min: 3 }).toJSONSchema().should.eql({ 33 | type: 'string', 34 | minLength: 3 35 | }); 36 | }); 37 | 38 | it('generates a json schema with maxLength option', function() { 39 | new string({ max: 3 }).toJSONSchema().should.eql({ 40 | type: 'string', 41 | maxLength: 3 42 | }); 43 | }); 44 | 45 | it('generates a json schema with description option', function() { 46 | new string({ description: 'Lorem ipsum' }).toJSONSchema().should.eql({ 47 | type: 'string', 48 | description: 'Lorem ipsum' 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /test/matchers/url.spec.js: -------------------------------------------------------------------------------- 1 | var url = require('../../lib/matchers/url'); 2 | 3 | describe('url matcher', function() { 4 | 5 | it('has to be a string', function() { 6 | new url().match('', 123).should.have.error(/should be a URL/); 7 | new url().match('', false).should.have.error(/should be a URL/); 8 | }); 9 | 10 | it('matches urls', function() { 11 | new url().match('', 'http://www.google.com').should.not.have.error() 12 | new url().match('', 'https://www.google.com').should.not.have.error() 13 | new url().match('', 'http://localhost:1234').should.not.have.error() 14 | new url().match('', 'http://www.google.com/path/hello%20world?query+string').should.not.have.error() 15 | new url().match('', 'http://user:pass@server').should.not.have.error() 16 | new url().match('', 'postgres://host/database').should.not.have.error() 17 | }); 18 | 19 | it('fails for non urls', function() { 20 | new url().match('', 'almost/a/url').should.have.error(/should be a URL/); 21 | new url().match('', 'http://').should.have.error(/should be a URL/); 22 | new url().match('', 'redis://localhost').should.have.error(/should be a URL/); 23 | }); 24 | 25 | it('generates a url format string json schema', function() { 26 | new url().toJSONSchema().should.eql({ 27 | type: 'string', 28 | format: 'url' 29 | }); 30 | }); 31 | 32 | it('generates a json schema with description option', function() { 33 | new url({ description: 'Lorem ipsum' }).toJSONSchema().should.eql({ 34 | type: 'string', 35 | format: 'url', 36 | description: 'Lorem ipsum' 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/matchers/uuid.spec.js: -------------------------------------------------------------------------------- 1 | var uuid = require('../../lib/matchers/uuid'); 2 | 3 | describe('uuid matcher', function() { 4 | 5 | it('has to be a string', function() { 6 | new uuid().match('', 123).should.have.error(/should be a UUID/); 7 | new uuid().match('', false).should.have.error(/should be a UUID/); 8 | }); 9 | 10 | it('has to be in the UUID format', function() { 11 | new uuid().match('', '89c34fa10be545f680a384b962f0c699').should.have.error(/should be a UUID/); 12 | new uuid().match('', '00000000-0000-1000-8000-000000000000').should.not.have.error(); 13 | new uuid().match('', '3c8a90dd-11b8-47c3-a88e-67e92b097c7a').should.not.have.error(); 14 | }); 15 | 16 | it('requires the digit to be between 1 and 5', function() { 17 | for (var i = 1; i <= 5; ++i) { 18 | new uuid().match('', '00000000-0000-' + i + '000-8000-000000000000').should.not.have.error(); 19 | } 20 | new uuid().match('', '00000000-0000-0000-8000-000000000000').should.have.error(/should be a UUID/) 21 | new uuid().match('', '00000000-0000-6000-8000-000000000000').should.have.error(/should be a UUID/) 22 | }); 23 | 24 | it('requires the digit to be either 8, 9, a, b', function() { 25 | new uuid().match('', '00000000-0000-4000-0000-000000000000').should.have.error(/should be a UUID/); 26 | new uuid().match('', '00000000-0000-4000-8000-000000000000').should.not.have.error(); 27 | new uuid().match('', '00000000-0000-4000-9000-000000000000').should.not.have.error(); 28 | new uuid().match('', '00000000-0000-4000-a000-000000000000').should.not.have.error(); 29 | new uuid().match('', '00000000-0000-4000-b000-000000000000').should.not.have.error(); 30 | }); 31 | 32 | it('can specify the required version', function() { 33 | new uuid({version: 3}).match('', 'hello').should.have.error(/should be a UUID version 3/); 34 | new uuid({version: 3}).match('', '00000000-0000-4000-8000-000000000000').should.have.error(/should be a UUID version 3/); 35 | new uuid({version: 3}).match('', '00000000-0000-3000-8000-000000000000').should.not.have.error(); 36 | }); 37 | 38 | it('generate specific version format of uuid json schema', function() { 39 | new uuid({version: 1}).toJSONSchema().should.eql({ type: 'string', format: 'uuid-v1' }); 40 | new uuid({version: 2}).toJSONSchema().should.eql({ type: 'string', format: 'uuid-v2' }); 41 | new uuid({version: 3}).toJSONSchema().should.eql({ type: 'string', format: 'uuid-v3' }); 42 | new uuid({version: 4}).toJSONSchema().should.eql({ type: 'string', format: 'uuid-v4' }); 43 | }); 44 | 45 | it('generate non version specific format of uuid json schema', function() { 46 | new uuid().toJSONSchema().should.eql({ type: 'string', format: 'uuid' }); 47 | }); 48 | 49 | it('generates a json schema with description option', function() { 50 | new uuid({version: 4, description: 'Lorem ipsum'}).toJSONSchema().should.eql({ type: 'string', format: 'uuid-v4', description: 'Lorem ipsum' }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/matchers/value.spec.js: -------------------------------------------------------------------------------- 1 | var ValueMatcher = require('../../lib/matchers/value'); 2 | 3 | describe('equals matcher', function() { 4 | 5 | it('raises an exception when expectedValue is undefined', function() { 6 | (function() { 7 | new ValueMatcher(); 8 | }).should.throw('must provide a primitive value to match'); 9 | }); 10 | 11 | it('raises an exception when expectedValue is an object', function() { 12 | (function() { 13 | new ValueMatcher({}); 14 | }).should.throw('must provide a primitive value to match'); 15 | }); 16 | 17 | it('raises an exception when expectedValue is an array', function() { 18 | (function() { 19 | new ValueMatcher([]); 20 | }).should.throw('must provide a primitive value to match'); 21 | }); 22 | 23 | it('raises an exception when expectedValue is a function', function() { 24 | (function() { 25 | new ValueMatcher(function(){}); 26 | }).should.throw('must provide a primitive value to match'); 27 | }); 28 | 29 | it('fails for non strict equality', function() { 30 | new ValueMatcher(true).match('', 'true').should.have.error(/should strict equal true/); 31 | }); 32 | 33 | describe('when strict equality on primitive', function() { 34 | it('passes for null', function() { 35 | new ValueMatcher(null).match('', null).should.not.have.error(); 36 | new ValueMatcher(null).match('', 123).should.eql([{ 37 | path: '', 38 | value: 123, 39 | message: 'should strict equal null' 40 | }]) 41 | }); 42 | 43 | it('passes for boolean', function() { 44 | new ValueMatcher(true).match('', true).should.not.have.error(); 45 | }); 46 | 47 | it('passes for int', function() { 48 | new ValueMatcher(1).match('', 1).should.not.have.error(); 49 | }); 50 | 51 | it('passes for string', function() { 52 | new ValueMatcher('true').match('', 'true').should.not.have.error(); 53 | }); 54 | }); 55 | 56 | it('generates enum json schema', function() { 57 | new ValueMatcher(1).toJSONSchema().should.eql({ 58 | enum: [1] 59 | }); 60 | }); 61 | 62 | it('generates enum json schema with description', function() { 63 | new ValueMatcher(1, {description: 'Lorem ipsum'}).toJSONSchema().should.eql({ 64 | enum: [1], 65 | description: 'Lorem ipsum' 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /test/non-constructor-api.spec.js: -------------------------------------------------------------------------------- 1 | var s = require('../lib/strummer'); 2 | 3 | describe('non constructor api', function() { 4 | 5 | it('can use the direct function call to create matcher instance', function() { 6 | var schema = s.string(); 7 | schema.match(3).should.eql([{ 8 | path: '', 9 | value: 3, 10 | message: 'should be a string' 11 | }]); 12 | }); 13 | 14 | it('can create a matcher which returns new matcher instance from direct function call', function() { 15 | var CustomMatcher = s.createMatcher({ 16 | initialize: function() {}, 17 | match: function(path, value) { 18 | if (value !== 'hehe') { 19 | return 'value should be hehe'; 20 | } 21 | } 22 | }); 23 | 24 | var cm = CustomMatcher(); 25 | cm.match('boom').should.eql([{ 26 | path: '', 27 | value: 'boom', 28 | message: 'value should be hehe' 29 | }]); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/spec-helpers.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var requireDir = require('require-dir'); 3 | 4 | // require all code to get full code coverage 5 | // (even if a file doesn't have tests) 6 | requireDir(__dirname + '/../lib', {recurse: true, duplicates: true}); 7 | 8 | should.Assertion.prototype.error = function(regex) { 9 | var found = this.obj.filter(function(err) { 10 | if (typeof regex === 'string') return err.message === regex; 11 | if (regex && regex.test) return regex.test(err.message); 12 | else return true; 13 | }); 14 | this.params = { 15 | actual: this.obj, 16 | operator: 'to have', 17 | expected: regex ? regex : 'any error' 18 | }; 19 | this.assert(this.negate ? found.length === 0 : found.length > 0); 20 | return this; 21 | }; 22 | 23 | should.Assertion.prototype.errors = function(errors) { 24 | errors.forEach(function(err) { 25 | this.obj.should.have.error(err); 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /test/strummer.spec.js: -------------------------------------------------------------------------------- 1 | var s = require('../lib/strummer'); 2 | 3 | describe('strummer', function() { 4 | it('throws error when passing a empty definition', function() { 5 | (function() { 6 | s(); 7 | }).should.throw(); 8 | }); 9 | 10 | it('passes null values to the matchers', function() { 11 | var schema = s({ 12 | name: new s.string() 13 | }); 14 | schema.match({ 15 | name: null 16 | }).should.eql([ 17 | { 18 | path: 'name', 19 | value: null, 20 | message: 'should be a string' 21 | } 22 | ]); 23 | }); 24 | 25 | it('can handle a null obj', function() { 26 | var schema = s({ 27 | name: new s.string() 28 | }); 29 | schema.match('path', null).should.eql([ 30 | { 31 | path: 'path', 32 | value: null, 33 | message: 'should be an object' 34 | } 35 | ]); 36 | }); 37 | 38 | it('can handle an undefined obj', function() { 39 | var schema = s({ 40 | name: new s.string() 41 | }); 42 | schema.match('path', undefined).should.eql([ 43 | { 44 | path: 'path', 45 | value: undefined, 46 | message: 'should be an object' 47 | } 48 | ]); 49 | }); 50 | 51 | it('can handle null values', function() { 52 | var schema = s({ 53 | name: new s.string() 54 | }); 55 | schema.match('', {name: null}).should.eql([ 56 | { 57 | path: 'name', 58 | value: null, 59 | message: 'should be a string' 60 | } 61 | ]); 62 | }); 63 | 64 | it('can handle undefined values', function() { 65 | var schema = s({ 66 | name: new s.string() 67 | }); 68 | schema.match('', {name: undefined}).should.eql([ 69 | { 70 | path: 'name', 71 | value: undefined, 72 | message: 'should be a string' 73 | } 74 | ]); 75 | }); 76 | 77 | it('can specify a matcher is optional', function() { 78 | var schema = s({ 79 | name: new s.optional(new s.string()) 80 | }); 81 | schema.match({ 82 | name: null 83 | }).should.eql([]); 84 | }); 85 | 86 | it('passes options to the matchers', function() { 87 | var schema = s({ 88 | val: new s.number({min: 10}) 89 | }); 90 | schema.match({ 91 | val: 5 92 | }).should.eql([{ 93 | path: 'val', 94 | value: 5, 95 | message: 'should be a number >= 10' 96 | }]); 97 | }); 98 | 99 | it('can define custom leaf matchers', function() { 100 | var greeting = s.createMatcher({ 101 | initialize: function() {}, 102 | match: function(path, val) { 103 | if (/hello [a-z]+/.test(val) === false) { 104 | return 'should be a greeting'; 105 | } 106 | } 107 | }); 108 | 109 | var schema = s({ 110 | hello: new greeting() 111 | }); 112 | schema.match({ 113 | hello: 'bye' 114 | }).should.eql([ 115 | { 116 | path: 'hello', 117 | value: 'bye', 118 | message: 'should be a greeting' 119 | } 120 | ]); 121 | }); 122 | 123 | it('can assert on a matcher being successful', function() { 124 | var person = { 125 | name: 'bob', 126 | age: 3 127 | }; 128 | 129 | s.assert(person, { 130 | name: 'string', 131 | age: 'number' 132 | }); 133 | }); 134 | 135 | it('can assert on a matcher being unsuccessful', function() { 136 | var person = { 137 | name: 3, 138 | age: 'bob' 139 | }; 140 | (function() { 141 | s.assert(person, { 142 | name: 'string', 143 | age: 'number' 144 | }); 145 | }).should.throw(/name should be a string \(was 3\)/) 146 | .throw(/age should be a number \(was 'bob'\)/); 147 | }); 148 | 149 | it('should stringify values when assertions fail', function() { 150 | var person = { 151 | name: { text: 'bob' } 152 | }; 153 | (function() { 154 | s.assert(person, { 155 | name: 'string' 156 | }); 157 | }).should.throw(/name should be a string \(was { text: 'bob' }\)/); 158 | }); 159 | 160 | it('throws error when match is not implemented', function() { 161 | (function() { 162 | s.createMatcher({}); 163 | }).should.throw(/match is not implemented/); 164 | }); 165 | 166 | it('supports optional initialize method', function() { 167 | (function() { 168 | var m = s.createMatcher({ 169 | match: function() { 170 | return; 171 | } 172 | }); 173 | var schema = m(); 174 | schema.match(); 175 | }).should.not.throw(); 176 | }); 177 | 178 | it('supports initialize with name', function() { 179 | var m = s('User', { 180 | foo: 'string', 181 | bar: 'number' 182 | }); 183 | 184 | var data = { 185 | foo: 123, 186 | bar: 'str' 187 | }; 188 | 189 | m.name.should.equal('User'); 190 | m.match(data).should.have.lengthOf(2); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /test/syntactic-sugar.spec.js: -------------------------------------------------------------------------------- 1 | var s = require('../lib/strummer'); 2 | 3 | describe('syntactic sugar', function() { 4 | 5 | it('can use the matchers name instead of the function', function() { 6 | var schema = s('string'); 7 | schema.match(3).should.eql([{ 8 | path: '', 9 | value: 3, 10 | message: 'should be a string' 11 | }]); 12 | }); 13 | 14 | it('can use object litterals instead of the object matcher', function() { 15 | var schema = s({ 16 | name: new s.string(), 17 | age: new s.number() 18 | }); 19 | schema.match({ 20 | name: 'bob', 21 | age: 'foo' 22 | }).should.eql([{ 23 | path: 'age', 24 | value: 'foo', 25 | message: 'should be a number' 26 | }]); 27 | }); 28 | 29 | it('can use matcher names inside object litterals', function() { 30 | var schema = s({ 31 | name: 'string', 32 | age: 'number' 33 | }); 34 | schema.match({ 35 | name: 'bob', 36 | age: 'foo' 37 | }).should.eql([{ 38 | path: 'age', 39 | value: 'foo', 40 | message: 'should be a number' 41 | }]); 42 | }); 43 | 44 | it('can use the array litteral notation', function() { 45 | var schema = s({ 46 | names: ['string'] 47 | }); 48 | schema.match({ 49 | names: ['hello', 123] 50 | }).should.eql([{ 51 | path: 'names[1]', 52 | value: 123, 53 | message: 'should be a string' 54 | }]); 55 | }); 56 | 57 | it('can use the regex litteral notation', function() { 58 | var schema = s({ 59 | name: /^[a-z]+$/ 60 | }); 61 | schema.match({ 62 | name: 'Bob123' 63 | }).should.eql([{ 64 | path: 'name', 65 | value: 'Bob123', 66 | message: 'should match the regex /^[a-z]+$/' 67 | }]); 68 | }); 69 | 70 | }); 71 | -------------------------------------------------------------------------------- /test/util.spec.js: -------------------------------------------------------------------------------- 1 | var utils = require('../lib/utils'); 2 | var hasValue = utils.hasValue; 3 | 4 | describe("hasValue() util function", function(){ 5 | it('Should return false when value is ', function(){ 6 | hasValue(null).should.be.false; 7 | }); 8 | 9 | it('Should return false when value is ', function(){ 10 | hasValue(undefined).should.be.false; 11 | }); 12 | 13 | it('Should return true when value is a number', function(){ 14 | hasValue(12).should.be.true; 15 | }); 16 | 17 | it('Should return true when value is a string', function(){ 18 | hasValue('a').should.be.true; 19 | }); 20 | 21 | it('Should return true when value is <{}>', function(){ 22 | hasValue({}).should.be.true; 23 | }); 24 | 25 | it('Should return true when value is <"">', function(){ 26 | hasValue('').should.be.true; 27 | }); 28 | }); 29 | --------------------------------------------------------------------------------