Skip to content

Commit

Permalink
Framework: experiment with schemas to validate persisted redux data
Browse files Browse the repository at this point in the history
  • Loading branch information
gwwar committed Feb 10, 2016
1 parent cd2364b commit 84632cd
Show file tree
Hide file tree
Showing 7 changed files with 153 additions and 25 deletions.
27 changes: 27 additions & 0 deletions CREDITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1278,3 +1278,30 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```

### https://github.com/mozilla/localForage
```text
Copyright 2014 Mozilla
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```

### https://github.com/geraintluff/tv4
```text
Author: Geraint Luff and others
Year: 2013
This code is released into the "public domain" by its author(s). Anybody may use, alter and distribute the code without restriction. The author makes no guarantees, and takes no liability of any kind for use of this code.
If you find a bug or make an improvement, it would be courteous to let the author know, but it is not compulsory.
```
22 changes: 12 additions & 10 deletions client/state/sites/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
*/
import { combineReducers } from 'redux';
import pick from 'lodash/object/pick';
import indexBy from 'lodash/collection/indexBy';
import isFunction from 'lodash/lang/isFunction';
import omit from 'lodash/object/omit';

Expand All @@ -12,6 +11,8 @@ import omit from 'lodash/object/omit';
*/
import { plans } from './plans/reducer';
import { SITE_RECEIVE, SERIALIZE, DESERIALIZE } from 'state/action-types';
import schema from './schema';
import { isValidStateWithSchema } from 'state/utils';

/**
* Tracks all known site objects, indexed by site ID.
Expand All @@ -23,19 +24,20 @@ import { SITE_RECEIVE, SERIALIZE, DESERIALIZE } from 'state/action-types';
export function items( state = {}, action ) {
switch ( action.type ) {
case SITE_RECEIVE:
//TODO: do not pass a decorated site object to SITE_RECEIVE
//site objects are being decorated in SitesList in lib/sites/sites-list/index.js
//with either lib/site/index.js or lib/site/jetpack.js
const blackList = [ '_headers', 'fetchingSettings', 'fetchingUsers',
'latestSettings', '_events', '_maxListeners' ];
let plainJSObject = pick( action.site, ( value ) => ! isFunction( value ) );
plainJSObject = omit( plainJSObject, blackList );
return Object.assign( {}, state, {
[ action.site.ID ]: action.site
[ action.site.ID ]: plainJSObject
} );
case SERIALIZE:
// scrub _events, _maxListeners, and other misc functions
const sites = Object.keys( state ).map( ( siteID ) => {
let plainJSObject = pick( state[ siteID ], ( value ) => ! isFunction( value ) );
plainJSObject = omit( plainJSObject, [ '_events', '_maxListeners'] );
return plainJSObject;
} );
return indexBy( sites, 'ID' );
case DESERIALIZE:
return state;
case DESERIALIZE:
return isValidStateWithSchema( state, schema ) ? state : {};
}
return state;
}
Expand Down
63 changes: 63 additions & 0 deletions client/state/sites/schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
export default {
type: 'object',
patternProperties: {
//be careful to escape regexes properly
'^\\d+$': {
type: 'object',
required: [ 'ID', 'name' ],
properties: {
ID: { type: 'number' },
name: { type: 'string' },
description: { type: 'string' },
URL: { type: 'string' },
jetpack: { type: 'boolean' },
post_count: { type: 'number' },
subscribers_count: { type: 'number' },
lang: { type: 'string' },
icon: {
type: 'object',
properties: {
img: { type: 'string' },
ico: { type: 'string' }
}
},
logo: {
type: 'object',
properties: {
id: { type: 'number' },
sizes: { type: 'array' },
url: { type: 'string' }
}
},
visible: { type: 'boolean' },
is_private: { type: 'boolean' },
is_following: { type: 'boolean' },
options: { type: 'object' },
meta: { type: 'object' },
user_can_manager: { type: 'boolean' },
is_vip: { type: 'boolean' },
is_multisite: { type: 'boolean' },
capabilities: {
type: 'object',
patternProperties: {
'^[a-z_]+$': { type: 'boolean' }
}
},
plan: {
type: 'object',
properties: {
product_id: { type: 'number' },
product_slug: { type: 'string' },
product_name_short: { type: 'string' },
free_trial: { type: 'boolean' }
}
},
single_user_site: { type: 'boolean' },
domain: { type: 'string' },
slug: { type: 'string' },
title: { type: 'string' }
}
}
},
additionalProperties: false
};
41 changes: 26 additions & 15 deletions client/state/sites/test/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,25 @@
* External dependencies
*/
import { expect } from 'chai';
import mockery from 'mockery';
import deepFreeze from 'deep-freeze';

/**
* Internal dependencies
*/
import { SITE_RECEIVE, SERIALIZE, DESERIALIZE } from 'state/action-types';
import { items } from '../reducer';

let items;
describe( 'reducer', () => {
before( function() {
mockery.registerMock( 'lib/warn', () => {} );
mockery.enable( { warnOnReplace: false, warnOnUnregistered: false } );
items = require( '../reducer' ).items;
} );
after( function() {
mockery.deregisterAll();
mockery.disable();
} );
describe( '#items()', () => {
it( 'should default to an empty object', () => {
const state = items( undefined, {} );
Expand All @@ -20,7 +31,8 @@ describe( 'reducer', () => {
it( 'should index sites by ID', () => {
const state = items( null, {
type: SITE_RECEIVE,
site: { ID: 2916284, name: 'WordPress.com Example Blog' }
site: { ID: 2916284, name: 'WordPress.com Example Blog' },
somethingDecoratedMe: () => {}
} );

expect( state ).to.eql( {
Expand All @@ -29,12 +41,13 @@ describe( 'reducer', () => {
} );

it( 'should accumulate sites', () => {
const original = Object.freeze( {
const original = deepFreeze( {
2916284: { ID: 2916284, name: 'WordPress.com Example Blog' }
} );
const state = items( original, {
type: SITE_RECEIVE,
site: { ID: 77203074, name: 'Just You Wait' }
site: { ID: 77203074, name: 'Just You Wait' },
somethingDecoratedMe: () => {}
} );

expect( state ).to.eql( {
Expand All @@ -44,7 +57,7 @@ describe( 'reducer', () => {
} );

it( 'should override previous site of same ID', () => {
const original = Object.freeze( {
const original = deepFreeze( {
2916284: { ID: 2916284, name: 'WordPress.com Example Blog' }
} );
const state = items( original, {
Expand All @@ -58,21 +71,19 @@ describe( 'reducer', () => {
} );
describe( 'persistence', () => {
it( 'should return a js object on SERIALIZE', () => {
const original = Object.freeze( {
const original = deepFreeze( {
2916284: {
ID: 2916284,
name: 'WordPress.com Example Blog',
somethingDecoratedMe: () => {
}
name: 'WordPress.com Example Blog'
}
} );
const state = items( original, { type: SERIALIZE } );
expect( state ).to.eql( {
2916284: { ID: 2916284, name: 'WordPress.com Example Blog' }
} );
} );
it( 'it loads state on DESERIALIZE', () => {
const original = Object.freeze( {
it( 'validates state on DESERIALIZE', () => {
const original = deepFreeze( {
2916284: {
ID: 2916284,
name: 'WordPress.com Example Blog'
Expand All @@ -94,15 +105,15 @@ describe( 'reducer', () => {
}
} );
} );
it.skip( 'when state has validation errors on DESERIALIZE it returns initial state', () => {
const original = Object.freeze( {
it( 'returns initial state when state is missing required properties', () => {
const original = deepFreeze( {
2916284: { name: 'WordPress.com Example Blog' }
} );
const state = items( original, { type: DESERIALIZE } );
expect( state ).to.eql( {} );
} );
it.skip( 'when state has validation errors on DESERIALIZE it returns initial state', () => {
const original = Object.freeze( {
it( 'returns initial state when state has invalid keys', () => {
const original = deepFreeze( {
foobar: { name: 'WordPress.com Example Blog' }
} );
const state = items( original, { type: DESERIALIZE } );
Expand Down
21 changes: 21 additions & 0 deletions client/state/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* External dependencies
*/
import tv4 from 'tv4';

/**
* Internal dependencies
*/
import warn from 'lib/warn';

/**
* Module variables
*/

export function isValidStateWithSchema( state, schema, checkForCycles = false, banUnknownProperties = false ) {
const result = tv4.validateResult( state, schema, checkForCycles, banUnknownProperties );
if ( ! result.valid ) {
warn( 'state validation failed', state, result.error );
}
return result.valid;
}
3 changes: 3 additions & 0 deletions npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
"superagent": "1.2.0",
"tinymce": "4.2.8",
"to-title-case": "0.1.5",
"tv4": "1.2.7",
"tween.js": "16.3.1",
"twemoji": "1.3.2",
"uglify-js": "2.6.1",
Expand Down

0 comments on commit 84632cd

Please sign in to comment.