Repository: bentoml/BentoML

Issue:
Add JsonArtifact for saving/loading JSON files in a SavedBundle
**Is your feature request related to a problem? Please describe.**
Currently, BentoML user can already use TextFileArtifact to save and load a JSON, but it requires the user code to convert/load the JSON data.

It is quite common that a prediction service requires some configuration metadata generated in the training time to be available in the serving time. And adding support for out-of-the-box JSON data support can make bundling configuration data easier for the user.

**Describe the solution you'd like**
```python
from bentoml.artifacts import JsonArtifact

# https://github.com/bentoml/BentoML/blob/master/guides/quick-start/iris_classifier.py
from bentoml import env, artifacts, api, BentoService
from bentoml.handlers import DataframeHandler
from bentoml.artifact import SklearnModelArtifact, JsonArtifact
from my_preprocessing_lib import get_preprocessor

@env(auto_pip_dependencies=True)
@artifacts([SklearnModelArtifact('model'), JsonArtifact('config')])
class IrisClassifier(BentoService):
 
   def __init__(self):
        super().__init__()
        self.preprocessor = get_preprocessor(self.config["preprocessor_name"])

    @api(DataframeHandler)
    def predict(self, df):
        df = self.preprocessor(df)
        return self.artifacts.model.predict(df)
```

```python
# https://github.com/bentoml/BentoML/blob/master/guides/quick-start/main.py
from sklearn import svm
from sklearn import datasets
from my_preprocessing_lib import get_preprocessor
from iris_classifier import IrisClassifier

for preprocessor_name in ["p1", "p2", "p3"]:
    # Load training data
    iris = datasets.load_iris()
    X, y = get_preprocessor(preprocessor_name)(iris.data), iris.target

    # Model Training
    clf = svm.SVC(gamma='scale')
    clf.fit(X, y)

    # Create a iris classifier service instance
    iris_classifier_service = IrisClassifier()

    # Pack the newly trained model artifact
    iris_classifier_service.pack('model', calf)

    # Pack the preprocessor config:
    iris_classifier_service.pack('config', {'preprocessor': preprocessor_name})

    # Save the prediction service to disk for model serving
    saved_path = iris_classifier_service.save()
```
**Describe alternatives you've considered**
n/a

**Additional context**
Suggested by @fernandocamargoti in the BentoML slack channel


Discussion:

Resolving patch:
diff --git a/bentoml/artifact/__init__.py b/bentoml/artifact/__init__.py
--- a/bentoml/artifact/__init__.py
+++ b/bentoml/artifact/__init__.py
@@ -22,6 +22,7 @@
     ArtifactCollection,
 )
 from bentoml.artifact.text_file_artifact import TextFileArtifact
+from bentoml.artifact.json_artifact import JSONArtifact
 from bentoml.artifact.pickle_artifact import PickleArtifact
 from bentoml.artifact.pytorch_model_artifact import PytorchModelArtifact
 from bentoml.artifact.keras_model_artifact import KerasModelArtifact
@@ -42,6 +43,7 @@
     "PickleArtifact",
     "PytorchModelArtifact",
     "TextFileArtifact",
+    "JSONArtifact",
     "KerasModelArtifact",
     "XgboostModelArtifact",
     "H2oModelArtifact",
diff --git a/bentoml/artifact/json_artifact.py b/bentoml/artifact/json_artifact.py
new file mode 100644
--- /dev/null
+++ b/bentoml/artifact/json_artifact.py
@@ -0,0 +1,67 @@
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+
+import os
+import re
+
+from bentoml.artifact import BentoServiceArtifact, BentoServiceArtifactWrapper
+
+
+class JSONArtifact(BentoServiceArtifact):
+    """Abstraction for saving/loading objects to/from JSON files.
+
+    Args:
+        name (str): Name of the artifact
+        file_extension (:obj:`str`, optional): The file extention used for the saved
+            text file. Defaults to ".txt"
+        encoding (:obj:`str`, optional): The encoding will be used for saving/loading
+            text. Defaults to "utf8"
+        json_module (module|object, optional): Namespace/object implementing `loads()`
+            and `dumps()` methods for serializing/deserializing to/from JSON string.
+            Defaults to stdlib's json module.
+    """
+
+    def __init__(self, name, file_extension=".json", encoding="utf8", json_module=None):
+        super().__init__(name)
+        self._file_extension = file_extension
+        self._encoding = encoding
+        if json_module:
+            self.json_module = json_module
+        else:
+            import json
+            self.json_module = json
+
+    def _file_path(self, base_path):
+        return os.path.join(
+            base_path,
+            re.sub("[^-a-zA-Z0-9_.() ]+", "", self.name) + self._file_extension,
+        )
+
+    def load(self, path):
+        with open(self._file_path(path), "rt", encoding=self._encoding) as fp:
+            content = self.json_module.loads(fp.read())
+        return self.pack(content)
+
+    def pack(self, content, **json_dumps_kwargs):  # pylint:disable=arguments-differ
+        return _JSONArtifactWrapper(self, content, **json_dumps_kwargs)
+
+
+class _JSONArtifactWrapper(BentoServiceArtifactWrapper):
+    def __init__(self, spec, content, **json_dumps_kwargs):
+        """
+        Args:
+              **json_dumps_kwargs: keyword args forwarded to call to json.dumps().
+        """
+        super().__init__(spec)
+        self._content = content
+        self._json_dumps_kwargs = json_dumps_kwargs
+
+    def get(self):
+        return self._content
+
+    def save(self, dst):
+        with open(self.spec._file_path(dst), "wt", encoding=self.spec._encoding) as fp:
+            fp.write(
+                self.spec.json_module.dumps(self._content, **self._json_dumps_kwargs)
+            )


Test patch:
diff --git a/tests/artifact/test_json_artifact.py b/tests/artifact/test_json_artifact.py
new file mode 100644
--- /dev/null
+++ b/tests/artifact/test_json_artifact.py
@@ -0,0 +1,23 @@
+import bentoml
+from bentoml.artifact import JSONArtifact
+
+
+@bentoml.artifacts([JSONArtifact("hparams")])
+class ExampleServiceWithJSONArtifact(bentoml.BentoService):
+    pass
+
+
+def test_json_artifact_simple_service_round_trip(tmp_path):
+    service = ExampleServiceWithJSONArtifact()
+    # 'hparams' = hyperparameters used at training time.
+    hparams = {
+        "lr": 1e-3,
+        "patience": 10,
+        "decay": 0.1,
+        "batch_size": 16
+    }
+    service.pack("hparams", hparams)
+    service.save_to_dir(str(tmp_path))
+    del service
+    new_service = bentoml.bundler.load(str(tmp_path))
+    assert new_service.artifacts.hparams == hparams