Skip to content

Instantly share code, notes, and snippets.

@jdthorpe
Last active June 30, 2017 22:01
Show Gist options
  • Save jdthorpe/b63883516494f65877e94e4f64e11310 to your computer and use it in GitHub Desktop.
Save jdthorpe/b63883516494f65877e94e4f64e11310 to your computer and use it in GitHub Desktop.

Using Ajv (AjvPy) with MongoEngine

Obviously you could use an ajvpy validator to validate documents directly, like this:

# imports
from mongoengine import *
from ajvpy import Ajv

# Instantiate an AJV validator
ajv = Ajv()
my_ajv_schema = {
    "type":"object",
    "required":["a","b"]
}
my_validator = ajv.compile(my_ajv_schema)

# Create a Document Class with a Clean Method that uses your validator
class My_document(Document):
    status = StringField(choices=('Published', 'Draft'), required=True)
    ...

    def clean(self):
        if not my_validator(self):
            raise ValidationError("Failed to validate against AJV schema.")

or you could use the ajv_clean decorator included in the ajvpy module:

from mongoengine import *
from ajvpy import ajv_clean

class My_document(Document):
    status = StringField(choices=('Published', 'Draft'), required=True)
    ...

    # Have the decorator instantiate the validator and validate each document instance
    @ajv_clean(my_ajv_schema)
    def clean(self,validated):
        pass

Note that because (1) the data are validated in the PyV8 runtime, and (2) the object can be modified using certian Ajv keywords, the wrapped Document.clean method should take two arguments: The mongoengne.Document instance being validated (self) and the validated object (validated) that made the round trip from Python to JavaScript and back, which can be used to pass an updates or additions made by Ajv to the mongoengine.Document instnace (see the example below).

If you want to extend the ajv instance used for validation with plugin modules, use:

from mongoengine import *
from ajvpy import Ajv, ajv_clean

# Create and extend an Ajv instance
ajv = Ajv()
ajv.plugin("my-plugin")

class My_document(Document):
    status = StringField(choices=('Published', 'Draft'), required=True)
    ...

    # Pass the extended Ajv instance to the decorator
    @ajv_clean(my_ajv_schema,ajv)
    def clean(self,validated):
        pass

Real world example: Using stored Ajv schemas to validate MongoEngine Documents

AJV schemas are just objects, so we can store them in a mongodb collection using MongoEngine. We can combine this with the ajv_clean wrapper to use stored schema when creating new mongoengine.Document classes

We'll use semver strings to keep track of schema versions, so to get started we'll need to clone ajvpy and then download and compile the ajv-semver plugin from NPM so it's available to use in ajvpy.

git clone https://github.com/jdthorpe/ajvpy
cd ajvpy
npm install
npm install ajv-semver
npm run bundle -- ajv-semver
cd ../

With the required Ajv plugins compiled, we can use it to validate Documents with MongoEngine when they are doc.clean()'d or doc.save()'d

# IMPORTS 
from mongoengine import * 
from ajvpy import Ajv, ajv_clean

# CREATE AN EXTENDED INSTANCE WITH THE "semver" KEYWORD
ajv = Ajv({"useDefaults":True})
ajv.plugin("ajv-semver")

# A SCHEMA FOR OUR THE COLLECTION THAT WILL STORE OUR FAVORITE SCHEMA
ajv_schema_schema =  {
        "type":"object",
        "properties":{
            "name":{
                "type":"string",
                # schema names should consist of lower case letters 
                # broken by single dashes or underscores, but not both
                "pattern":"^[a-z]+(?:(?:-[a-z]+)+|(?:_[a-z]+)+)?$",
                },
            "version":{
                # version should be a Semver string
                "semver":{"clean":True},
                },
            # schema should be a valid JSON Schema
            "schema":{
                "$ref": "http://json-schema.org/draft-06/schema#",
                },
            # Automatically calculate the Major, Minor, and Patch of the version
            "major":{
                "default": None, # required to create a new attribute
                "semver":{"major":{"$data":"1/version"},"loose":True}
            },
            "minor":{
                "default": None, # required to create a new attribute
                "semver":{"minor":{"$data":"1/version"},"loose":True}
            },
            "patch":{
                "default": None, # required to create a new attribute
                "semver":{"patch":{"$data":"1/version"},"loose":True}
            },
            # SCHEMA MAY REQUIRE AJV options
            "options":{ "type": "object" },
            # SCHEMA MAY REQUIRE A PLUGIN OR TWO.
            # specifically, plugins may be an object like one of these:
            #    
            #    - {"my-plugin":true}      #>> "my-plugin" plugin is required
            #    
            #    - {"my-plugin":"^3.2.1"}  #>> version "^3.2.1" of "my-plugin" plugin is required
            #    
            #    - {
            #           "my-plugin":{
            #               "version":"^3.2.1",     #>> version "^3.2.1" of "my-plugin" plugin is required
            #               "options":{"foo":"bar"} #>> "my-plugin" plugin should be loaded with the options {"foo":"bar"}
            #            }
            #      }
            "plugins":{
                "type": "object",
                "additionalProperties":{
                    "oneOf":[
                        # no particular version or options required
                        {"type":"boolean"},
                        # required plugin version
                        {"type":"string","semver":True},
                        # required version and/or options object
                        {
                            "type":"object",
                            "properties":{
                                "version":{"type":"string","semver":True},
                                "options":{"type":"object"} # any
                            },
                            "anyOf":[
                                {"required":["version"]},
                                {"required":["options"]}
                                ]
                            },
                        ]
                    }
                }
            },
        "required":[
                "name",
                "version",
                "schema",
                ],
        }

# CREATE DOCUMENT CLASS FOR THE STORING OUR FAVORITE AJV SCHEMAS
class ajvSchema(Document):
    # store the schema in the `--ajv-schema` collection
    meta =  {'collection': '--ajv-schema'}
    # Fields
    name = StringField(required=True)
    version = StringField(required=True)
    schema = DictField(required=True)
    options = DictField(required=False,default=None)
    plugins = DictField(required=False,default=None)
    major = IntField(required=False)
    minor = IntField(required=False)
    patch = IntField(required=False)
    # pass the Ajv instance extended with the "semver" keyword
    @ajv_clean(ajv_schema_schema, ajv)
    def clean(self,validated):
        # Gather the major, minor and patch calculated by ajv and the
        # cleaned up version string from the `validated` object which 
        # made the round trip from python to JavaScript and back.
        self.version = validated["version"]
        self.major = validated["major"]
        self.minor = validated["minor"]
        self.patch = validated["patch"]

and now you can store your favorite AJV schema along side your data, like so:

# an Ajv-schema we'd like to store
contact_schema = {
    "type":"object",
    "properties":{
        "name":     { "type":"string" },
        "email":    { "type":"string" ,"format":"email"},
        "birthday": { "type":"string" ,"format":"date"},
        "website":  { "type":"string" ,"format":"url"},
    },
    "required":["name","email"]
}

# create a new MongoEngine ajvSchema Document with the new schema
good = ajvSchema(name="my-contacts",version="=1.0.0",schema=contact_schema)

# Does this document match the schema?
good.clean() # Yep, no exception is raised!

# note the version string was cleaned up and that the major, minor and
# patch have been added to the object -- handy for indexing by version.
good.version # "1.0.0"
good.major # 1
good.minor # 0
good.patch # 0

# now connect to the database 'My-project' and save
connect("my-project")
good.save() #>> Stores the object

# count the stored objects
ajvSchema.objects.count() # 1

# create a few more objects
ajvSchema(name="my-contacts",version="1.2.3",schema=contact_schema).save()
ajvSchema(name="my-contacts",version="1.2.4",schema=contact_schema).save()
ajvSchema(name="my-contacts",version="1.6.3",schema=contact_schema).save()
ajvSchema(name="my-contacts",version="2.6.3",schema=contact_schema).save()

# Count by version
ajvSchema.objects(major=1).count()
ajvSchema.objects(major=2).count()
ajvSchema.objects(minor=6).count()

# get the latest version
ajvSchema.objects.order_by('-major', '-minor','-patch')[0].version
# get the latest of the version 1.x.x series
ajvSchema.objects(major=1).order_by( '-minor','-patch')[0].version

In another Python session use the latest version to populate a validator!

from mongoengine import * 
from ajvpy import Ajv, ajv_clean

# Even better: create a module with the definition of ajvSchema and call:
# from my_schema import ajvSchema
class ajvSchema(Document):
    # store the schema in the `--ajv-schema` collection
    meta =  {'collection': '--ajv-schema'}
    name = StringField(required=True)
    version = StringField(required=True)
    schema = DictField(required=True)
    options = DictField(required=False,default=None)
    plugins = DictField(required=False,default=None)
    major = IntField(required=False)
    minor = IntField(required=False)
    patch = IntField(required=False)

# Connect to the database 
connect("my-project")
# Fetch the latest version of our Ajv schema and options
latest_contact_schema = ajvSchema.objects.order_by('-major', '-minor','-patch')[0]

# Instantiate an ajv instance with any required options
ajv_inst = Ajv(latest_contact_schema.options)

# Add in any required plugins
plugin_options = latest_contact_schema.plugins
if plugin_options is not None:
    for name in plugin_options.keys():
        if isinstance(plugin_options[name],(bool,basestring)):
            ajv_inst.plugin(name)
        else:
            ajv_inst.plugin(
                name,
                plugin_options[name].get("options",None))

# create the contacts class to be validated with the stored validator
class Contact(Document):
    name = StringField(required=True)
    email = StringField(required=True)
    birthday = StringField(required=False,defautl=None)
    website = StringField(required=False)
    # pass the Ajv instance extended with the "semver" keyword
    @ajv_clean(latest_contact_schema.schema, ajv_inst)
    def clean(self,validated):
        pass

my_contact = Contact(
        name="Oscar Madison",
        email="[email protected]",
        website="https://www.google.com/search?q=oscar+madison")
my_contact.clean()
my_contact.save()

Contact(name="Oscar Madison",
        email="[email protected]",
        website="ttps://www.google.com/search?q=oscar+madison" # bad url
        ).clean() # raises: '#/properties/website/format' should match format "url" 

Contact(name="Oscar Madison",
        email="oscar_at_poker.com", # bad email
        ).clean() #  raises: '#/properties/email/format' should match format "email"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment