Skip to content

Commit

Permalink
feat(robot-server): adds formatted json responses and error handling
Browse files Browse the repository at this point in the history
Adds formatted json responses and error handling in FastAPI framework

closes #4636
  • Loading branch information
iansolano committed Mar 11, 2020
1 parent b15d360 commit e32d2d4
Show file tree
Hide file tree
Showing 16 changed files with 918 additions and 1 deletion.
25 changes: 24 additions & 1 deletion robot-server/robot_server/service/main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
from opentrons import __version__
from fastapi import FastAPI
from .routers import health, networking, control, settings, deck_calibration
from fastapi.exceptions import RequestValidationError
from starlette.responses import JSONResponse
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

from .routers import health, networking, control, settings, deck_calibration, item
from .models.json_api.errors import ErrorResponse, transform_to_json_api_errors

app = FastAPI(
title="Opentrons OT-2 HTTP API Spec",
Expand All @@ -13,6 +19,21 @@
version=__version__
)

@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)
return JSONResponse(
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
content=errors,
)

@app.exception_handler(StarletteHTTPException)
async def custom_http_exception_handler(request, exception) -> JSONResponse:
errors = transform_to_json_api_errors(exception.status_code, exception)
return JSONResponse(
status_code=exception.status_code,
content=errors,
)

app.include_router(router=health.router,
tags=["health"])
Expand All @@ -24,3 +45,5 @@
tags=["settings"])
app.include_router(router=deck_calibration.router,
tags=["deckCalibration"])
app.include_router(router=item.router,
tags=["item"])
15 changes: 15 additions & 0 deletions robot-server/robot_server/service/models/item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from pydantic import BaseModel
from dataclasses import dataclass
from uuid import uuid4

@dataclass
class ItemData:
name: str
quantity: int
price: float
id: str = str(uuid4().hex)

class Item(BaseModel):
name: str
quantity: int
price: float
52 changes: 52 additions & 0 deletions robot-server/robot_server/service/models/json_api/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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

from .filter import filter_none
from .resource_links import ResourceLinks


class ErrorSource(BaseModel):
pointer: Optional[str]
parameter: Optional[str]


class Error(BaseModel):
"""https://jsonapi.org/format/#error-objects"""
id: Optional[str]
links: Optional[ResourceLinks]
status: Optional[str]
code: Optional[str]
title: Optional[str]
detail: Optional[str]
source: Optional[ErrorSource]
meta: Optional[dict]


class ErrorResponse(BaseModel):
errors: List[Error]

def transform_to_json_api_errors(status_code, exception) -> dict:
if isinstance(exception, StarletteHTTPException):
request_error = {
'status': status_code,
'detail': exception.detail,
'title': 'Bad Request'
}
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())
15 changes: 15 additions & 0 deletions robot-server/robot_server/service/models/json_api/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from typing import Any, Tuple

from .request import JsonApiRequest, RequestModel
from .response import JsonApiResponse, ResponseModel

def JsonApiModel(
type_string: str,
attributes_model: Any,
*,
list_response: bool = False
) -> Tuple[RequestModel, ResponseModel]:
return (
JsonApiRequest(type_string, attributes_model),
JsonApiResponse(type_string, attributes_model, use_list=list_response),
)
17 changes: 17 additions & 0 deletions robot-server/robot_server/service/models/json_api/filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from typing import TypeVar
from collections.abc import Mapping, Iterable

T = TypeVar('T')
def filter_none(thing_to_traverse: T) -> T:
if isinstance(thing_to_traverse, dict):
return {
k: filter_none(v)
for k, v in thing_to_traverse.items()
if v is not None
}
elif isinstance(thing_to_traverse, list):
return [
filter_none(item)
for item in thing_to_traverse
]
return thing_to_traverse
32 changes: 32 additions & 0 deletions robot-server/robot_server/service/models/json_api/request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from typing import Generic, TypeVar, Optional, Any, Type
from typing_extensions import Literal
from pydantic.generics import GenericModel

TypeT = TypeVar('TypeT')
AttributesT = TypeVar('AttributesT')
class RequestDataModel(GenericModel, Generic[TypeT, AttributesT]):
"""
"""
id: Optional[str]
type: TypeT
attributes: AttributesT


DataT = TypeVar('DataT', bound=RequestDataModel)
class RequestModel(GenericModel, Generic[DataT]):
"""
"""
data: DataT

def attributes(self):
return self.data.attributes

def JsonApiRequest(type_string: str, attributes_model: Any) -> Type[RequestModel]:
request_data_model = RequestDataModel[
Literal[type_string],
attributes_model,
]
request_data_model.__name__ = f'RequestData[{type_string}]'
request_model = RequestModel[request_data_model]
request_model.__name__ = f'Request[{type_string}]'
return request_model
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from pydantic import BaseModel

class ResourceLinks(BaseModel):
self: str

ResourceLinks.__doc__ = "https://jsonapi.org/format/#document-links"
76 changes: 76 additions & 0 deletions robot-server/robot_server/service/models/json_api/response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from typing import Generic, TypeVar, Optional, List, Any, Type, get_type_hints
from typing_extensions import Literal

from pydantic.generics import GenericModel

from .filter import filter_none
from .resource_links import ResourceLinks

TypeT = TypeVar('TypeT', bound=str)
AttributesT = TypeVar('AttributesT')
class ResponseDataModel(GenericModel, Generic[TypeT, AttributesT]):
"""
"""
id: str
type: TypeT
attributes: AttributesT = {}

class Config:
validate_all = True

DataT = TypeVar('DataT', bound=ResponseDataModel)
class ResponseModel(GenericModel, Generic[DataT]):
"""
"""
meta: Optional[dict]
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,
*,
use_list: bool = False
) -> Type[ResponseModel]:
response_data_model = ResponseDataModel[
Literal[type_string],
attributes_model,
]
if use_list:
response_data_model = List[response_data_model]
response_data_model.__name__ = f'ListResponseData[{type_string}]'
response_model = ResponseModel[response_data_model]
response_model.__name__ = f'ListResponse[{type_string}]'
else:
response_data_model.__name__ = f'ResponseData[{type_string}]'
response_model = ResponseModel[response_data_model]
response_model.__name__ = f'Response[{type_string}]'
return response_model
51 changes: 51 additions & 0 deletions robot-server/robot_server/service/routers/item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import inspect

from fastapi import APIRouter, Depends, HTTPException
from pydantic import ValidationError

from robot_server.service.models.item import Item, ItemData
from robot_server.service.models.json_api.factory import JsonApiModel
from robot_server.service.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

router = APIRouter()

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",
summary="Get an individual item",
response_model=ItemResponse,
responses={
HTTP_422_UNPROCESSABLE_ENTITY: { "model": ErrorResponse },
})
async def get_item(item_id: int) -> ItemResponse:
try:
# 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)

@router.post("/items",
description="Create an item",
summary="Create an item via post route",
response_model=ItemResponse,
responses={
HTTP_400_BAD_REQUEST: { "model": ErrorResponse },
HTTP_422_UNPROCESSABLE_ENTITY: { "model": ErrorResponse },
})
async def create_item(item_request: ItemRequest) -> ItemResponse:
try:
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:
raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=e)
20 changes: 20 additions & 0 deletions robot-server/tests/service/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from pydantic import BaseModel
from dataclasses import dataclass
from uuid import uuid4

from robot_server.service.models.json_api.request import JsonApiRequest

@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 e32d2d4

Please sign in to comment.