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,