jam:easy-schema

v1.5.1Published 3 weeks ago

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 _ids)
  • ObjectID (matches Mongo.ObjectIDs)
  • Any (matches anything)

and supports:

  • Decimal (if using the mongo-decimal package)

ID

You can simply use String to validate Meteor-generated _ids 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 ObjectIDs 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