Repository: RockefellerArchiveCenter/DACSspace

Issue:
Allow users to specify a schema to validate against
**Is your feature request related to a problem? Please describe.**
DACSspace will ship with a schema for single-level minimum requirement and (maybe) a custom RAC schema. Users may also want to supply their own schema to validate against.

**Describe the solution you'd like**
Allow users to specify which schema they want to validate against. Ideally we should allow both:
- A pointer to an existing schema that is part of DACSspace (presuming there is more than one), somewhat like we do in `rac_schemas`: https://github.com/RockefellerArchiveCenter/rac_schemas/blob/base/rac_schemas/__init__.py#L49
- A schema as a file object or a filepath, which would allow users to create their own arbitrary schema.

Discussion:

Patch:
diff --git a/dacsspace/dacsspace.py b/dacsspace/dacsspace.py
index fcecea7..b953a5b 100644
--- a/dacsspace/dacsspace.py
+++ b/dacsspace/dacsspace.py
@@ -6,9 +6,10 @@ from .validator import Validator
 class DACSspace:
     """Base DACSspace class. Fetches data from AS, validates and reports results."""
 
-    def run(self, published_only, invalid_only):
+    def run(self, published_only, invalid_only,
+            schema_identifier='single_level_required.json', schema_filepath=None):
         client = ArchivesSpaceClient()
-        validator = Validator()
+        validator = Validator(schema_identifier, schema_filepath)
         reporter = CSVReporter()
         data = client.get_resources(published_only)
         results = []
@@ -21,3 +22,6 @@ class DACSspace:
 # These variables should eventually be passed as arguments in the command line
 # published_only = False
 # invalid_only = True
+# schema_identifier - should default to single_level_required.json
+# schema_filepath - should default to None, only one of schema_identifier
+# or schema_filepath allowed
diff --git a/dacsspace/validator.py b/dacsspace/validator.py
index 82cc9b3..3f4d8cb 100644
--- a/dacsspace/validator.py
+++ b/dacsspace/validator.py
@@ -1,14 +1,25 @@
 import json
 
-from jsonschema import Draft7Validator
+from jsonschema import Draft202012Validator
 
 
 class Validator:
     """Validates data from ArchivesSpace."""
 
-    def __init__(self):
-        with open("single_level_required.json", "r") as json_file:
+    def __init__(self, schema_identifier, schema_filepath):
+        """Loads and validates the schema from an identifier or filepath.
+
+        Args:
+            schema_identifier (str): a pointer to a schema that is part of
+            DACSspace, located in the `schemas` directory.
+            schema_filepath (str): a filepath pointing to an external schema.
+        """
+        self.validator = Draft202012Validator
+        if not schema_filepath:
+            schema_filepath = f"schemas/{schema_identifier.removesuffix('.json')}.json"
+        with open(schema_filepath, "r") as json_file:
             self.schema = json.load(json_file)
+        self.validator.check_schema(self.schema)
 
     def validate_data(self, data):
         """Validates data.
@@ -21,8 +32,7 @@ class Validator:
            indication of the validation result and, if necessary, an explanation
            of any validation errors. { "valid": False, "explanation": "You are missing the following fields..." }
         """
-        schema = self.schema
-        validator = Draft7Validator(schema)
+        validator = self.validator(self.schema)
         errors_found = [error.message for error in validator.iter_errors(data)]
         if len(errors_found):
             return {"valid": False, "explanation": "\n".join(errors_found)}


Test patch:
diff --git a/single_level_required.json b/schemas/single_level_required.json
similarity index 100%
rename from single_level_required.json
rename to schemas/single_level_required.json
diff --git a/tests/test_validator.py b/tests/test_validator.py
index 5b6176b..cc75b39 100644
--- a/tests/test_validator.py
+++ b/tests/test_validator.py
@@ -1,13 +1,40 @@
-# from unittest.mock import patch
-
-
 import json
+import os
 import unittest
 
+from jsonschema.exceptions import SchemaError
+
 from dacsspace.validator import Validator
 
 
 class TestValidator(unittest.TestCase):
+    def test_schema(self):
+        """Asserts schema identifiers and filenames are handled correctly."""
+
+        test_schema_filepath = "test_schema.json"
+
+        # handling for schema identifier
+        validator = Validator('single_level_required', None)
+        self.assertEqual(validator.schema["$id"], 'single_level_required.json')
+
+        validator = Validator('single_level_required.json', None)
+        self.assertEqual(validator.schema["$id"], 'single_level_required.json')
+
+        # passing external filename
+        with open(test_schema_filepath, "w") as sf:
+            json.dump({"$id": "test_schema.json"}, sf)
+        validator = Validator(None, test_schema_filepath)
+        self.assertEqual(validator.schema["$id"], test_schema_filepath)
+
+        # invalid external schema
+        with open(test_schema_filepath, "w") as sf:
+            json.dump({"type": 12}, sf)
+        with self.assertRaises(SchemaError):
+            validator = Validator(None, test_schema_filepath)
+
+        # cleanup
+        os.remove(test_schema_filepath)
+
     def test_validator(self):
         valid_json = "fixtures/valid_resource.json"
         invalid_fixtures = [
@@ -15,12 +42,16 @@ class TestValidator(unittest.TestCase):
             "fixtures/no_metadata_rights.json"]
         with open(valid_json, 'r') as v:
             valid_json = json.load(v)
-            result = Validator().validate_data(valid_json)
+            result = Validator(
+                'single_level_required',
+                None).validate_data(valid_json)
         self.assertTrue(isinstance(result, dict))
         self.assertEqual(result["valid"], True)
         for f in invalid_fixtures:
             with open(f, 'r') as i:
                 invalid_json = json.load(i)
-                result = Validator().validate_data(invalid_json)
+                result = Validator(
+                    'single_level_required',
+                    None).validate_data(invalid_json)
             self.assertTrue(isinstance(result, dict))
             self.assertEqual(result["valid"], False)