0.3.4 • Published 6 years ago

feathers-schema v0.3.4

Weekly downloads
57
License
ISC
Repository
github
Last release
6 years ago

Feathers Schema

ALPHA VERSION DOCUMENTATION

If you're reading this, feathers-schema is in alpha, and not all of the functionality is fully described or finalized.


Why?

  • You're using feathers.js serverside, and you'd like to validate data.
  • You'd also like to validate this data using a variety of database adapters with the same syntax.
  • You'd like to validate data client-side using the same schemas that you create to validate serverside, because you understand the benefits of isomorphism.
  • You're one of those cools we keep hearing about.

QuickStart

The following assumes you're familiar with feathers.js workflow. If you've never heard of feathers.js before, it's great. Learn it: feathers.js

Install

npm install feathers-schema

Define a Schema

import Schema from 'feathers-schema'

const articleSchema = new Schema({
  writeup: String,
  published: { type: Boolean, default: false },
  author: {
    required: true,
    name: { type: String, required: true, trim: true },
    age: { type: Number, range: ['>', 16, 'Must be over 16 years old.'] }
  },
  comments: [String]
})

Hmm... Looks a lot like writing schemas for Mongoose.

Quite right. Mongoose has an intuitive syntax for creating schemas that I shamelessly ripped off while creating this package. If you're familiar with mongoose, feathers-schema will be easy mode.

Then why not just use Mongoose? Mongoose works with feathers.

Go for it! Mongoose is mature, stable and reliable, and this package is not an attempt to replace it.

The scope of this tool is much more limited than Mongoose. You cannot create document instances with feather-schema, or directly interact with a database: feathers-schema only validates data.

Feathers.js focuses on being loose, agnostic, and lightweight. This tool is simply an extension of that mindset.

Use in a Service

Lets create an app with the minimum functionality required to test a service:

import feathers from 'feathers'
import hooks from 'feathers-hooks'

//this will create an app that allows you to create documents server-side only
const app = feathers()
  .configure(hooks())

Then, we'll create a service using the article schema we created above:

import memory from 'feathers-memory'

//For the sake of example, we'll create a service that stores data in memory
const articleService = app.use('/articles', memory())

Here's where the magic happens. A schema automatically builds the hooks relevant to validating data, and stores them in on its own hooks object.

You can only put schema hooks on 'before' 'create'/'patch' or 'update' methods, and typically you'd want them on all three:

  const articleHooks = [...articleSchema.hooks]

  const before = {
    create: articleHooks,
    patch: articleHooks,
    update: articleHooks
  }

  articleService.hooks({ before })

Alternatively, if you're going to be weaving other hooks throughout for more advanced cases (or if you're a filthy es5 casual who isn't yet using the rest/spread operator) you can do this:

const articleHooks = [
  articleSchema.hooks.populate,
  articleSchema.hooks.sanitize,
  articleSchema.hooks.validate
]

const before = {
  create: articleHooks,
  patch: articleHooks,
  update: articleHooks
}

articleService.hooks({ before })

Now, your service will validate document data:

  app.service('articles')
    .create({
      writeup: 'Buzzwords and such.'
    })
    .catch(err => console.log(err)) // { errors: { author: 'Required.' } }

And sanitize it as well:

  app.service('articles')
    .create({
      writeup: 'This is an unpublished article.',
      author: {
        name: '  Whitespace Mgee   '
      }
    })
    .then(savedDoc => console.log(savedDoc))/*:
      {
        writeup: 'This is an unpublished article.'
        published: false, // <- false by default
        author: {
          name: 'Whitespace Mgee' //<- trimmed
          age: null // unprovided fields will be stored as null
        },
        comments: null
      }
    */
  }

Types

The most basic attribute a property can have is it's type:

new Schema({
  property: { type: String }
})

If you only need to define a type, and no other attributes:

new Schema({ property: String })

You can also define weather the property is intended to be an array of items by wrapping the rest of the definition in an array:

new Schema({ property: [String] })

A Schema can be composed of properties of the following types:

  • String
  • Number
  • Boolean
  • Date
  • Object
  • ANY ( defined as null)

Without any further attributes, properties are nullable by default. That is, a missing or invalid property will be stored in the database as null.

Object type

Type Object does not mean any object. It specifically means plain key-value objects. If you are creating a nested property, it will default to Object.

new Schema({

  //Objects types with no sub properties may have any structure, and will not
  //be validated further.
  metadata_v1: Object, // { whatever: ['you want'], is: ['fine']} √
  metadata_v2: {},     //

  //However, it can only be placed in an array if the property is defined as
  //an array
  metadata_v3: [Object], //[{cool: true}, {beans: true}] √
  metadata_v4: [{}],

  //Object types with at least one nested property will be limited in structure
  //to those properties:
  name: {
    first: String,
    last: String
  } // { first: 'Jim', last: 'Beam', isABottleOfWhiskey: true } <-- last key will
  //be filtered by type sanitizer
})

ANY type

The ANY type means almost any type. Like Object type, it needs to be defined as an array property if it's intended to be able to take arrays.

import { ANY } from 'feathers-schema/types'

new Schema({
  whatever_v1: ANY,
  whatever_v2: null, // ANY === null, literally

  whatever_v3: undefined, // throws error. Must be null

  whatever_array_v1: [],
  whatever_array_v2: [ANY]
})

ANY type is of course the most malleable. Strings, numbers, Objects, whatever. It also has the least number of attributes that apply to it, and cannot validate nested properties.


Sanitization vs Validation

Some attributes will sanitize a value, and some will validate a value.

A schema has methods that can be called by client code or server hooks, that handle each:

  const schema = new Schema({
    name: {
      type: String, /// type is a validator attribute
      trim: true    /// trim is a sanitizer attribute
    }
  })

  //Sanitizers take data and output sanitized data
  schema.sanitize({ name: '  whitespace mcgee  '})
    .then(data => console.log(data)) // { name: 'whitespace mcgee' }

  //Validators take data and output errors
  schema.validate({ name: 1000 })
    .then(result => console.log(result)) // { name: 'Expected String.'}

  //If there are no errors, schema.validate will return false
  schema.validate({ name: 'whitespace mcgee'})
    .then(result => console.log(result)) // false

Sanitization should happen before validation.


Attributes

Attribute signatures

Some attributes can be configured with a number of options, some attributes are enabled simply by being a key.

Attributes configurations can be input as a single value, array, or object:

new Schema({
  //with some config value, expected to be a number
  prop1: { attribute: 100 },
  //with some other config value, expected to be a string
  prop1: { attribute: 'Error Message.' },
  //both, implicitly in an array
  prop2: { attribute: ['Error Message.', 100] },
  //explicitly defined as an object
  prop3: { attribute: { msg: 'Error Message.', option: 100 } }
})

String attributes

Using feathers-schema, blank strings are converted to null.

length

The length attribute validates the length of a string.

/*

Configurations: * = required
{ min: Number*, max: Number*, msg: String }
-or-
{ value: Number*, compare: ('<=', '<', '>' or '>=')*, msg: String }

*/

const msg = 'Fool! You\'ll kill us all!'
const type = String
new Schema({
//Ways to validate a string of 144 characters or less:
  tweet_v1: { type, length: ['<=', 144, msg] },
  tweet_v2: { type, length: [msg, 144, '<=' ] }, //order irrelevant
  tweet_v3: { type, length: { value: 144, compare: '<=', msg } },
  tweet_v4: { type, length: [0, 144, msg] },
  tweet_v4: { type, length: [144, msg, 0] }, //once again, order irrelevant
  tweet_v5: { type, length: { min: 0, max: 144, msg }},

//these will break:
  tweet_wrong_v1: { type, length: { min: 144, max: 0 } }, // min must be below max
  tweet_wrong_v2: { type, length: [0, msg] }, //must supply a comparer with only one value
  tweet_wrong_v3: { type, length: [0, 144, '<=' ]}, //compare only works with a single value

//or, be a smartass:
  tweet_lulz: { type, length: [0, 144, '<=>', msg] } //you figured out what I used for the in-between comparison operator, you are cool
})

format, email, alpha, numeric, alphanumeric and nospaces

The format attribute will validate a string against a regular expression. It will throw an error if an input value does not pass the regular expression test() method.

/*

Configuration: * = required
{ exp: RegExp*, msg: String }

*/

const schema = new Schema({
  zoomy: { type: String, format: [/zoom/, 'Must contain the word "zoom"'] }
})

The email, alpha, numeric, alphanumeric and nospaces attributes are simply canned format validators:

/*

Configuration:
{ msg: String }

*/

const type = String
const schema = new Schema({
  email: { type, email: true },
  name: { type, alpha: 'Only letters in your name there, cheif.' },
  order: { type, numeric: true },
  alias: { type, alphanumeric: 'Your cool nickname cannot have any symbols.' },
  password: { type, nospaces: 'No spaces in your password, please.' },
})

lowercase, uppercase and trim

/*
No Configuration
*/

const schema = new Schema({
  loud: { type: String, uppercase: true },
  quiet: { type: String, lowercase: true },
  pretty: { type: String, trim: true },
})

schema.sanitize({
  loud: 'hey',
  quiet: 'HO',
  pretty: '      Im whitespace mcgee        '
})
.then(data => console.log(data))
//^^
//{
//  loud: 'HEY',
//  quiet: 'ho',
//  pretty: 'Im whitespace mcgee'
//}

Number attributes

range

The range attribute validates the range of a number.

/*

Configurations: * = required
{ min: Number*, max: Number*, msg: String }
-or-
{ value: Number*, compare: ('<=', '<', '>' or '>=')*, msg: String }

*/

const type = Number
new Schema({
  //Between Configurations:
  SIN: { type, range: [100000000, 999999999] },
  age: { type, range: { min: 19, max: 99, msg: 'No teenagers or centenarians.'} },

  //Compare Configurations:
  villagePopulation: { type, range: ['<', 10000, 'More than ten thousand people is not considered a village.']},
  celsius: { type, range: { value: -273.15, compare: '>=', msg: 'Must be above absolute Zero.' }}

})

even and odd

Pretty self explanatory:

/*

Configuration: * = required
{ msg: String }

*/

const type = Number
new Schema({
  bestOfRounds: { type, odd: true },
  totalShoes: { type, even: true }
})

General attributes

General attributes can be applied to a many (or all) types.

default

The default attribute can be placed on any type. It will sanitize undefined or null values as the provided default.

/*

Configuration: * = required
{ value:(property type or Function)*,  msg: String }

*/

new Schema({
  score: { type: Number, default: 0 },
  //default may also take a function
  chant: { type: String, default: () => 'go sports team!\n'.repeat(5) }
})

required

The required attribute can be placed on any type. It will validate undefined or null values, throwing an error.

/*

Configuration: * = required
{ condition:Function*,  msg: String }

*/

const onFridays = () => new Date().getDay() === 5

new Schema({
  name: { type: String, required: true },
  //required may also take a function
  coverCharge: { type: String, required: onFridays }
  //required attribute on an array property means that the array must exist,
  //and must have more than 0 items
  comments: [{ type: String, required: true }]
})

Hook attributes

Hook attributes only have an effect when the schema is being used in a feathers service in the form of hooks. If you're just calling the validate/sanitize methods on a built schema (if you're using them client side, for example) they will not do anything.

unique

The unique attributes validates that a property value is unique amongst all other documents in it's service.

/*
Configuration: * = required
{ msg: String }
*/

new Schema({
  accountName: { type: String, unique: true }
})

service

It acts as an associative filter, only allowing values that correspond with ids of other services. It's important to ensure that the type property being used matches the type of id the database adapter for that service is using.

/*

Configuration: * = required
{ name: String* }

*/

//linking to two hypothetical services that are using, say, mongodb
new Schema({
  essay: String,
  author: { type: Number, service: 'authors' },
  comments: [{ type: Number, service: 'comments'}]
})

Custom Validators and Sanitizers

sanitize and validate

You can create your own functions to sanitize an validate data with the sanitize and validate attributes, respectively.

const batmanify = () => {

  // MUST be created out of higher-order functions.
  // This example doesn't require any set up, it simply returns the actual method
  return value => {

    // null or undefined values should be ignored by a sanitizer, by convention.
    // If you'd like to give a property a default value, use the 'default' sanitizer.
    if (value == null)
      return value

    return value.replace(/bruce\swayne/gi, 'Batman')

  }
}

new Schema({

  article: {
    type: String,
    sanitize: batmanify
  }

})

Accessing Property Info

When creating a validator or sanitizer, you can access property info to ensure proper usage or to help with validation.

//Ensure that you use the function keyword, NOT an arrow function
function supermanify() {

  //this.type will contain a type of the current property.
  if (this.type !== String)
    //this.key will contain the name of the current property.
    throw new Error(`${this.key} is not a String property. 'supermanify' must be placed on String properties.`)

  return value => {

    //null or undefined values should be ignored by a sanitizer, by convention.
    //If you'd like to give a property a default value, use the 'default' sanitizer.
    if (value == null)
      return value

    return value.replace(/clark\skent/gi, 'Superman')

  }
}

new Schema({

  article: {
    type: String,
    sanitize: supermanify
  }

})

Accessible Property Info:

Inside of a function(){} higher order function, here are the properties you Should be able to reference:

key: The key of the property. array: True if this is an array property, false if not. type: The data type of this property. null if ANY. parent: A link to the parent property, if any.

Using Parameters

If used serverside, your validate or sanitize function is provided with a number of parameters:

function customValidatorOrSanitizer () {

  return (value, params) => { }
                //^^^^
}

Accessible Parameters:

Inside of a function(){} higher order function, here are the properties you Should be able to reference:

id: Id of the document currently being validated, if provided by the hook method: Hook method currently being executed: create, patch, or update service: Service object the document belongs to. app: Link to the feathers app. data: Data stored in hook.data.

Its important that you handle the case of the parameters not being provided, otherwise your validator will break client-side.


Custom Types

The default types are a narrow subset of Javascript types that translate well to database types. You can also add existing or custom types to feathers-schema.

import { ObjectID } from 'mongodb'
import Schema, { types } from 'feathers-schema'

const idSchema = new Schema({ _id: ObjectID }) // throws 'Malformed property: ObjectID is not a valid type.'

types.setCustom(ObjectID)

const idSchema = new Schema({ _id: ObjectID }) // will not throw

Casting

Schema's sanitization phase will take any value supplied and try to cast it to it's intended type. If it cannot cast a value to it's intended type, it will turn it to null.

If you are providing a custom type, it will try to cast a value to that type by using the type as a constructor:

const { _id } = await idSchema.sanitize({ _id: '59b339090149b98c0a74332d' })
// is equivalent to
const _id = new ObjectID('59b339090149b98c0a74332d')

However, this may not always be suitable for any custom type:

import Schema, { types } from 'feathers-schema'

class Vector2 {
  constructor (x, y) {
    this.x = typeof x === 'number' ? x : 0
    this.y = typeof y === 'number' ? y : 0
  }
}

types.setCustom(Vector2)

const transform = new Schema({
  position: Vector2,
  scale: Vector2
})

const data = await transform.sanitize({
  position: { x: 1, y: 2 },
  scale: { x: 1, y: 2 }
})
// is equivalent to
const data = {
  position: new Vector2({ x: 1, y: 2}),
  scale: new Vector2({ x: 1, y: 2 })
}

// Which will result in:
console.log(data) // { position: { x: 0, y: 0 }, scale: { x:0, y:0 } }

This is a contrived example. If Vector2 was your own type, you'd clearly write a more liberal constructor. However, if you're using a type from a library, you may need to write a function to cast the value into something the type can construct with:

types.setCustom(Vector2, input => {

  return typeof input === 'object'
    ? new Vector2(input.x, input.y)
    : new Vector2(input, input)
})

The second argument to setCustom will alter the casting function for the specified type.

You can ALSO alter the casting functions for default types:

types.setCustom(String, value => escape(`${value}`))

But it is wiser to provide custom sanitizers that custom casters.

Backing Type and Serialization

If the custom type you're adding is supported by your database (such as an ObjectID), then you don't have to make any further considerations for Serialization.

//TODO: describe how to set up a backing type and serialize/deserialize functions
// for custom types

Associations

//TODO: finish this example once associations are fleshed out.

Further Considerations

// TODO detail further considerations about interweaving hooks, database adapter
// restrictions (if any)
0.3.4

6 years ago

0.3.1

7 years ago

0.3.0

7 years ago

0.2.5

7 years ago

0.2.4

7 years ago

0.2.3

7 years ago

0.2.2

7 years ago

0.2.1

7 years ago

0.2.0

7 years ago

0.1.17

7 years ago

0.1.16

7 years ago

0.1.15

7 years ago

0.1.14

7 years ago

0.1.13

7 years ago

0.1.12

7 years ago

0.1.11

7 years ago

0.1.10

7 years ago

0.1.9

7 years ago

0.1.8

7 years ago

0.1.7

7 years ago

0.1.6

7 years ago

0.1.5

7 years ago

0.1.4

7 years ago

0.1.3

7 years ago

0.1.2

7 years ago

0.1.1

7 years ago

0.1.0

7 years ago