Repository: pybel/pybel
Pull request #450 (2020-06-30 18:11:12)

Add Node and Edge Validator
Create the`pybel.schema` module, with functions to verify the format of a given node or edge. This will help ensure that all PyBEL data is stored in a consistent and clearly defined manner across the repository.

cthoyt: @aman527 why did you close this one?
aman527: I closed because the PR failed some linting checks -- and I also forgot to write unit tests!
cthoyt: leave the PR open - you can always continue to make improvements and they will be reflected over time. The PR also serves as a point of discussion
cthoyt: Besides some picky stuff, this is looking really good so far! Very impressive how fast you figured this out. Once it's all set and #451 is merged (will be done tomorrow I think), we're going to continue with building the schema for the "streamable" edge, which will encapsulate two nodes, a realtionship, and other metadata
codecov[bot]: # [Codecov](https://codecov.io/gh/pybel/pybel/pull/450?src=pr&el=h1) Report
> Merging [#450](https://codecov.io/gh/pybel/pybel/pull/450?src=pr&el=desc) into [master](https://codecov.io/gh/pybel/pybel/commit/b20255d72e55cdec20493dc736648c051e1751a1&el=desc) will **decrease** coverage by `0.01%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/pybel/pybel/pull/450/graphs/tree.svg?width=650&height=150&src=pr&token=J7joRTRygG)](https://codecov.io/gh/pybel/pybel/pull/450?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #450      +/-   ##
==========================================
- Coverage   79.16%   79.15%   -0.02%     
==========================================
  Files         172      172              
  Lines        8987     8957      -30     
  Branches     1298     1290       -8     
==========================================
- Hits         7115     7090      -25     
+ Misses       1593     1591       -2     
+ Partials      279      276       -3     
```


| [Impacted Files](https://codecov.io/gh/pybel/pybel/pull/450?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/pybel/schema/\_\_init\_\_.py](https://codecov.io/gh/pybel/pybel/pull/450/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3NjaGVtYS9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/pybel/io/nodelink.py](https://codecov.io/gh/pybel/pybel/pull/450/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2lvL25vZGVsaW5rLnB5) | `81.81% <0.00%> (-0.41%)` | :arrow_down: |
| [src/pybel/\_\_init\_\_.py](https://codecov.io/gh/pybel/pybel/pull/450/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL19faW5pdF9fLnB5) | `100.00% <0.00%> (ø)` | |
| [src/pybel/io/\_\_init\_\_.py](https://codecov.io/gh/pybel/pybel/pull/450/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2lvL19faW5pdF9fLnB5) | `100.00% <0.00%> (ø)` | |
| [src/pybel/io/sbel.py](https://codecov.io/gh/pybel/pybel/pull/450/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2lvL3NiZWwucHk=) | | |
| [src/pybel/dsl/node\_classes.py](https://codecov.io/gh/pybel/pybel/pull/450/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2RzbC9ub2RlX2NsYXNzZXMucHk=) | `92.41% <0.00%> (+1.44%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pybel/pybel/pull/450?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pybel/pybel/pull/450?src=pr&el=footer). Last update [b20255d...03b6633](https://codecov.io/gh/pybel/pybel/pull/450?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

cthoyt: I'm doing a bit of reorganizing of the parser, and was thinking I might use this format for the SBEL instead of the one that splits the node and the modifier. What do you think of this structure?
```python
{
  "subject": {
    "function": "Protein",
    "concept": {
      "namespace": "HGNC",
      "name": "123"
    },
    "variants": [
      {
        "kind": "pmod",
        "concept": {
          "namespace": "bel",
          "name": "Ph"
        }
      }
    ],
    "modifier": "Activity",
    "effect": {
      "namespace": "go",
      "name": "transporter activity",
      "identifier": "0005215"
    }
  },
  "relation": "directlyDecreases",
  "object": {
    "function": "Protein",
    "concept": {
      "namespace": "HGNC",
      "name": "234"
    },
    "modifier": "Translocation",
    "effect": {
      "fromLoc": {
        "namespace": "go",
        "name": "intracellular",
        "identifier": "0005622"
      },
      "toLoc": {
        "namespace": "go",
        "name": "extracellular space",
        "identifier": "0005615"
      }
    }
  }
}
```
aman527: Yep, the first one works for me! I'll modify the edges schema file to fit that pattern.
aman527: > Would this still have in it "source" and "target" keys?

Oh I see, the new "subject" and "object" contain within them the old "source" and "target" functionality, where the actual node is included.
cthoyt: @aman527 I made a few updates to the underlying data structure, so now some of the keys have changed from "subject" to "source" and "object" to "target"

Before, this was really inconsistent throughout the whole repo, and now it is all the same. Sorry for the inconvenience

Patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 815e15b8..4fcd960c 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -17,3 +17,4 @@ Contributors
 - `Steffen Möller <https://github.com/smoe>`_
 - `tehw0lf <https://github.com/tehw0lf>`_
 - `Jeremy Zucker <https://github.com/djinnome>`_
+- `Aman Choudhri <https://github.com/aman527>`_
diff --git a/setup.cfg b/setup.cfg
index 34b3ad45..35b6759c 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -63,6 +63,7 @@ install_requires =
     humanize
     tabulate
     pandas
+    jsonschema
 
 # Random options
 zip_safe = false
diff --git a/src/pybel/schema/__init__.py b/src/pybel/schema/__init__.py
new file mode 100644
index 00000000..21f92245
--- /dev/null
+++ b/src/pybel/schema/__init__.py
@@ -0,0 +1,81 @@
+# -*- coding: utf-8 -*-
+
+"""Validation for PyBEL data.
+
+The :mod:`pybel.schema` module houses functions to verify the format of a given node or edge.
+Its inclusion will help ensure that all PyBEL data is stored in a consistent and
+clearly defined manner across the repository.
+"""
+
+import json
+import logging
+import os
+import pathlib
+from typing import Any, Mapping, Optional, Tuple
+
+import jsonschema
+
+__all__ = ['is_valid_node', 'is_valid_edge']
+
+logger = logging.getLogger(__name__)
+
+HERE = os.path.abspath(os.path.dirname(__file__))
+
+NODE_FILENAME = "base_node.schema.json"
+EDGE_FILENAME = "edge.schema.json"
+
+# To use schemas from other files, jsonschema needs to know where the references point to, so
+# create a resolver that directs any references (like "entity.schema.json") to the schema's dir
+schema_uri = pathlib.PurePath(__file__).as_uri()
+RESOLVER = jsonschema.RefResolver(base_uri=schema_uri, referrer=__file__)
+
+
+def _build_validator(filename: str) -> jsonschema.Draft7Validator:
+    """
+    Return a validator that checks against a given schema.
+
+    :param filename: The relative path to the schema, e.g. "base_node.schema.json"
+    """
+    path = os.path.join(HERE, filename)
+    with open(path) as json_schema:
+        schema = json.load(json_schema)
+    return jsonschema.Draft7Validator(schema, resolver=RESOLVER)
+
+
+def _validate(validator: jsonschema.Draft7Validator, entity: Mapping[str, Any]) -> bool:
+    """
+    Determine whether a given entity is valid based on its JSON schema.
+
+    :param input: A dict representing a PyBEL entity.
+    :return: if the input is valid
+    """
+    try:
+        validator.validate(entity)
+        return True
+    except jsonschema.exceptions.ValidationError as err:
+        logger.info(err)
+        return False
+
+
+node_validator = _build_validator(NODE_FILENAME)
+edge_validator = _build_validator(EDGE_FILENAME)
+
+
+def is_valid_node(node: Mapping[str, Any]) -> bool:
+    """
+    Determine whether a given node is valid based on the node's JSON schema.
+
+    :param node: A dict representing a PyBEL node.
+    :return: if the node is valid
+    """
+    return _validate(node_validator, node)
+
+
+def is_valid_edge(edge: Mapping[str, Any]) -> bool:
+    """
+    Determine whether a given edge is valid based on the edge's JSON schema.
+
+    :param node: A dict representing an edge between two PyBEL nodes.
+    :return: if the edge is valid
+    """
+    return _validate(edge_validator, edge)
diff --git a/src/pybel/schema/base_node.schema.json b/src/pybel/schema/base_node.schema.json
new file mode 100644
index 00000000..15bf755b
--- /dev/null
+++ b/src/pybel/schema/base_node.schema.json
@@ -0,0 +1,38 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL Node",
+    "description": "Top-level schema to validate JSON files containing any node.",
+    "type": "object",
+    "allOf": [
+        {
+            "$comment": "Check if the key 'fusion' is present. If so, validate based on the fusion schema.",
+            "if": {
+                "required": ["fusion"]
+            },
+            "then": {
+                "$ref": "node_types/fusion.schema.json"
+            }
+        },
+        {
+            "$comment": "Check whether the node is a Reaction. If so, validate accordingly.",
+            "if": {
+                "properties": {"function": {"const": "Reaction"}}
+            },
+            "then": {
+                "$ref": "node_types/reaction.schema.json"
+            }
+        },
+        {
+            "$comment": "Check whether the node is a Complex or Composite. If so, validate as a ListAbundance; if not, validate as a BaseAbundance.",
+            "if": {
+                "properties": {"function": {"enum": ["Complex", "Composite"]}}
+            },
+            "then": {
+                "$ref": "node_types/list_abundance.schema.json"
+            },
+            "else": {
+                "$ref": "node_types/base_abundance.schema.json"
+            }
+        }
+    ]
+}
\ No newline at end of file
diff --git a/src/pybel/schema/edge.schema.json b/src/pybel/schema/edge.schema.json
new file mode 100644
index 00000000..83a5a5ce
--- /dev/null
+++ b/src/pybel/schema/edge.schema.json
@@ -0,0 +1,23 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL Edge",
+    "description": "Top-level schema to validate JSON files containing an edge.",
+    "type": "object",
+    "properties": {
+        "source": {"$ref": "modifier.schema.json"},
+        "relation": {"type": "string"},
+        "target": {"$ref": "modifier.schema.json"},
+        "evidence": {"type": "string"},
+        "citation": {
+            "type": "object",
+            "properties": {
+                "db": {"type": "string"},
+                "db_id": {"type": "string"}
+            }
+        },
+        "annotations": {
+            "type": "object"
+        }
+    },
+    "required": ["source", "relation", "target"]
+}
\ No newline at end of file
diff --git a/src/pybel/schema/entity.schema.json b/src/pybel/schema/entity.schema.json
new file mode 100644
index 00000000..eb7f33a5
--- /dev/null
+++ b/src/pybel/schema/entity.schema.json
@@ -0,0 +1,22 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL Entity",
+    "description": "Schema to validate a named entity with a namespace and a name/identifier",
+    "type": "object",
+    "properties": {
+        "namespace": {
+            "description": "Namespace of the node, e.g. ChemBL",
+            "type": "string"
+        },
+        "name": {
+            "description": "Name of the node, e.g. ACE2",
+            "type": "string"
+        },
+        "identifier": {"type": "string"}
+    },
+    "anyOf": [
+        {"required": ["namespace"]},
+        {"required": ["name"]},
+        {"required": ["identifier"]}
+    ]
+}
\ No newline at end of file
diff --git a/src/pybel/schema/modifier.schema.json b/src/pybel/schema/modifier.schema.json
new file mode 100644
index 00000000..19ef44f7
--- /dev/null
+++ b/src/pybel/schema/modifier.schema.json
@@ -0,0 +1,34 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL Subject/Object Modifier",
+    "description": "Schema to validate JSON files containing a modifier for a subject/object node in an edge.",
+    "type": "object",
+    "allOf": [
+        {"$ref": "base_node.schema.json"},
+        {
+            "properties": {
+                "modifier": {
+                    "type": "string"
+                },
+                "effect": {
+                    "type": "object",
+                    "oneOf": [
+                        {
+                            "$comment": "Generally, the effect entry will be a pybel.dsl.Entity.",
+                            "$ref": "entity.schema.json"
+                        },
+                        {
+                            "$comment": "However, translocations have a slightly different format.",
+                            "properties": {
+                                "fromLoc": {"$ref": "entity.schema.json"},
+                                "toLoc": {"$ref": "entity.schema.json"}
+                            },
+                            "required": ["fromLoc", "toLoc"]
+                        }
+                    ]
+                },
+                "location": {"$ref": "entity.schema.json"}
+            }
+        }
+    ]
+}
\ No newline at end of file
diff --git a/src/pybel/schema/node_types/base_abundance.schema.json b/src/pybel/schema/node_types/base_abundance.schema.json
new file mode 100644
index 00000000..fa0d8cb0
--- /dev/null
+++ b/src/pybel/schema/node_types/base_abundance.schema.json
@@ -0,0 +1,69 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL BaseAbundance Node",
+    "description": "Schema to validate JSON files containing a BaseAbundance (or subclass) node.",
+    "type": "object",
+    "properties": {
+        "function": {"description": "The function of the node."},
+        "concept": {"$ref": "../entity.schema.json"},
+        "xrefs": {
+            "description": "An optional list of extra identifiers for the node.",
+            "type": "array",
+            "items": {"$ref": "../entity.schema.json"}
+        }
+    },
+    "allOf": [
+        {
+            "if": {
+                "properties": {"function": {"const": "Protein"}}
+            },
+            "then": { 
+                "properties": {
+                    "variants": {
+                        "type": "array",
+                        "items": {
+                            "anyOf": [
+                                {"$ref": "variants/protein_mod.schema.json"},
+                                {"$ref": "variants/hgvs/base_hgvs.schema.json"},
+                                {"$ref": "variants/fragment.schema.json"}
+                            ]
+                        }
+                    }
+                }
+            }
+        },
+        {
+            "if": {
+                "properties": {"function": {"const": "Gene"}}
+            },
+            "then": {
+                "properties": {
+                    "variants": {
+                        "type": "array",
+                        "items": {
+                            "$ref": "variants/gene_mod.schema.json"
+                        }
+                    }
+                }
+            }
+        },
+        {
+            "if": {
+                "not": {
+                    "properties": {"function": {"enum": ["Protein", "Gene"]}}
+                }
+            },
+            "then": {
+                "properties": {
+                    "variants": {
+                        "type": "array",
+                        "items": {
+                            "$ref": "variants/base_variant.schema.json"
+                        }
+                    }
+                }
+            }
+        }
+    ],
+    "required": ["function"]
+}
diff --git a/src/pybel/schema/node_types/fusion.schema.json b/src/pybel/schema/node_types/fusion.schema.json
new file mode 100644
index 00000000..8667717e
--- /dev/null
+++ b/src/pybel/schema/node_types/fusion.schema.json
@@ -0,0 +1,30 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL Fusion Node",
+    "description": "Basic schema to validate JSON files describing a BEL fusion object",
+    "type": "object",
+    "properties": {
+        "fusion": {
+            "type": "object",
+            "properties": {
+                "partner_5p": {
+                    "description": "5-prime fusion partner",
+                    "$ref": "base_abundance.schema.json"
+                },
+                "partner_3p": {
+                    "description": "3-prime fusion partner",
+                    "$ref": "base_abundance.schema.json"
+                },
+                "range_5p": {
+                    "description": "Fusion range for the 5-prime partner",
+                    "$ref": "fusion_range.schema.json"
+                },
+                "range_3p": {
+                    "description": "Fusion range for the 3-prime partner",
+                    "$ref": "fusion_range.schema.json"
+                }
+            }
+        }
+    },
+    "required": ["function", "fusion"]
+}
\ No newline at end of file
diff --git a/src/pybel/schema/node_types/fusion_range.schema.json b/src/pybel/schema/node_types/fusion_range.schema.json
new file mode 100644
index 00000000..5fec944a
--- /dev/null
+++ b/src/pybel/schema/node_types/fusion_range.schema.json
@@ -0,0 +1,25 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL FusionRange Node",
+    "description": "Schema to validate JSON files describing a BEL FusionRange object",
+    "type": "object",
+    "properties": {
+        "reference": {
+            "description": "The reference code",
+            "type": "string"
+        },
+        "left": {
+            "description": "The start position",
+            "type": ["string", "integer"]
+        },
+        "right": {
+            "description": "The stop position",
+            "type": ["string", "integer"]
+        },
+        "missing": {"const": "?"}
+    },
+    "oneOf": [
+        {"required": ["reference", "left", "right"]},
+        {"required": ["missing"]}
+    ]
+}
\ No newline at end of file
diff --git a/src/pybel/schema/node_types/list_abundance.schema.json b/src/pybel/schema/node_types/list_abundance.schema.json
new file mode 100644
index 00000000..c9b84ab3
--- /dev/null
+++ b/src/pybel/schema/node_types/list_abundance.schema.json
@@ -0,0 +1,34 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL ListAbundance Node",
+    "description": "Schema to validate JSON files containing a ListAbundance (ComplexAbundance or CompositeAbundance) node.",
+    "type": "object",
+    "properties": {
+        "function": {"enum": ["Complex", "Composite"]},
+        "members": {
+            "type": "array",
+            "items": {"$ref": "base_abundance.schema.json"}
+        },
+        "concept": {"$ref": "../entity.schema.json"},
+        "xrefs": {
+            "description": "An optional list of extra identifiers for the node.",
+            "type": "array",
+            "items": {"$ref": "../entity.schema.json"}
+        },
+        "variants": {
+            "type": "array",
+            "items": {"$ref": "variants/base_variant.schema.json"}
+        }
+    },
+    "if": {
+        "properties": {
+            "function": {"const": "Complex"}
+        }
+    },
+    "then": {
+        "required": ["function", "concept"]
+    },
+    "else": {
+        "required": ["function"]
+    }
+}
\ No newline at end of file
diff --git a/src/pybel/schema/node_types/reaction.schema.json b/src/pybel/schema/node_types/reaction.schema.json
new file mode 100644
index 00000000..abc6d549
--- /dev/null
+++ b/src/pybel/schema/node_types/reaction.schema.json
@@ -0,0 +1,18 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL Reaction Node",
+    "description": "Schema to validate JSON files containing a Reaction node.",
+    "type": "object",
+    "properties": {
+        "function": {"const": "Reaction"},
+        "reactants": {
+            "type": "array",
+            "items": {"$ref": "base_abundance.schema.json"}
+        },
+        "products": {
+            "type": "array",
+            "items": {"$ref": "base_abundance.schema.json"}
+        }
+    },
+    "required": ["function", "reactants", "products"]
+}
\ No newline at end of file
diff --git a/src/pybel/schema/node_types/variants/base_variant.schema.json b/src/pybel/schema/node_types/variants/base_variant.schema.json
new file mode 100644
index 00000000..89da5757
--- /dev/null
+++ b/src/pybel/schema/node_types/variants/base_variant.schema.json
@@ -0,0 +1,36 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL Variant Node",
+    "description": "Basic schema to validate JSON files describing a BEL variant object",
+    "type": "object",
+    "properties": {
+      "concept": {
+        "description": "Entity description if the variant is a protein or gene modification",
+        "$ref": "../../entity.schema.json"
+      },
+      "code": {
+        "description": "Amino acid code for the affected residue in a protein modification",
+        "enum": [
+          "Ala", "Arg", "Asn", "Asp", "Cys", "Glu", "Gln",
+          "Gly", "His", "Ile", "Leu", "Lys", "Met", "Phe",
+          "Pro", "Ser", "Thr", "Trp", "Tyr", "Val"
+        ]
+      },
+      "pos": {
+        "description": "Position of the affected residue in a protein modification",
+        "type": "number"
+      },
+      "hgvs": {
+        "description": "HGVS variant string",
+        "type": "string"
+      },
+      "start": {
+        "description": "Starting position of a protein fragment",
+        "type": ["number", "string"]
+      },
+      "stop": {
+        "description": "Stopping position of a protein fragment",
+        "type": ["number", "string"]
+      }
+    }
+}
diff --git a/src/pybel/schema/node_types/variants/fragment.schema.json b/src/pybel/schema/node_types/variants/fragment.schema.json
new file mode 100644
index 00000000..6261d915
--- /dev/null
+++ b/src/pybel/schema/node_types/variants/fragment.schema.json
@@ -0,0 +1,16 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL Variant Node",
+    "description": "Schema to validate JSON files describing a protein fragment variant.",
+    "type": "object",
+    "allOf": [
+        {
+            "$ref": "base_variant.schema.json"
+        },
+        {
+            "properties": {
+                "kind": {"const": "frag"}
+            }
+        }
+    ]
+}
diff --git a/src/pybel/schema/node_types/variants/gene_mod.schema.json b/src/pybel/schema/node_types/variants/gene_mod.schema.json
new file mode 100644
index 00000000..53d26569
--- /dev/null
+++ b/src/pybel/schema/node_types/variants/gene_mod.schema.json
@@ -0,0 +1,16 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL Variant Node",
+    "description": "Schema to validate JSON files describing a gene modification variant.",
+    "type": "object",
+    "allOf": [
+        {
+            "$ref": "base_variant.schema.json"
+        },
+        {
+            "properties": {
+                "kind": {"const": "gmod"}
+            }
+        }
+    ]
+}
diff --git a/src/pybel/schema/node_types/variants/hgvs/base_hgvs.schema.json b/src/pybel/schema/node_types/variants/hgvs/base_hgvs.schema.json
new file mode 100644
index 00000000..6afbf7d8
--- /dev/null
+++ b/src/pybel/schema/node_types/variants/hgvs/base_hgvs.schema.json
@@ -0,0 +1,19 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL Variant Node",
+    "description": "Schema to validate JSON files describing a HGVS variant.",
+    "type": "object",
+    "allOf": [
+        {
+            "$ref": "../base_variant.schema.json"
+        },
+        {
+            "properties": {
+                "kind": {"const": "hgvs"}
+            }
+        },
+        {
+            "required": ["hgvs"]
+        }
+    ]
+}
\ No newline at end of file
diff --git a/src/pybel/schema/node_types/variants/hgvs/gene_hgvs.schema.json b/src/pybel/schema/node_types/variants/hgvs/gene_hgvs.schema.json
new file mode 100644
index 00000000..c8546cbf
--- /dev/null
+++ b/src/pybel/schema/node_types/variants/hgvs/gene_hgvs.schema.json
@@ -0,0 +1,18 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL Variant Node",
+    "description": "Schema to validate JSON files describing a gene HGVS variant.",
+    "type": "object",
+    "allOf": [
+        {
+            "$ref": "base_hgvs.schema.json"
+        },
+        {
+            "properties": {
+                "hgvs": {
+                    "pattern": "^[cg]"
+                }
+            }
+        }
+    ]
+}
\ No newline at end of file
diff --git a/src/pybel/schema/node_types/variants/hgvs/protein_hgvs.schema.json b/src/pybel/schema/node_types/variants/hgvs/protein_hgvs.schema.json
new file mode 100644
index 00000000..264c16ea
--- /dev/null
+++ b/src/pybel/schema/node_types/variants/hgvs/protein_hgvs.schema.json
@@ -0,0 +1,18 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL Variant Node",
+    "description": "Schema to validate JSON files describing a protein HGVS variant.",
+    "type": "object",
+    "allOf": [
+        {
+            "$ref": "base_hgvs.schema.json"
+        },
+        {
+            "properties": {
+                "hgvs": {
+                    "pattern": "^p"
+                }
+            }
+        }
+    ]
+}
\ No newline at end of file
diff --git a/src/pybel/schema/node_types/variants/hgvs/rna_hgvs.schema.json b/src/pybel/schema/node_types/variants/hgvs/rna_hgvs.schema.json
new file mode 100644
index 00000000..bedff4a8
--- /dev/null
+++ b/src/pybel/schema/node_types/variants/hgvs/rna_hgvs.schema.json
@@ -0,0 +1,18 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL Variant Node",
+    "description": "Schema to validate JSON files describing a RNA HGVS variant.",
+    "type": "object",
+    "allOf": [
+        {
+            "$ref": "base_hgvs.schema.json"
+        },
+        {
+            "properties": {
+                "hgvs": {
+                    "pattern": "^r"
+                }
+            }
+        }
+    ]
+}
\ No newline at end of file
diff --git a/src/pybel/schema/node_types/variants/protein_mod.schema.json b/src/pybel/schema/node_types/variants/protein_mod.schema.json
new file mode 100644
index 00000000..23ff5a9f
--- /dev/null
+++ b/src/pybel/schema/node_types/variants/protein_mod.schema.json
@@ -0,0 +1,16 @@
+{
+    "$schema": "https://json-schema.org/draft/2019-09/schema#",
+    "title": "BEL Variant Node",
+    "description": "Schema to validate JSON files describing a protein modification variant.",
+    "type": "object",
+    "allOf": [
+        {
+            "$ref": "base_variant.schema.json"
+        },
+        {
+            "properties": {
+                "kind": {"const": "pmod"}
+            }
+        }
+    ]
+}
diff --git a/tox.ini b/tox.ini
index 2985a5a4..d9379703 100644
--- a/tox.ini
+++ b/tox.ini
@@ -70,6 +70,7 @@ deps =
     pydocstyle
 commands =
     flake8 src/pybel/ setup.py
+    flake8 tests/test_schema.py
 description = Run the flake8 tool with several plugins (bandit, docstrings, import order, pep8 naming).
 
 [testenv:xenon]


Test patch:
diff --git a/tests/example_edge.json b/tests/example_edge.json
new file mode 100644
index 00000000..9e93535d
--- /dev/null
+++ b/tests/example_edge.json
@@ -0,0 +1,45 @@
+{
+    "source": {
+        "function": "Protein",
+        "concept": {
+            "namespace": "HGNC",
+            "name": "123"
+        },
+        "variants": [
+            {
+                "kind": "pmod",
+                "concept": {
+                    "namespace": "bel",
+                    "name": "Ph"
+                }
+            }
+        ],
+        "modifier": "Activity",
+        "effect": {
+            "namespace": "go",
+            "name": "transporter activity",
+            "identifier": "0005215"
+        }
+    },
+    "relation": "directlyDecreases",
+    "target": {
+        "function": "Protein",
+        "concept": {
+            "namespace": "HGNC",
+            "name": "234"
+        },
+        "modifier": "Translocation",
+        "effect": {
+            "fromLoc": {
+                "namespace": "go",
+                "name": "intracellular",
+                "identifier": "0005622"
+            },
+            "toLoc": {
+                "namespace": "go",
+                "name": "extracellular space",
+                "identifier": "0005615"
+            }
+        }
+    }
+}
diff --git a/tests/test_schema.py b/tests/test_schema.py
new file mode 100644
index 00000000..31a29af1
--- /dev/null
+++ b/tests/test_schema.py
@@ -0,0 +1,165 @@
+# -*- coding: utf-8 -*-
+
+"""Tests for the jsonschema node validation."""
+
+import copy
+import json
+import os
+import unittest
+
+import pybel.dsl
+from pybel.schema import is_valid_edge, is_valid_node
+from pybel.testing.utils import n
+
+NAMESPACE, NAME, IDENTIFIER = n(), n(), n()
+
+BLANK_ABUNDANCE = {
+    "function": "",
+    "variants": []
+}
+
+# Abundances
+PROTEIN = pybel.dsl.Protein(namespace=NAMESPACE, name=NAME, identifier=IDENTIFIER)
+GENE = pybel.dsl.Gene(NAMESPACE, name=NAME, identifier=IDENTIFIER)
+
+# Variants
+GENE_MOD = pybel.dsl.GeneModification(NAME, namespace=NAMESPACE, identifier=IDENTIFIER)
+PROTEIN_SUB = pybel.dsl.ProteinSubstitution('Ala', 1, 'Tyr')
+PROTEIN_MOD = pybel.dsl.ProteinModification(NAME, code='Ala', position=1,
+                                            namespace=NAMESPACE, identifier=IDENTIFIER)
+FRAGMENT = pybel.dsl.Fragment(1, 2)
+
+
+class TestNodeSchema(unittest.TestCase):
+    """Tests for the jsonschema node validation."""
+
+    def test_pure_abundances(self):
+        """Test validating abundance nodes."""
+        self.assertTrue(is_valid_node(PROTEIN))
+        self.assertTrue(is_valid_node(GENE))
+
+        abundance = pybel.dsl.Abundance(namespace=NAMESPACE, name=NAME, identifier=IDENTIFIER)
+        self.assertTrue(is_valid_node(abundance))
+
+    def test_variant_abundances(self):
+        """Test validating abundance nodes with variants."""
+        gmod = GENE.with_variants(GENE_MOD)
+        self.assertTrue(is_valid_node(gmod))
+
+        psub = PROTEIN.with_variants(PROTEIN_SUB)
+        self.assertTrue(is_valid_node(psub))
+
+        pmod = PROTEIN.with_variants(PROTEIN_MOD)
+        self.assertTrue(is_valid_node(pmod))
+
+        frag = PROTEIN.with_variants(FRAGMENT)
+        self.assertTrue(is_valid_node(frag))
+
+    def test_matching_variants(self):
+        """Test catching invalid abundance/variant pairs, e.g. ProteinModification on a Gene."""
+        invalid_gmod = PROTEIN.with_variants(GENE_MOD)
+        self.assertFalse(is_valid_node(invalid_gmod))
+
+        invalid_psub = GENE.with_variants(PROTEIN_SUB)
+        self.assertFalse(is_valid_node(invalid_psub))
+
+        invalid_pmod = GENE.with_variants(PROTEIN_MOD)
+        self.assertFalse(is_valid_node(invalid_pmod))
+
+        invalid_frag = GENE.with_variants(FRAGMENT)
+        self.assertFalse(is_valid_node(invalid_frag))
+
+    def test_fusions(self):
+        """Test validating fusion nodes."""
+        protein = pybel.dsl.Protein(namespace=NAMESPACE, name=NAME,
+                                    identifier=IDENTIFIER)
+        enum_fusion_range = pybel.dsl.EnumeratedFusionRange("r", 1, 79)
+        missing_fusion_range = pybel.dsl.MissingFusionRange()
+        fusion_node = pybel.dsl.FusionBase(protein, protein,
+                                           range_5p=enum_fusion_range,
+                                           range_3p=missing_fusion_range)
+        self.assertTrue(is_valid_node(fusion_node))
+
+    def test_list_abundances(self):
+        """Test validating list abundance nodes."""
+        complex_abundance = pybel.dsl.ComplexAbundance(
+            [GENE.with_variants(GENE_MOD), PROTEIN.with_variants(PROTEIN_SUB)],
+            namespace=NAMESPACE, name=NAME, identifier=IDENTIFIER)
+        composite_abundance = pybel.dsl.CompositeAbundance([PROTEIN, complex_abundance])
+        self.assertTrue(is_valid_node(complex_abundance))
+        self.assertTrue(is_valid_node(composite_abundance))
+
+    def test_reaction(self):
+        """Test validating a reaction node."""
+        node = pybel.dsl.Rna(namespace=NAMESPACE, name=NAME, identifier=IDENTIFIER)
+        node_list = [node, PROTEIN, GENE]
+        rxn = pybel.dsl.Reaction(reactants=node_list, products=node_list)
+        self.assertTrue(is_valid_node(rxn))
+
+    def test_invalid_abundances(self):
+        """Test that invalid abundances are caught."""
+        missing_fn = BLANK_ABUNDANCE.copy()
+        missing_fn.pop("function")
+        self.assertFalse(is_valid_node(missing_fn))
+
+    def test_invalid_psub(self):
+        """Test that invalid protein substitutions are caught."""
+        missing_hgvs = dict(PROTEIN_SUB)
+        # Remove the required "hgvs" property
+        missing_hgvs.pop("hgvs")
+        protein = BLANK_ABUNDANCE.copy()
+        protein["function"] = "Protein"
+        protein["variants"] = [missing_hgvs]
+        self.assertFalse(is_valid_node(protein))
+
+    def test_invalid_amino(self):
+        """Test that protein variants with invalid amino acid codes are caught."""
+        invalid_psub = dict(PROTEIN_SUB)
+        invalid_psub['hgvs'] = 'p.Aaa0Bbb'
+        self.assertFalse(is_valid_node(invalid_psub))
+
+        invalid_pmod = dict(PROTEIN_MOD)
+        invalid_pmod['code'] = 'Aaa'
+        self.assertFalse(is_valid_node(invalid_pmod))
+
+
+class TestEdgeSchema(unittest.TestCase):
+    """Tests for the jsonschema edge validation."""
+
+    @classmethod
+    def setUpClass(cls):
+        """Load the edge contained in example_edge.json."""
+        here = os.path.abspath(os.path.dirname(__file__))
+        example_file = os.path.join(here, 'example_edge.json')
+        with open(example_file) as example_json:
+            edge = json.load(example_json)
+        cls.example_edge = edge
+
+    def test_predefined_example(self):
+        """Test a predefined edge example."""
+        edge = self.example_edge
+        self.assertTrue(is_valid_edge(edge))
+
+    def test_missing_information(self):
+        """Test removing information from the predefined edge."""
+        edge = self.example_edge
+
+        missing_source = copy.deepcopy(edge)
+        missing_source.pop('source')
+        self.assertFalse(is_valid_edge(missing_source))
+
+        missing_relation = copy.deepcopy(edge)
+        missing_relation.pop('relation')
+        self.assertFalse(is_valid_edge(missing_relation))
+
+        missing_target = copy.deepcopy(edge)
+        missing_target.pop('target')
+        self.assertFalse(is_valid_edge(missing_target))
+
+        missing_location = copy.deepcopy(edge)
+        missing_location['target']['effect'].pop('fromLoc')
+        self.assertFalse(is_valid_edge(missing_location))
+
+
+if __name__ == '__main__':
+    unittest.main()