Skip to content

Commit

Permalink
feat(api): adds response formatting to api requests
Browse files Browse the repository at this point in the history
Adds response formatting to api requests in new fastapi framework

closes #4636
  • Loading branch information
iansolano committed Mar 11, 2020
1 parent 6f99d4e commit e1adc9d
Show file tree
Hide file tree
Showing 15 changed files with 658 additions and 264 deletions.
100 changes: 2 additions & 98 deletions Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 20 additions & 11 deletions api/src/opentrons/app/models/json_api/errors.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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())
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())
83 changes: 0 additions & 83 deletions api/src/opentrons/app/models/json_api/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 2 additions & 6 deletions api/src/opentrons/app/models/json_api/resource_links.py
Original file line number Diff line number Diff line change
@@ -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"
33 changes: 30 additions & 3 deletions api/src/opentrons/app/models/json_api/response.py
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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,
Expand Down
24 changes: 12 additions & 12 deletions api/src/opentrons/app/routers/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)

Expand All @@ -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:
Expand Down
5 changes: 0 additions & 5 deletions api/src/opentrons/protocol_api/labware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions api/tests/opentrons/app/helpers.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit e1adc9d

Please sign in to comment.