Easy Schema
Easy Schema is an easy way to add schema validation for Meteor apps. It extends the functionality provided by Meteor's check to validate arguments passed to Meteor Methods and validates automatically on the server prior to write operations insert / update / upsert
. It also automatically generates a MongoDB JSON Schema and attaches it to the database's Collection. It's meant to be lightweight and fast. By default, it validates automatically but it is configurable.
This package can be used with jam:method, Meteor.methods, Validated Method, or any other package that includes a way to validate the method via a function. It also has built-in support for Validation Error for friendlier error messages.
Basics
When using this package, you create a schema once for each Collection and attach it with attachSchema
. When a method is called, you'll use this package's check
function to make sure the arguments passed from the client match what is expected by the schema you defined.
Then, right before the insert / update / upsert
to the database, a validation will be automatically performed against the data that will be written to the database. By default, it will also be validated against the JSON Schema attached to the Collection via Mongo's JSON Schema support though you can disable this if you'd like.
Usage
Add the package to your app
meteor add jam:easy-schema
Define a schema and attach it to its Collection
1import { Mongo } from 'meteor/mongo'; 2 3export const Todos = new Mongo.Collection('todos'); 4 5const schema = { 6 _id: String, 7 text: String, 8 done: Boolean, 9 createdAt: Date, 10 authorId: String, 11 username: String 12}; 13 14Todos.attachSchema(schema); // attachSchema is a function that's built into this package
Use the schema with jam:method
1import { createMethod } from 'meteor/jam:method'; 2 3export const insertTodo = createMethod({ 4 name: 'todos.insert', 5 schema: Todos.schema, 6 async run({ text }) { 7 const user = await Meteor.userAsync(); 8 9 const todo = { 10 text, 11 done: false, 12 createdAt: new Date(), 13 authorId: user._id, 14 username: user.username 15 } 16 17 return Todos.insertAsync(todo); 18 } 19});
See jam:method for more info.
Use the check function with Meteor.methods or ValidatedMethod
You can use this package with Meteor.methods or ValidatedMethod
if you prefer. Use the check
function provided by this package.
1import { check } from 'meteor/jam:easy-schema'; 2 3Meteor.methods({ 4 'todos.insert': async function({ text }) { 5 check({ text }, Todos.schema); // "text" will be automatically picked from the Todos.schema so no need to do this manually 6 7 const user = await Meteor.userAsync(); 8 9 const todo = { 10 text, 11 done: false, 12 createdAt: new Date(), 13 authorId: user._id, 14 username: user.username 15 } 16 17 return Todos.insertAsync(todo); 18 } 19})
Defining Schemas
Primitives
In addition to the built-in Javascript primitives:
String
Number
Boolean
Date
Array
Object
This package adds:
Integer
(matches only signed 32-bit integers)ID
(matches Meteor-generated_id
s)ObjectID
(matchesMongo.ObjectID
s)Any
(matches anything)
and supports:
Decimal
(if using themongo-decimal
package)
ID
You can simply use String
to validate Meteor-generated _id
s but if you'd like to be more precise you can use ID
:
1import { ID } from 'meteor/jam:easy-schema'; 2 3const schema = { 4 _id: ID, 5 // ... rest of your schema // 6}
ObjectID
If you're using Mongo ObjectID
s instead of Meteor's default _id
generation, you'll need to do the following:
1import { ObjectID } from 'meteor/jam:easy-schema'; 2 3const schema = { 4 _id: ObjectID, 5 // ... rest of your schema // 6}
Optional
By default, everything listed in the schema is assumed to be required. For anything optional, you need to specify it with Optional
1import { Optional } from 'meteor/jam:easy-schema'; 2 3optionalArray: Optional([String]) 4optionalObject: Optional({thing: String, optionalString: Optional(String)}) 5arrayOfOptionalInts: [Optional(Integer)]
Note: If Optional
is used inside an object and the value of the key is null
or undefined
, it will throw a validation error. You can either not send the key value pair if the value is null
or undefined
or if you must send a null
or undefined
value, you can use AnyOf(x, null, undefined)
where x
is the type. This was chosen because undefined
arguments to Meteor Methods are converted to null
when sent over the wire.
1// In an object 2const pattern = { name: Optional(String) }; 3 4check({ name: 'something' }, pattern); // OK 5check({}, pattern); // OK 6check({ name: undefined }, pattern); // Throws an exception 7check({ name: null }, pattern); // Throws an exception 8 9// Outside an object 10check(null, Optional(String)); // OK 11check(undefined, Optional(String)); // OK
AnyOf
AnyOf
matches one or more of the items. If you're coming from Meteor's Match
, this is equivalent to Match.OneOf
.
1import { AnyOf } from 'meteor/jam:easy-schema'; 2 3anyOf: AnyOf([String], [Date]) // matches either an array of Strings or an array of Dates 4arrayAnyOf: [AnyOf(String, Number)] // matches an array of Strings or an array of Numbers
Conditions
You can add conditions to validate against. You can use the fluent-style syntax by importing and using [has]
. For example:
1import { has, ID } from 'meteor/jam:easy-schema'; 2 3const schema = { 4 _id: ID, 5 text: String[has].min(1).max(140), 6 done: Boolean[has].default(false), 7 createdAt: Date 8}
Or if you prefer, you can use an object-based syntax:
1import { ID } from 'meteor/jam:easy-schema'; 2 3const schema = { 4 _id: ID, 5 text: { type: String, min: 1, max: 140 }, 6 done: { type: Boolean, default: false }, 7 createdAt: Date 8}
The rest of the examples in this Readme will use the fluent-style syntax due to its conciseness and readability.
min / max
Strings, Numbers, Integers, Arrays, Objects
min is greater than or equal to
and max is less than or equal to
. min / max
map to the JSON Schema equivalent for the type.
1String[has].min(1).max(16) // a string that is at least 1 character and at most 16 characters 2Number[has].min(0.1).max(9.9) // a number greater than or equal to 0.1 and less than or equal to 9.9 3Integer[has].min(10).max(25) // an integer greater than or equal to 10 and less than or equal to 25 4Array[has].min(1).max(5) // an array with at least one item and no more than 5 items 5Array[has].only(String).min(1).max(5) // an array of strings with at least one item and no more than 5 items 6Object[has].min(1).max(2) // an object with at least one property and no more than 2 properties 7Object[has].only({name: String, age: Optional(Number)}).min(1).max(2) // an object with the properties name and age with at least one property and no more than 2 properties
Note
: where you place min / max
matters, for example:
1[String[has].min(1).max(5)] // an array of strings, each with at 1 character and at most 5 characters 2Array[has].only(String).min(1).max(5) // an array of strings with at least one string and no more than 5 strings
regex
Strings only
regex
maps to JSON Schema's pattern
.
1String[has].regex(/.com$/)
unique
Arrays only
unique
maps to JSON Schema's uniqueItems
.
1Array[has].only(Number).unique() // an array of numbers that must be unique, e.g. [1, 2, 3]. [1, 2, 1] would fail.
extra
Objects only
By default, extra
is false
, i.e. what you define in the schema is what is expected to match the data in the db. If you want to accept extra key value pairs, you can do that like this:
1Object[has].only({name: String, createdAt: Date}).extra()
enums
Any Type
You can specify an array of items that are allowed values with enums
– it maps to JSON Schema's enum
1String[has].enums(['hello', 'hi']) 2Number[has].enums([1.2, 6.8, 24.5]) 3Integer[has].enums([145, 29]) 4Boolean[has].enums([true]) 5Date[has].enums([new Date('2021-12-17T03:24:00'), new Date('2022-01-01T03:24:00')]) 6 7// Arrays 8[String[has].enums(['hello', 'hi'])] 9// 2d arrays are supported too 10Array[has].only([[String, Number]]).enums([['hi', 1], ['bye', 2]]) 11 12// Objects 13Object[has].only({hi: String, num: Optional(Number)}).enums([{hi: 'hi', num: 2}]) 14Object[has].enums([{hi: 'hi', num: 2}])
default
Any Type
You can set a default value to use unless a value is explicitly provided. There are two types of defaults: static
and dynamic
.
Static defaults will be set once when the doc is inserted. For example:
1const schema = { 2 // ... // 3 text: String[has].default('hi'), 4 done: Boolean[has].default(false), 5 creatorId: ID[has].default(Meteor.userId), 6 username: String[has].default(Meteor.user), // will use the value Meteor.user.username 7 createdAt: Date[has].default(Date.now) 8}
Dynamic defaults will be set on each write (insert / update / upsert
) to a doc. Pass in a function to make a default dynamic, for exmaple:
1const schema = { 2 // ... // 3 updaterId: ID[has].default(() => Meteor.userId()), 4 updatedAt: Date[has].default(() => Date.now()) 5}
where
Any Type
where
is a custom function that you can use to validate logic and even create a dependency on another property of your schema. Throw the error message you want as a plain string. You can return true
inside where
if you want, but it's taken care of for you if you want to keep it concise.
Note
: Currently, unlike the other conditions, there isn't a great way to map where
to JSON Schema so the where
function will not be translated to Mongo JSON Schema.
Here are some examples of how you might use this:
You can make a property conditionally required on its value.
1{ 2 // ... // 3 text: Optional(String[has].where(text => { if (text === 'world') throw EasySchema.REQUIRED })), // you can also destructure text in the where function if you'd like 4 // ... // 5}
You can make a property of the schema dependent on the value of a sibling property.
Important
: you must destructure the params.
1{ 2 // ... // 3 text: Optional(String), 4 status: Optional(String[has].where(({ text, status }) => { 5 if (text && !status) throw EasySchema.REQUIRED 6 })), 7 // ... // 8}
1{ 2 // ... // 3 password: String, 4 confirmPassword: String[has].where(({ password, confirmPassword }) => { 5 if (confirmPassword !== password) throw 'Passwords must match' 6 }), 7 // ... // 8}
Customizing Error Messages
Easy Schema comes with nicely formatted error messages out of the box, but you can easily customize them. Customizing is supported for these conditions:
min
max
regex
enums
unique
Here's an example:
1const schema = { 2 email: String[has].min(1, 'You must enter an email').regex(/@/, 'You must enter a valid email') 3}
Note
: if you're using the object-based syntax rather than [has]
, it would look like this:
1const schema = { 2 email: {type: String, min: [1, 'You must enter an email'], regex: [/@/, 'You must enter a valid email']} 3}
For anything more involved you can use the where
function. Note that conditions are available as a second parameter:
1const schema = { 2 email: String[has].min(1).regex(/@/).where((email, { min, regex }) => { 3 // ... something complex that couldn't be handled otherwise ... // 4 }) 5}
Blackboxes
In general, it's recommended to specify what you expect but sometimes it's helpful just to validate against a blackbox, i.e. validating the contents is not important or wanted.
1// For blackbox objects 2{stuff: Object}
1// For blackbox arrays 2{things: Array}
1// For a true blackbox 2{something: Any}
Setting a base schema
You can easily set a base schema that all of your schemas will use. For example:
1import { EasySchema } from 'meteor/jam:easy-schema'; 2 3const base = { 4 createdAt: Date, 5 updatedAt: Date 6} 7 8EasySchema.configure({ base }); 9// all schemas will now have a createdAt and updatedAt so you don't need to manually add them each time when writing out your other schemas
Embedding Schemas
If you have a schema that repeats, you can define it once and then embed it.
1const addressSchema = { 2 street: String, 3 city: String, 4 state: String[has].min(2).max(2) 5} 6 7const personSchema = { 8 _id: String, 9 name: String, 10 homeAddress: addressSchema, 11 billingAddress: Optional(addressSchema) 12}
Working with Numbers
Currently, numbers like 1
and 1.0
are both considered to be type Integer
by the Node Mongo driver. Numbers like 1.2
are considered a Double
as you might expect.
Javascript has the Number
prototype which is technically a double-precision floating point numeric value but if you do Number.isInteger(1.0)
in your console you'll see it’s true
. The JSON Schema spec also says that 1.0
should be treated as an Integer
.
What this means for you
Basically, if you can ensure that the numbers you'll store will never be or sum to .0
or .0000000
etc, and you don't require precision, then it’s fine to use the Number
type, which will be mapped to bsonType: 'double'
in the JSON Schema this package generates and attaches to the Mongo Collection. Depending on your situation, you might be better of using Integer
or storing the numbers as String
and convert them to Numbers when you need to perform some simple math. Otherwise if you use Number
and you have a situation where some data of yours sums to, for example 34.000000
, the Node Mongo driver will see that as an integer 34
and will complain that it was expecting bsonType: 'double'
.
One way to get around this – and prevent raising an error when you're ok using floating numbers – would be to define your schema like:
1const schema = { 2 _id: String, 3 num: AnyOf(Number, Integer) 4}
Precise numbers
If you need precision for your numbers, and want to avoid weird floating point math where 0.1 + 0.2 = 0.30000000000000004
then you should probably store them as Decimal
. Here's what Mongo has to say about modeling monetary data.
You can do that by adding the mongo-decimal
package:
meteor add mongo-decimal
mongo-decimal
uses decimal.js
so you can refer to its documentation.
Then when defining your schema:
1import { Decimal } from 'meteor/mongo-decimal'; 2 3const schema = { 4 _id: String, 5 price: Decimal, 6}
Examples
1import { has, ID, Optional, Any, Integer, AnyOf } from 'meteor/jam:easy-schema'; 2 3const schema = { 4 _id: ID, 5 text: String, 6 emails: [String], // an array of strings 7 createdAt: Date, 8 private: Boolean, 9 thing: Number, 10 stuff: Object, // blackbox object 11 int: Integer, 12 digit: Integer[has].min(4).max(12), // min is >= and max is <= 13 address: { 14 street: Optional(String), // street is optional 15 city: String, 16 state: String[has].min(2).max(2) // state has exactly 2 characters 17 }, 18 messages: [{text: String, createdAt: Date}], // array of objects 19 people: [ // an array of objects with extra: true, meaning it accepts additional properties. extra is false by default. 20 Object[has].only({name: String, age: Number, arrayOfOptionalBooleans: [Optional(Boolean)]}.extra() 21 ], 22 regexString: String[has].regex(/.com$/), // regex supported for Strings. should be a regex literal. 23 optionalArray: Optional([String]), 24 optionalObject: Optional({thing: String, optionalString: Optional(String)}), 25 arrayOfInts: [Integer], 26 arrayOfOptionalInts: [Optional(Integer)], 27 arrayOfRegexStrings: [String[has].regex(/.com$/)], 28 anyOf: AnyOf([String], [Date]), // AnyOf matches one or more of the items. In this example, it matches either an array of Strings or an array of Dates 29 arrayAnyOf: [AnyOf(String, Number)], // matches an array of Strings or an array of Numbers, 30 something: Any // matches anything, aka a blackbox 31};
Configuring (optional)
If you like the defaults, then you won't need to configure anything. But there is some flexibility in how you use this package.
Here are the global defaults:
1const config = { 2 base: {}, // set a base schema for all your schemas to use 3 autoCheck: true, // automatically validate on the server prior to insert / update / upsert 4 autoAttachJSONSchema: true, // automatically use a MongoDB JSON Schema 5 validationAction: 'error', // set MongoDB JSON Schema validation action 6 validationLevel: 'strict', // set MongoDB JSON Schema validation level 7 additionalBsonTypes: {} // set additional MongoDB bson types 8};
To change the global defaults, use:
1// put this in a file that's imported on both the client and server into their repective main.js 2import { EasySchema } from 'meteor/jam:easy-schema'; 3 4EasySchema.configure({ 5 // ... change the defaults here ... // 6});
By default, an automatic validation will be performed on the server prior to insert / update / upsert
operations. If you don't want that, you can turn it off by setting autoCheck
to false
. The data will still be validated against the JSON Schema but you won't get as friendly of error messages and you won't know that a write fails until it's attempted on the database.
1import { EasySchema } from 'meteor/jam:easy-schema'; 2 3EasySchema.configure({ autoCheck: false });
If you turn autoCheck
off, you can still validate manually by using the check
function
1import { check } from 'meteor/jam:easy-schema'; 2// Use check inside of a Meteor Method before you perform a db write operation. 3// data - the object you want to validate. For an insert operation, this is the document object you're inserting. In the case of an update / upsert operation, this is the modifier, aka the 2nd argument of the operation. 4// schema - the schema object. Most likely you'll want to pass in the schema attached to your collection, e.g. Todos.schema, but you can also create a custom schema object. 5const data = {$set: {text: 'book flight to hawaii'}}; // example of an update modifier 6const schema = Todos.schema; 7check(data, schema); 8 9// perform db write operation. here's an example with update. 10Todos.updateAsync(_id, data);
If you choose to validate manually, you can force a full check against the entire schema by passing in {full: true}
to the check function.
1import { check } from 'meteor/jam:easy-schema'; 2 3const data = {text: 'hi', ...}; 4const schema = Todos.schema; 5check(data, schema, {full: true}); // will perform a full check only on the server
You can also skip an auto check on a one-off basis turning it off for that operation with{ autoCheck: false }
1await Todos.insertAsync(..., { autoCheck: false });
You can prevent automatically attaching a JSON Schema to your Collection by setting autoAttachJSONSchema
to false
. If you stick with the default of automatically attaching a JSON Schema, you can configure the validationAction
and validationLevel
– see Mongo's Schema Validation for more info.
1import { EasySchema } from 'meteor/jam:easy-schema'; 2 3EasySchema.configure({ 4 autoAttachJSONSchema: false, 5 // validationAction: 'warn', // 'error' is default 6 // valdiationLevel: 'moderate' // 'strict' is default 7});
Unsupported
Auto Check for bulk writes
If you're using rawCollection()
for bulk writes, these will not be automatically validated prior to the operation but you will get validation from the JSON Schema on the db Collection. You can manually use the check
function if you'd like to validate prior to writing to the db. I'm hoping that Meteor will one day support bulk writes without needing rawCollection()
. There was some progress on this front but it seems to have stalled – https://github.com/meteor/meteor/pull/11222
Update accepting an aggregation pipeline
Though technically Mongo supports it, Meteor does not support an aggregation pipeline in update
operations (https://github.com/meteor/meteor/issues/11276) and so this package does not.
JSON Schema omissions
See: MongoDB JSON Schema omissions
In addition to the MongoDB omissions, these are also unsupported by this package at this time:
- dependencies
- oneOf
- allOf
- not
- Array keywords
- contains
- maxContains
- minContains
- Number keywords
- multipleOf
- exclusiveMinimum (you can use min instead to achieve the desired result just be aware that min is inclusive)
- exclusiveMaximum (you can use min instead to achieve the desired result just be aware that max is inclusive)
- String keywords
- contentMediaType
- contentEncoding