From e1adc9d39fe9da8263cccf403e3e7993aae246c0 Mon Sep 17 00:00:00 2001
From: Ian Solano-Kamaiko <2371418+iansolano@users.noreply.github.com>
Date: Wed, 11 Mar 2020 10:39:52 -0400
Subject: [PATCH] feat(api): adds response formatting to api requests

Adds response formatting to api requests in new fastapi framework

closes #4636
---
 Pipfile.lock                                  | 100 +------
 .../opentrons/app/models/json_api/errors.py   |  31 +-
 .../opentrons/app/models/json_api/factory.py  |  83 ------
 .../app/models/json_api/resource_links.py     |   8 +-
 .../opentrons/app/models/json_api/response.py |  33 ++-
 api/src/opentrons/app/routers/item.py         |  24 +-
 api/src/opentrons/protocol_api/labware.py     |   5 -
 api/tests/opentrons/app/helpers.py            |  19 ++
 .../app/models/json_api/test_errors.py        |  81 +++++
 .../app/models/json_api/test_factory.py       |  17 ++
 .../app/models/json_api/test_request.py       | 113 +++++++
 .../models/json_api/test_resource_links.py    |  31 ++
 .../app/models/json_api/test_response.py      | 279 ++++++++++++++++++
 api/tests/opentrons/app/routers/test_item.py  |  78 +++--
 robot-server/robot_server/service/main.py     |  20 +-
 15 files changed, 658 insertions(+), 264 deletions(-)
 create mode 100644 api/tests/opentrons/app/helpers.py
 create mode 100644 api/tests/opentrons/app/models/json_api/test_errors.py
 create mode 100644 api/tests/opentrons/app/models/json_api/test_factory.py
 create mode 100644 api/tests/opentrons/app/models/json_api/test_request.py
 create mode 100644 api/tests/opentrons/app/models/json_api/test_resource_links.py
 create mode 100644 api/tests/opentrons/app/models/json_api/test_response.py

diff --git a/Pipfile.lock b/Pipfile.lock
index e903f44bf4d..9a51a2828ed 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "ed63b31f068d51bac7a2fd34e605f75dbafcc8c96209103c96edcd09c836d424"
+            "sha256": "7e7ef69da7248742e869378f8421880cf8f0017f96d94d086813baa518a65489"
         },
         "pipfile-spec": 6,
         "requires": {
@@ -16,101 +16,5 @@
         ]
     },
     "default": {},
-    "develop": {
-        "astroid": {
-            "hashes": [
-                "sha256:71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a",
-                "sha256:840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42"
-            ],
-            "version": "==2.3.3"
-        },
-        "isort": {
-            "hashes": [
-                "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1",
-                "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"
-            ],
-            "version": "==4.3.21"
-        },
-        "lazy-object-proxy": {
-            "hashes": [
-                "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d",
-                "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449",
-                "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08",
-                "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a",
-                "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50",
-                "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd",
-                "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239",
-                "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb",
-                "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea",
-                "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e",
-                "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156",
-                "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142",
-                "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442",
-                "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62",
-                "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db",
-                "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531",
-                "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383",
-                "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a",
-                "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357",
-                "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4",
-                "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"
-            ],
-            "version": "==1.4.3"
-        },
-        "mccabe": {
-            "hashes": [
-                "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
-                "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
-            ],
-            "version": "==0.6.1"
-        },
-        "pylint": {
-            "hashes": [
-                "sha256:3db5468ad013380e987410a8d6956226963aed94ecb5f9d3a28acca6d9ac36cd",
-                "sha256:886e6afc935ea2590b462664b161ca9a5e40168ea99e5300935f6591ad467df4"
-            ],
-            "index": "pypi",
-            "version": "==2.4.4"
-        },
-        "six": {
-            "hashes": [
-                "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
-                "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
-            ],
-            "version": "==1.14.0"
-        },
-        "typed-ast": {
-            "hashes": [
-                "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
-                "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
-                "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
-                "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
-                "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
-                "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
-                "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
-                "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
-                "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
-                "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
-                "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
-                "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
-                "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
-                "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
-                "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
-                "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
-                "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
-                "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
-                "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
-                "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
-                "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
-            ],
-            "markers": "implementation_name == 'cpython' and python_version < '3.8'",
-            "version": "==1.4.1"
-        },
-        "wrapt": {
-            "hashes": [
-                "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1"
-            ],
-            "version": "==1.11.2"
-        }
-    }
+    "develop": {}
 }
diff --git a/api/src/opentrons/app/models/json_api/errors.py b/api/src/opentrons/app/models/json_api/errors.py
index 9f594ed5062..5f4f29a0a8b 100644
--- a/api/src/opentrons/app/models/json_api/errors.py
+++ b/api/src/opentrons/app/models/json_api/errors.py
@@ -1,5 +1,7 @@
 from typing import Optional, List
 from pydantic import BaseModel, ValidationError
+
+from starlette.exceptions import HTTPException as StarletteHTTPException
 # https://github.com/encode/starlette/blob/master/starlette/status.py
 from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY
 
@@ -28,16 +30,23 @@ class ErrorResponse(BaseModel):
     errors: List[Error]
 
 def transform_to_json_api_errors(status_code, exception) -> dict:
-    def transform_error(error):
-        return {
+    if isinstance(exception, StarletteHTTPException):
+        request_error = {
             'status': status_code,
-            'detail': error.get('msg'),
-            'title': error.get('type'),
-            'source': {
-                'pointer': '/' + '/'.join(error['loc']),
-            },
+            'detail': exception.detail,
+            'title': 'Bad Request'
         }
-    error_response = ErrorResponse(
-        errors=[transform_error(error) for error in exception.errors()]
-    )
-    return filter_none(error_response.dict())
\ No newline at end of file
+        error_response = ErrorResponse(errors=[request_error])
+        return filter_none(error_response.dict())
+    else:
+        def transform_error(error):
+            return {
+                'status': status_code,
+                'detail': error.get('msg'),
+                'source': { 'pointer': '/' + '/'.join(error['loc']) },
+                'title': error.get('type')
+            }
+        error_response = ErrorResponse(
+            errors=[transform_error(error) for error in exception.errors()]
+        )
+        return filter_none(error_response.dict())
\ No newline at end of file
diff --git a/api/src/opentrons/app/models/json_api/factory.py b/api/src/opentrons/app/models/json_api/factory.py
index 80fa61ca1c8..898c02afb49 100644
--- a/api/src/opentrons/app/models/json_api/factory.py
+++ b/api/src/opentrons/app/models/json_api/factory.py
@@ -3,89 +3,6 @@
 from .request import JsonApiRequest, RequestModel
 from .response import JsonApiResponse, ResponseModel
 
-# TODO(isk: 2/7/20): This might be totally unnessary depending on the resources
-# Explore infering type from the request itself
-def format_json_request(type, data):
-    data_id = data.get("id")
-    return {
-        "data": {
-            "id": data_id,
-            "type": type,
-            "attributes": data
-        },
-        "links": {
-            "self": f'/{type}s/{data_id}'
-        }
-    }
-
-# HTTP/1.1 200 OK
-# Content-Type: application/vnd.api+json
-
-# {
-#   "links": {
-#     "self": "http://example.com/articles"
-#   },
-#   "data": [{
-#     "type": "articles",
-#     "id": "1",
-#     "attributes": {
-#       "title": "JSON:API paints my bikeshed!"
-#     }
-#   }, {
-#     "type": "articles",
-#     "id": "2",
-#     "attributes": {
-#       "title": "Rails is Omakase"
-#     }
-#   }]
-# }
-
-# POST /photos HTTP/1.1
-# Content-Type: application/vnd.api+json
-# Accept: application/vnd.api+json
-
-# {
-#   "data": {
-#     "type": "photos",
-#     "id": "550e8400-e29b-41d4-a716-446655440000",
-#     "attributes": {
-#       "title": "Ember Hamster",
-#       "src": "http://example.com/images/productivity.png"
-#     }
-#   }
-# }
-
-# HTTP/1.1 201 Created
-# Location: http://example.com/photos/550e8400-e29b-41d4-a716-446655440000
-# Content-Type: application/vnd.api+json
-
-# {
-#   "data": {
-#     "type": "photos",
-#     "id": "550e8400-e29b-41d4-a716-446655440000",
-#     "attributes": {
-#       "title": "Ember Hamster",
-#       "src": "http://example.com/images/productivity.png"
-#     },
-#     "links": {
-#       "self": "http://example.com/photos/550e8400-e29b-41d4-a716-446655440000"
-#     }
-#   }
-# }
-
-# PATCH /articles/1 HTTP/1.1
-# Content-Type: application/vnd.api+json
-# Accept: application/vnd.api+json
-
-# {
-#   "data": {
-#     "type": "articles",
-#     "id": "1",
-#     "attributes": {
-#       "title": "To TDD or Not"
-#     }
-#   }
-# }
 def JsonApiModel(
     type_string: str,
     attributes_model: Any,
diff --git a/api/src/opentrons/app/models/json_api/resource_links.py b/api/src/opentrons/app/models/json_api/resource_links.py
index c5e03c7ff5a..e9e4e7e3031 100644
--- a/api/src/opentrons/app/models/json_api/resource_links.py
+++ b/api/src/opentrons/app/models/json_api/resource_links.py
@@ -1,10 +1,6 @@
-from typing import Mapping, Union
 from pydantic import BaseModel
 
-class LinkHref(BaseModel):
-    href: str
-    meta: dict
+class ResourceLinks(BaseModel):
+    self: str
 
-Link = Union[str, LinkHref]
-ResourceLinks = Mapping[str, Link]
 ResourceLinks.__doc__ = "https://jsonapi.org/format/#document-links"
\ No newline at end of file
diff --git a/api/src/opentrons/app/models/json_api/response.py b/api/src/opentrons/app/models/json_api/response.py
index 9ec87be2e74..bf899bbf0e0 100644
--- a/api/src/opentrons/app/models/json_api/response.py
+++ b/api/src/opentrons/app/models/json_api/response.py
@@ -1,11 +1,10 @@
-from typing import Generic, TypeVar, Optional, List, Any, Type
+from typing import Generic, TypeVar, Optional, List, Any, Type, get_type_hints
 from typing_extensions import Literal
 
-from pydantic import validator
 from pydantic.generics import GenericModel
 
+from .filter import filter_none
 from .resource_links import ResourceLinks
-from .errors import Error
 
 TypeT = TypeVar('TypeT', bound=str)
 AttributesT = TypeVar('AttributesT')
@@ -27,6 +26,34 @@ class ResponseModel(GenericModel, Generic[DataT]):
     data: DataT
     links: Optional[ResourceLinks]
 
+    def dict(
+        self,
+        *,
+        serlialize_none: bool = False,
+        **kwargs
+    ):
+        response = super().dict(**kwargs)
+        if serlialize_none:
+            return response
+        return filter_none(response)
+
+    @classmethod
+    def resource_object(
+        cls,
+        *,
+        id: str,
+        attributes: Optional[dict] = None,
+    ) -> ResponseDataModel:
+        data_type = get_type_hints(cls)['data']
+        if getattr(data_type, '__origin__', None) is list:
+            data_type = data_type.__args__[0]
+        typename = get_type_hints(data_type)['type'].__args__[0]
+        return data_type(
+            id=id,
+            type=typename,
+            attributes=attributes or {},
+        )
+
 def JsonApiResponse(
     type_string: str,
     attributes_model: Any,
diff --git a/api/src/opentrons/app/routers/item.py b/api/src/opentrons/app/routers/item.py
index 3d24e6720cb..0d3ee3d6eba 100644
--- a/api/src/opentrons/app/routers/item.py
+++ b/api/src/opentrons/app/routers/item.py
@@ -4,18 +4,15 @@
 from pydantic import ValidationError
 
 from opentrons.app.models.item import Item, ItemData
-from opentrons.app.models.json_api.factory import JsonApiModel, format_json_request
+from opentrons.app.models.json_api.factory import JsonApiModel
 from opentrons.app.models.json_api.errors import ErrorResponse
 # https://github.com/encode/starlette/blob/master/starlette/status.py
 from starlette.status import HTTP_400_BAD_REQUEST, HTTP_422_UNPROCESSABLE_ENTITY
 
-from opentrons.app.models.json_api.response import JsonApiResponse
-from opentrons.app.models.json_api.request import JsonApiRequest
-
 router = APIRouter()
 
 ITEM_TYPE_NAME = "item"
-ItemResponse = JsonApiResponse(ITEM_TYPE_NAME, Item)
+ItemRequest, ItemResponse = JsonApiModel(ITEM_TYPE_NAME, Item)
 
 @router.get("/items/{item_id}",
             description="Get an individual item by it's ID",
@@ -26,9 +23,10 @@
             })
 async def get_item(item_id: int) -> ItemResponse:
     try:
-        data = { "id": item_id, "name": "apple", "quantity": "10", "price": 1.20 }
-        request = format_json_request(ITEM_TYPE_NAME, data)
-        return ItemResponse(**request)
+        # NOTE(isk: 3/10/20): mock DB / robot response
+        item = Item(name="apple", quantity=10, price=1.20)
+        data = ItemResponse.resource_object(id=item_id, attributes=item)
+        return ItemResponse(data=data, links={"self": f'/items/{item_id}'})
     except ValidationError as e:
         raise HTTPException(status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail=e)
 
@@ -40,11 +38,13 @@ async def get_item(item_id: int) -> ItemResponse:
                 HTTP_400_BAD_REQUEST: { "model": ErrorResponse },
                 HTTP_422_UNPROCESSABLE_ENTITY: { "model": ErrorResponse },
             })
-async def create_item(attributes: Item) -> ItemResponse:
+async def create_item(item_request: ItemRequest) -> ItemResponse:
     try:
-        item_data = ItemData(**attributes.dict())
-        request = format_json_request(ITEM_TYPE_NAME, vars(item_data))
-        return ItemResponse(**request)
+        attributes = item_request.attributes().dict()
+        # NOTE(isk: 3/10/20): mock DB / robot response
+        item = ItemData(**attributes)
+        data = ItemResponse.resource_object(id=item.id, attributes=vars(item))
+        return ItemResponse(data=data, links={"self": f'/items/{item.id}'})
     except ValidationError as e:
         raise HTTPException(status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail=e)
     except Exception as e:
diff --git a/api/src/opentrons/protocol_api/labware.py b/api/src/opentrons/protocol_api/labware.py
index 3479c16d009..0fcdd6d6925 100644
--- a/api/src/opentrons/protocol_api/labware.py
+++ b/api/src/opentrons/protocol_api/labware.py
@@ -13,11 +13,6 @@
 import time
 import os
 import shutil
-<<<<<<< HEAD
-
-=======
-import abc
->>>>>>> use shared_data module instead of pkgutil.get_data
 from pathlib import Path
 from collections import defaultdict
 from enum import Enum, auto
diff --git a/api/tests/opentrons/app/helpers.py b/api/tests/opentrons/app/helpers.py
new file mode 100644
index 00000000000..4c027a66808
--- /dev/null
+++ b/api/tests/opentrons/app/helpers.py
@@ -0,0 +1,19 @@
+from pydantic import BaseModel
+from opentrons.app.models.json_api.request import JsonApiRequest
+from dataclasses import dataclass
+from uuid import uuid4
+
+@dataclass
+class ItemData:
+    name: str
+    quantity: int
+    price: float
+    id: str = str(uuid4().hex)
+
+class ItemModel(BaseModel):
+    name: str
+    quantity: int
+    price: float
+
+item_type_name = 'item'
+ItemRequest = JsonApiRequest(item_type_name, ItemModel)
\ No newline at end of file
diff --git a/api/tests/opentrons/app/models/json_api/test_errors.py b/api/tests/opentrons/app/models/json_api/test_errors.py
new file mode 100644
index 00000000000..e5ed9c9fb4b
--- /dev/null
+++ b/api/tests/opentrons/app/models/json_api/test_errors.py
@@ -0,0 +1,81 @@
+from functools import reduce
+
+import pytest
+from pytest import raises
+from pydantic import ValidationError
+from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY
+
+from opentrons.app.models.json_api.request import JsonApiRequest
+from opentrons.app.models.json_api.errors import ErrorResponse, transform_to_json_api_errors
+from opentrons.app.models.json_api.filter import filter_none
+
+from tests.opentrons.app.helpers import ItemRequest
+
+errors_wrapper = lambda d: { 'errors': [d] }
+
+valid_error_objects = [
+    { 'id': 'abc123' },
+    { 'status': '404' },
+    { 'code': '1005' },
+    { 'title': 'Something went wrong' },
+    { 'detail': "oh wow, there's a few things we messed up there" },
+    { 'meta': { 'num_errors_today': 10000 } },
+    { 'links': { 'self': '/my/error-info?code=1005'} },
+    { 'source': {
+            'pointer': '/data/attributes/price',
+        },
+    },
+]
+
+valid_error_responses = map(errors_wrapper, valid_error_objects)
+
+@pytest.mark.parametrize('error_response', valid_error_responses)
+def test_valid_error_response_fields(error_response):
+    validated = ErrorResponse(**error_response)
+    assert filter_none(validated.dict()) == error_response
+
+error_with_all_fields = reduce(
+    lambda acc, d: { **acc, **d }, valid_error_objects, {}
+)
+
+def test_error_response_with_all_fields():
+    error_response = errors_wrapper(error_with_all_fields)
+    validated = ErrorResponse(**error_response)
+    assert filter_none(validated.dict()) == error_response
+
+
+def test_empty_error_response_valid():
+    error_response = { 'errors': [] }
+    validated = ErrorResponse(**error_response)
+    assert filter_none(validated.dict()) == error_response
+
+def test_transform_to_json_api_errors():
+    with raises(ValidationError) as e:
+        ItemRequest(**{
+            'data': {
+                'type': 'invalid'
+            }
+        })
+    assert transform_to_json_api_errors(
+        HTTP_422_UNPROCESSABLE_ENTITY,
+        e.value
+    ) == {
+        'errors': [
+            {
+                'status': str(HTTP_422_UNPROCESSABLE_ENTITY),
+                'detail': "unexpected value; permitted: 'item'",
+                'source': {
+                    'pointer': '/data/type'
+                },
+                'title': 'value_error.const'
+            },
+            {
+                'status': str(HTTP_422_UNPROCESSABLE_ENTITY),
+                'detail': 'field required',
+                'source': {
+                    'pointer': '/data/attributes'
+                },
+                'title': 'value_error.missing'
+            },
+        ]
+    }
\ No newline at end of file
diff --git a/api/tests/opentrons/app/models/json_api/test_factory.py b/api/tests/opentrons/app/models/json_api/test_factory.py
new file mode 100644
index 00000000000..ffec47b617a
--- /dev/null
+++ b/api/tests/opentrons/app/models/json_api/test_factory.py
@@ -0,0 +1,17 @@
+from typing import List
+from typing_extensions import Literal
+
+from opentrons.app.models.json_api.factory import JsonApiModel
+from opentrons.app.models.json_api.request import RequestModel, RequestDataModel
+from opentrons.app.models.json_api.response import ResponseModel, ResponseDataModel
+from tests.opentrons.app.helpers import ItemModel
+
+def test_json_api_model():
+    ItemRequest, ItemResponse = JsonApiModel('item', ItemModel)
+    assert ItemRequest == RequestModel[RequestDataModel[Literal['item'], ItemModel]]
+    assert ItemResponse == ResponseModel[ResponseDataModel[Literal['item'], ItemModel]]
+
+def test_json_api_model__list_response():
+    ItemRequest, ItemResponse = JsonApiModel('item', ItemModel, list_response=True)
+    assert ItemRequest == RequestModel[RequestDataModel[Literal['item'], ItemModel]]
+    assert ItemResponse == ResponseModel[List[ResponseDataModel[Literal['item'], ItemModel]]]
\ No newline at end of file
diff --git a/api/tests/opentrons/app/models/json_api/test_request.py b/api/tests/opentrons/app/models/json_api/test_request.py
new file mode 100644
index 00000000000..99468a70a4d
--- /dev/null
+++ b/api/tests/opentrons/app/models/json_api/test_request.py
@@ -0,0 +1,113 @@
+from pytest import raises
+
+from pydantic import ValidationError
+
+from opentrons.app.models.json_api.request import JsonApiRequest
+from tests.opentrons.app.helpers import ItemModel
+
+class TestJsonApiRequest:
+    def test_attributes_as_dict(self):
+        DictRequest = JsonApiRequest('item', dict)
+        obj_to_validate = {
+            'data': {'type': 'item', 'attributes': {}}
+        }
+        my_request_obj = DictRequest(**obj_to_validate)
+        assert my_request_obj.dict() == {
+            'data': {
+                'type': 'item',
+                'attributes': {},
+                'id': None,
+            }
+        }
+
+    def test_attributes_as_item_model(self):
+        ItemRequest = JsonApiRequest('item', ItemModel)
+        obj_to_validate = {
+            'data': {
+                'type': 'item',
+                'attributes': {
+                    'name': 'apple',
+                    'quantity': 10,
+                    'price': 1.20
+                },
+                'id': None,
+            }
+        }
+        my_request_obj = ItemRequest(**obj_to_validate)
+        assert my_request_obj.dict() == obj_to_validate
+
+    def test_attributes_as_item_model__empty_dict(self):
+        ItemRequest = JsonApiRequest('item', ItemModel)
+        obj_to_validate = {
+            'data': {
+                'type': 'item',
+                'attributes': {}
+            }
+        }
+        with raises(ValidationError) as e:
+            ItemRequest(**obj_to_validate)
+
+        assert e.value.errors() == [
+            {'loc': ('data', 'attributes', 'name'), 'msg': 'field required', 'type': 'value_error.missing'},
+            {'loc': ('data', 'attributes', 'quantity'), 'msg': 'field required', 'type': 'value_error.missing'},
+            {'loc': ('data', 'attributes', 'price'), 'msg': 'field required', 'type': 'value_error.missing'}
+        ]
+
+    def test_type_invalid_string(self):
+        MyRequest = JsonApiRequest('item', dict)
+        obj_to_validate = {
+            'data': {'type': 'not_an_item', 'attributes': {}}
+        }
+        with raises(ValidationError) as e:
+            MyRequest(**obj_to_validate)
+
+        assert e.value.errors() == [
+            {
+                'loc': ('data', 'type'),
+                'msg': "unexpected value; permitted: 'item'",
+                'type': 'value_error.const',
+                'ctx': {'given': 'not_an_item', 'permitted': ('item',)},
+            },
+        ]
+
+    def test_attributes_required(self):
+        MyRequest = JsonApiRequest('item', dict)
+        obj_to_validate = {
+            'data': {'type': 'item', 'attributes': None}
+        }
+        with raises(ValidationError) as e:
+            MyRequest(**obj_to_validate)
+
+        assert e.value.errors() == [
+            {'loc': ('data', 'attributes'), 'msg': 'none is not an allowed value', 'type': 'type_error.none.not_allowed'},
+        ]
+
+    def test_data_required(self):
+        MyRequest = JsonApiRequest('item', dict)
+        obj_to_validate = {
+            'data': None
+        }
+        with raises(ValidationError) as e:
+            MyRequest(**obj_to_validate)
+
+        assert e.value.errors() == [
+            {'loc': ('data',), 'msg': 'none is not an allowed value', 'type': 'type_error.none.not_allowed'},
+        ]
+
+    def test_request_with_id(self):
+        MyRequest = JsonApiRequest('item', dict)
+        obj_to_validate = {
+            'data': {
+                'type': 'item',
+                'attributes': {},
+                'id': 'abc123'
+            },
+        }
+        my_request_obj = MyRequest(**obj_to_validate)
+        assert my_request_obj.dict() == {
+            'data': {
+                'type': 'item',
+                'attributes': {},
+                'id': 'abc123'
+            },
+        }
diff --git a/api/tests/opentrons/app/models/json_api/test_resource_links.py b/api/tests/opentrons/app/models/json_api/test_resource_links.py
new file mode 100644
index 00000000000..2cec17be00f
--- /dev/null
+++ b/api/tests/opentrons/app/models/json_api/test_resource_links.py
@@ -0,0 +1,31 @@
+from pytest import raises
+
+from pydantic import BaseModel, ValidationError
+from opentrons.app.models.json_api.resource_links import ResourceLinks
+
+
+class ThingWithLink(BaseModel):
+    links: ResourceLinks
+
+
+class TestResourceLinks:
+    def test_follows_structure(self):
+        structure_to_validate = {
+            'links': {
+                'self': '/items/1',
+            }
+        }
+        validated = ThingWithLink(**structure_to_validate)
+        assert validated.dict() == structure_to_validate
+
+    def test_must_be_self_key_with_string_value(self):
+        invalid_structure_to_validate = {
+            'invalid': {
+                'key': 'value',
+            }
+        }
+        with raises(ValidationError) as e:
+            ThingWithLink(**invalid_structure_to_validate)
+        assert e.value.errors() == [
+            {'loc': ('links',), 'msg': 'field required', 'type': 'value_error.missing'}
+        ]
\ No newline at end of file
diff --git a/api/tests/opentrons/app/models/json_api/test_response.py b/api/tests/opentrons/app/models/json_api/test_response.py
new file mode 100644
index 00000000000..02c7d773f80
--- /dev/null
+++ b/api/tests/opentrons/app/models/json_api/test_response.py
@@ -0,0 +1,279 @@
+from pytest import raises
+from pydantic import BaseModel, ValidationError
+
+from opentrons.app.models.json_api.response import JsonApiResponse
+from tests.opentrons.app.helpers import ItemModel, ItemData
+
+class TestJsonApiResponse:
+    def test_attributes_as_dict(self):
+        MyResponse = JsonApiResponse('item', dict)
+        obj_to_validate = {
+            'data': {'id': '123', 'type': 'item', 'attributes': {}},
+        }
+        my_response_object = MyResponse(**obj_to_validate)
+        assert my_response_object.dict() == {
+            'data': {
+                'id': '123',
+                'type': 'item',
+                'attributes': {},
+            }
+        }
+
+    def test_missing_attributes_dict(self):
+        MyResponse = JsonApiResponse('item', dict)
+        obj_to_validate = {
+            'data': {'id': '123', 'type': 'item'}
+        }
+        my_response_object = MyResponse(**obj_to_validate)
+        assert my_response_object.dict() == {
+            'data': {
+                'id': '123',
+                'type': 'item',
+                'attributes': {},
+            }
+        }
+
+    def test_missing_attributes_empty_model(self):
+        class EmptyModel(BaseModel):
+            pass
+
+        MyResponse = JsonApiResponse('item', EmptyModel)
+        obj_to_validate = {
+            'data': {'id': '123', 'type': 'item'}
+        }
+        my_response_object = MyResponse(**obj_to_validate)
+        assert my_response_object.dict() == {
+            'data': {
+                'id': '123',
+                'type': 'item',
+                'attributes': {},
+            }
+        }
+        assert isinstance(my_response_object.data.attributes, EmptyModel)
+
+    def test_attributes_as_item_model(self):
+        ItemResponse = JsonApiResponse('item', ItemModel)
+        obj_to_validate = {
+            'data': {
+                'id': '123',
+                'type': 'item',
+                'attributes': {
+                    'name': 'apple',
+                    'quantity': 10,
+                    'price': 1.20
+                }
+            }
+        }
+        my_response_obj = ItemResponse(**obj_to_validate)
+        assert my_response_obj.dict() == {
+            'data': {
+                'id': '123',
+                'type': 'item',
+                'attributes': {
+                    'name': 'apple',
+                    'quantity': 10,
+                    'price': 1.20,
+                }
+            }
+        }
+
+    def test_list_item_model(self):
+        ItemResponse = JsonApiResponse('item', ItemModel, use_list=True)
+        obj_to_validate = {
+            'data': [
+                {
+                    'id': '123',
+                    'type': 'item',
+                    'attributes': {
+                        'name': 'apple',
+                        'quantity': 10,
+                        'price': 1.20
+                    },
+                },
+                {
+                    'id': '321',
+                    'type': 'item',
+                    'attributes': {
+                        'name': 'banana',
+                        'quantity': 20,
+                        'price': 2.34
+                    },
+                },
+            ],
+        }
+        my_response_obj = ItemResponse(**obj_to_validate)
+        assert my_response_obj.dict() == {
+            'data': [
+                {
+                    'id': '123',
+                    'type': 'item',
+                    'attributes': {
+                        'name': 'apple',
+                        'quantity': 10,
+                        'price': 1.20,
+                    },
+                },
+                {
+                    'id': '321',
+                    'type': 'item',
+                    'attributes': {
+                        'name': 'banana',
+                        'quantity': 20,
+                        'price': 2.34,
+                    },
+                },
+            ],
+        }
+
+    def test_type_invalid_string(self):
+        MyResponse = JsonApiResponse('item', dict)
+        obj_to_validate = {
+            'data': {'id': '123', 'type': 'not_an_item', 'attributes': {}}
+        }
+        with raises(ValidationError) as e:
+            MyResponse(**obj_to_validate)
+
+        assert e.value.errors() == [
+            {
+                'loc': ('data', 'type'),
+                'msg': "unexpected value; permitted: 'item'",
+                'type': 'value_error.const',
+                'ctx': {'given': 'not_an_item', 'permitted': ('item',)},
+            },
+        ]
+
+    def test_attributes_required(self):
+        ItemResponse = JsonApiResponse('item', ItemModel)
+        obj_to_validate = {
+            'data': {'id': '123', 'type': 'item', 'attributes': None}
+        }
+        with raises(ValidationError) as e:
+            ItemResponse(**obj_to_validate)
+
+        assert e.value.errors() == [
+            {
+                'loc': ('data', 'attributes'),
+                'msg': 'none is not an allowed value',
+                'type': 'type_error.none.not_allowed',
+            },
+        ]
+
+    def test_attributes_as_item_model__empty_dict(self):
+        ItemResponse = JsonApiResponse('item', ItemModel)
+        obj_to_validate = {
+            'data': {
+                'id': '123',
+                'type': 'item',
+                'attributes': {}
+            }
+        }
+        with raises(ValidationError) as e:
+            ItemResponse(**obj_to_validate)
+
+        assert e.value.errors() == [
+            {'loc': ('data', 'attributes', 'name'), 'msg': 'field required', 'type': 'value_error.missing'},
+            {'loc': ('data', 'attributes', 'quantity'), 'msg': 'field required', 'type': 'value_error.missing'},
+            {'loc': ('data', 'attributes', 'price'), 'msg': 'field required', 'type': 'value_error.missing'},
+        ]
+
+    def test_resource_object_constructor(self):
+        ItemResponse = JsonApiResponse('item', ItemModel)
+        item = ItemModel(name='pear', price=1.2, quantity=10)
+        document = ItemResponse.resource_object(id='abc123', attributes=item).dict()
+        
+        assert document == {
+            'id': 'abc123',
+            'type': 'item',
+            'attributes': {
+                'name': 'pear',
+                'price': 1.2,
+                'quantity': 10,
+            }
+        }
+
+    def test_resource_object_constructor__no_attributes(self):
+        IdentifierResponse = JsonApiResponse('item', dict)
+        document = IdentifierResponse.resource_object(id='abc123').dict()
+
+        assert document == {
+            'id': 'abc123',
+            'type': 'item',
+            'attributes': {},
+        }
+    
+    def test_resource_object_constructor__with_list_response(self):
+        ItemResponse = JsonApiResponse('item', ItemModel, use_list=True)
+        item = ItemModel(name='pear', price=1.2, quantity=10)
+        document = ItemResponse.resource_object(id='abc123', attributes=item).dict()
+        
+        assert document == {
+            'id': 'abc123',
+            'type': 'item',
+            'attributes': {
+                'name': 'pear',
+                'price': 1.2,
+                'quantity': 10,
+            }
+        }
+
+    def test_response_constructed_with_resource_object(self):
+        ItemResponse = JsonApiResponse('item', ItemModel)
+        item = ItemModel(name='pear', price=1.2, quantity=10)
+        data = ItemResponse.resource_object(id='abc123', attributes=item).dict()
+
+        assert ItemResponse(data=data).dict() == {
+            "data": {
+                'id': 'abc123',
+                "type": 'item',
+                "attributes": {
+                    'name': 'pear',
+                    'price': 1.2,
+                    'quantity': 10,
+                },
+            }
+        }
+
+    def test_response_constructed_with_resource_object__list(self):
+        ItemResponse = JsonApiResponse('item', ItemModel, use_list=True)
+        items = [
+            ItemData(id=1, name='apple', price=1.5, quantity=3),
+            ItemData(id=2, name='pear', price=1.2, quantity=10),
+            ItemData(id=3, name='orange', price=2.2, quantity=5)
+        ]
+        response = ItemResponse(
+            data=[
+                ItemResponse.resource_object(id=item.id, attributes=vars(item))
+                for item in items
+            ]
+        )
+        assert response.dict() == {
+            'data': [
+                {
+                    'id': '1',
+                    'type': 'item',
+                    'attributes': {
+                        'name': 'apple',
+                        'price': 1.5,
+                        'quantity': 3,
+                    },
+                },
+                {
+                    'id': '2',
+                    'type': 'item',
+                    'attributes': {
+                        'name': 'pear',
+                        'price': 1.2,
+                        'quantity': 10,
+                    },
+                },
+                {
+                    'id': '3',
+                    'type': 'item',
+                    'attributes': {
+                        'name': 'orange',
+                        'price': 2.2,
+                        'quantity': 5,
+                    },
+                },
+            ]
+        }
\ No newline at end of file
diff --git a/api/tests/opentrons/app/routers/test_item.py b/api/tests/opentrons/app/routers/test_item.py
index 28c9bc5f76d..7664b6539fa 100644
--- a/api/tests/opentrons/app/routers/test_item.py
+++ b/api/tests/opentrons/app/routers/test_item.py
@@ -6,22 +6,23 @@
 
 client = TestClient(app)
 
-# TODO(isk: 2/7/20): Add factories and add/refactor setup
-
 def test_get_item():
     item_id = "1"
     response = client.get(f'items/{item_id}')
     assert response.status_code == HTTP_200_OK
     assert response.json() == {
-      "data": {
-        "id": item_id,
-        "type": 'item',
-        "attributes": {
-          "name": "apple",
-          "quantity": 10,
-          "price": 1.20
+        "data": {
+            "id": item_id,
+            "type": 'item',
+            "attributes": {
+                "name": "apple",
+                "quantity": 10,
+                "price": 1.2
+            },
+        },
+        "links": {
+            "self": f'/items/{item_id}',
         }
-      }
     }
 
 def test_create_item():
@@ -29,41 +30,58 @@ def test_create_item():
     item = ItemData(**data)
     response = client.post(
         "/items",
-        json=vars(item)
+        json={"data": { "type": "item", "attributes": vars(item) }}
     )
     assert response.status_code == HTTP_200_OK
     assert response.json() == {
-      "meta": None,
-      "links": None,
-      "data": {
-        "id": item.id,
-        "type": 'item',
-        "attributes": {
-          "name": item.name,
-          "quantity": item.quantity,
-          "price": item.price
+        "data": {
+            "id": item.id,
+            "type": 'item',
+            "attributes": {
+                "name": item.name,
+                "quantity": item.quantity,
+                "price": item.price
+            },
+        },
+        "links": {
+            "self": f'/items/{item.id}',
         }
-      }
     }
 
 def test_create_item_with_attribute_validation_error():
     response = client.post(
         "/items",
-        json={ "quantity": "10", "price": 1.20 }
+        json={
+            "data": {
+                "type": "item",
+                "attributes": {}
+            }
+        }
     )
     assert response.status_code == HTTP_422_UNPROCESSABLE_ENTITY
     assert response.json() == {
       'errors': [{
-          'id': None,
-          'links': None,
           'status': str(HTTP_422_UNPROCESSABLE_ENTITY),
-          'code': None,
           'title': 'value_error.missing',
           'detail': 'field required',
           'source': {
-            'pointer': '/body/attributes/name',
-            'parameter': None
-          },
-          'meta': None
+            'pointer': '/body/item_request/data/attributes/name',
+          }
+      },
+      {
+          'status': str(HTTP_422_UNPROCESSABLE_ENTITY),
+          'title': 'value_error.missing',
+          'detail': 'field required',
+          'source': {
+            'pointer': '/body/item_request/data/attributes/quantity',
+          }
+      },
+      {
+          'status': str(HTTP_422_UNPROCESSABLE_ENTITY),
+          'title': 'value_error.missing',
+          'detail': 'field required',
+          'source': {
+            'pointer': '/body/item_request/data/attributes/price',
+          }
       }]
-    }
\ No newline at end of file
+    }
diff --git a/robot-server/robot_server/service/main.py b/robot-server/robot_server/service/main.py
index 0179f4876f1..60e9bf4a567 100644
--- a/robot-server/robot_server/service/main.py
+++ b/robot-server/robot_server/service/main.py
@@ -1,5 +1,5 @@
 from opentrons import __version__
-from fastapi import FastAPI, HTTPException
+from fastapi import FastAPI
 from fastapi.exceptions import RequestValidationError
 from starlette.responses import JSONResponse
 from starlette.exceptions import HTTPException as StarletteHTTPException
@@ -22,29 +22,17 @@
 @app.exception_handler(RequestValidationError)
 async def custom_request_validation_exception_handler(request, exception) -> JSONResponse:
     errors = transform_to_json_api_errors(HTTP_422_UNPROCESSABLE_ENTITY, exception)
-    errors_response = ErrorResponse(**errors)
     return JSONResponse(
         status_code=HTTP_422_UNPROCESSABLE_ENTITY,
-        content=errors_response.dict(),
+        content=errors,
      )
 
-# @app.exception_handler(HTTPException)
-# async def custom_http_exception_handler(request, exception) -> JSONResponse:
-#     errors = transform_to_json_api_errors(exception.status_code, exception.detail)
-#     errors_response = ErrorResponse(**errors)
-#     return JSONResponse(
-#         status_code=exception.status_code,
-#         content=errors_response.dict(),
-#      )
-
 @app.exception_handler(StarletteHTTPException)
 async def custom_http_exception_handler(request, exception) -> JSONResponse:
-    print('HERE', exception.detail)
-    errors = transform_to_json_api_errors(exception.status_code, exception.detail)
-    errors_response = ErrorResponse(**errors)
+    errors = transform_to_json_api_errors(exception.status_code, exception)
     return JSONResponse(
         status_code=exception.status_code,
-        content=errors_response.dict(),
+        content=errors,
      )
 
 app.include_router(router=health.router,