Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(robot-server): add formatted json responses and error handling #5208

Merged
merged 2 commits into from
Mar 18, 2020

Conversation

iansolano
Copy link
Contributor

@iansolano iansolano commented Mar 11, 2020

Adds formatted json responses and error handling in FastAPI framework

closes #4636

overview

changelog

review requests

In the robot-server project:

  • run OT_API_FF_useFastApi=TRUE make dev

You should be able to visit /docs route on localhost. This will allow you to review the swagger interactive docs. The two endpoints these changes affect are the placeholder endpoints /items and /items/{item_id}

  • Make sure 200 responses are returned as expected
  • Test error responses when JSON is invalid structure
  • Test error responses when there is a model validation error

Please add comments around any additions you would like to see, this is a first pass at solidifying some structure based on the JSON:API spec but we can change and add as we see fit.

Still todo:

  • Need to fixup types!
  • Add an endpoint that returns multiple resources
  • Determine how the meta object will be formatted
  • Add a convenience method for formatting link responses

@iansolano iansolano added ready for review robot-svcs Falls under the purview of the Robot Services squad (formerly CPX, Core Platform Experience). robot server Affects the `robot-server` project labels Mar 11, 2020
@iansolano iansolano requested a review from a team as a code owner March 11, 2020 16:41
Comment on lines 33 to 40
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())
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is annoying b/c the error comes back differently if it's a HTTPException, I can probably clean this up a bit (feels a bit redundant)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My preference would be a different function for each Exception type.

# 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}'})
Copy link
Contributor Author

@iansolano iansolano Mar 11, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should add a helper method in ItemResponse to format links similar to resource_object class method.

Copy link
Member

@sfoster1 sfoster1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple small typos but I like the model factories. Is the idea to use this on everything?

title: Optional[str]
detail: Optional[str]
source: Optional[ErrorSource]
meta: Optional[dict]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here and elsewhere, we should use typing.Dict in type definitions - it's the typing generic version of dictionaries

def dict(
self,
*,
serlialize_none: bool = False,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo


def transform_to_json_api_errors(status_code, exception) -> dict:
if isinstance(exception, StarletteHTTPException):
request_error = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can also construct this using an Error object rather than a dict.

Comment on lines 33 to 40
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())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My preference would be a different function for each Exception type.

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

def JsonApiModel(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

functions should use snake case.

'title': 'Bad Request'
}
error_response = ErrorResponse(errors=[request_error])
return filter_none(error_response.dict())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you saw, you can direct fastapi to omit default or unset values in handlers. Looks like you can also tell pydantic to do that when converting to dict. So you might not need the filter functions.

https://pydantic-docs.helpmanual.io/usage/exporting_models/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice! this was what i found yesterday (https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter) so it looks like we can get rid of this filter_none stuff entirely and TBH it's a bit more explicit in the handlers

@iansolano
Copy link
Contributor Author

@sfoster1 yeah, the idea would be to use this to format our api responses going forward (new endpoints).

@iansolano iansolano requested a review from a team as a code owner March 12, 2020 19:11
@codecov
Copy link

codecov bot commented Mar 12, 2020

Codecov Report

Merging #5208 into edge will increase coverage by 0.16%.
The diff coverage is 95.80%.

Impacted file tree graph

@@            Coverage Diff             @@
##             edge    #5208      +/-   ##
==========================================
+ Coverage   61.17%   61.34%   +0.16%     
==========================================
  Files        1032     1039       +7     
  Lines       29416    29559     +143     
==========================================
+ Hits        17995    18132     +137     
- Misses      11421    11427       +6     
Impacted Files Coverage Δ
robot-server/robot_server/service/routers/item.py 78.57% <78.57%> (ø)
robot-server/robot_server/service/main.py 100.00% <100.00%> (ø)
robot-server/robot_server/service/models/item.py 100.00% <100.00%> (ø)
...ver/robot_server/service/models/json_api/errors.py 100.00% <100.00%> (ø)
...er/robot_server/service/models/json_api/factory.py 100.00% <100.00%> (ø)
...er/robot_server/service/models/json_api/request.py 100.00% <100.00%> (ø)
...t_server/service/models/json_api/resource_links.py 100.00% <100.00%> (ø)
...r/robot_server/service/models/json_api/response.py 100.00% <100.00%> (ø)
... and 5 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 91178f0...0b1fb67. Read the comment docs.

@mcous mcous changed the title feat(robot-server): adds formatted json responses and error handling feat(robot-server): add formatted json responses and error handling Mar 12, 2020


class ErrorSource(BaseModel):
pointer: Optional[str]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that it's essential for this PR, but adding documentation for these attributes will be great for the docs UI:

my_field: str = Field(..., description="This is my field")

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, yeah - thanks i'll add now

errors: List[Error]


def transform_http_exception_to_json_api_errors(exception) -> Dict:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I would return an ErrorResponse object from these two functions. Only converting it to Dict when it needs to go over the wire. It's a minor point, but it's about habit. The habit of using clearly defined types rather than dicts and tuples and so on.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes a lot of sense

from typing import Generic, TypeVar, Optional, Any, Type
from typing_extensions import Literal
from pydantic.generics import GenericModel

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know I'm old fashioned, but I like comments. This is somewhat complicated and comments explain what these types are would be great.

from robot_server.service.main import app
from tests.service.helpers import ItemData

client = TestClient(app)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's nothing necessarily wrong with this, but our python code base tends to use pytest and their fixtures.

In fact, if you rebase of of edge, you can just use the fixture that's already set up called api_client. That way your tests could look like:

def test_get_item(api_client):
   response = api_client.get(....)

from robot_server.service.models.json_api.response import JsonApiResponse
from tests.service.helpers import ItemModel, ItemData

class TestJsonApiResponse:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a need for there to be a class here. Can't the tests just all be global functions? That would more jive with the rest of our code base.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

they sure can. yup, makes sense to be consistent was just trying to keep nicely organized, I didn't realize that we don't do this elsewhere. my bad

@amitlissack
Copy link
Contributor

This is a great piece of work!

A lot of style comments. Pythonisms etc. And just asking for more comments.

@iansolano iansolano force-pushed the robot-server_json-response-patterns branch 3 times, most recently from a8794cf to bf814b3 Compare March 16, 2020 14:51
Copy link
Contributor

@amitlissack amitlissack left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Woot!

@iansolano iansolano requested a review from sfoster1 March 16, 2020 15:36
@@ -28,6 +36,8 @@
tags=["settings"])
app.include_router(router=deck_calibration.router,
tags=["deckCalibration"])
app.include_router(router=item.router,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use a TODO comment noting that this should be removed

" meta-information about the error.")


class ErrorResponse(BaseModel):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't match what's in our documentation at https://coda.io/d/HTTP-API-v2_dS1dJrSYETc/Error-Responses_suowf#_luSMh. Can you make sure the docs are up to date?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, for sure. I will update this accordingly. thanks for pointing out

exception: RequestValidationError
) -> JSONResponse:
errors = transform_validation_error_to_json_api_errors(
HTTP_422_UNPROCESSABLE_ENTITY, exception
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious about the rational behind using what appears to be a WebDAV extension code (422) instead of an old-fashioned 400

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fastapi and json:api spec both return validation errors as 422. The main difference between 400 is that the syntax is correct in the request. See below (https://tools.ietf.org/html/rfc4918#section-11.2):

The 422 (Unprocessable Entity) status code means the server understands the content type of the request entity (hence a 415(Unsupported Media Type) status code is inappropriate), and the syntax of the request entity is correct (thus a 400 (Bad Request) status code is inappropriate) but was unable to process the contained instructions.



@router.get("/items/{item_id}",
description="Get an individual item by it's ID",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/it's/its

Copy link
Member

@sfoster1 sfoster1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One minor nitpick but if it passes tests I'm ok with it. A lot of fun type manipulation, I like it. You might want to get some solid documentation going somewhere to onboard people who haven't really used runtime type handling.


# Note(isk: 3/13/20): returns response based on whether
# the data object is a list or not
def JsonApiResponse(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's keep to snake case or (if you must) camelCase for functions please

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, sorry - i meant to change that. thanks for catching

Add formatted json responses and error handling in FastAPI framework

closes #4636
@iansolano iansolano force-pushed the robot-server_json-response-patterns branch from 821b170 to 0b1fb67 Compare March 18, 2020 16:06
@iansolano iansolano merged commit 25e9298 into edge Mar 18, 2020
@mcous mcous deleted the robot-server_json-response-patterns branch May 6, 2020 23:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
robot server Affects the `robot-server` project robot-svcs Falls under the purview of the Robot Services squad (formerly CPX, Core Platform Experience).
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Robot HTTP Api: Establish error codes and structures
4 participants