diff --git a/.github/workflows/test_suite.yml b/.github/workflows/test_suite.yml index 4ba4ad4..3c9d710 100644 --- a/.github/workflows/test_suite.yml +++ b/.github/workflows/test_suite.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - python-version: ["3.6", "3.7", "3.8"] + python-version: ["3.6", "3.7", "3.8", "3.9"] steps: - uses: "actions/checkout@v2" diff --git a/README.md b/README.md index 0159911..499d413 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ any available async ORM. This also means that you are going to get a very basic with some helpers to make your experience more pleasant, but nothing fancy. ##### Installing -`pip install starlette-jsonapi==1.2.0` +`pip install starlette-jsonapi` Since this project is under development, please pin your dependencies to avoid problems. @@ -25,9 +25,9 @@ Since this project is under development, please pin your dependencies to avoid p - sparse fields - support for client generated IDs - support top level meta objects +- [pagination helpers](https://jsonapi.org/format/#fetching-pagination) ### Todo: -- [pagination helpers](https://jsonapi.org/format/#fetching-pagination) - [sorting helpers](https://jsonapi.org/format/#fetching-sorting) - examples for other ORMs - [support jsonapi objects](https://jsonapi.org/format/#document-jsonapi-object) @@ -39,6 +39,18 @@ Available on [Read The Docs](https://starlette-jsonapi.readthedocs.io/) You should take a look at the [examples](examples) directory for full implementations. +To generate documentation files locally, you should create a virtualenv, +then activate it and install the requirements: +```shell +cd docs +pip install -r requirements.txt +``` + +With the docs virtualenv activated, you can then run `make html` to generate the HTML files. + +The result will be written to `docs/build`, and you can open `docs/build/html/index.html` in your browser of choice +to view the pages. + ## Contributing This project is in its early days, so **any** help is appreciated. @@ -47,4 +59,4 @@ As simple as running ```tox```. If you plan to use pyenv and want to run tox for multiple python versions, you can create multiple virtual environments and then make them available to tox by running -something like: `pyenv shell starlette_jsonapi_venv36 starlette_jsonapi_venv37`. +something like: `pyenv shell starlette_jsonapi_venv36 starlette_jsonapi_venv37 starlette_jsonapi_venv38 starlette_jsonapi_venv39`. diff --git a/docs/source/_static/wide_theme.css b/docs/source/_static/wide_theme.css new file mode 100644 index 0000000..c83eced --- /dev/null +++ b/docs/source/_static/wide_theme.css @@ -0,0 +1,3 @@ +.wy-nav-content { + /*max-width: 75%;*/ +} \ No newline at end of file diff --git a/docs/source/api_reference/modules.rst b/docs/source/api_reference/modules.rst index 3d2870e..a010149 100644 --- a/docs/source/api_reference/modules.rst +++ b/docs/source/api_reference/modules.rst @@ -4,4 +4,11 @@ starlette_jsonapi .. toctree:: :maxdepth: 4 - starlette_jsonapi + starlette_jsonapi.exceptions + starlette_jsonapi.fields + starlette_jsonapi.meta + starlette_jsonapi.pagination + starlette_jsonapi.resource + starlette_jsonapi.responses + starlette_jsonapi.schema + starlette_jsonapi.utils diff --git a/docs/source/api_reference/starlette_jsonapi.pagination.rst b/docs/source/api_reference/starlette_jsonapi.pagination.rst new file mode 100644 index 0000000..280d96f --- /dev/null +++ b/docs/source/api_reference/starlette_jsonapi.pagination.rst @@ -0,0 +1,14 @@ +starlette\_jsonapi.pagination module +==================================== + +.. automodule:: starlette_jsonapi.pagination + :members: + :exclude-members: Pagination + :undoc-members: + :show-inheritance: + :inherited-members: + + +.. autoclass:: starlette_jsonapi.pagination.Pagination + :members: + :show-inheritance: diff --git a/docs/source/api_reference/starlette_jsonapi.resource.rst b/docs/source/api_reference/starlette_jsonapi.resource.rst index a5c3012..a228a01 100644 --- a/docs/source/api_reference/starlette_jsonapi.resource.rst +++ b/docs/source/api_reference/starlette_jsonapi.resource.rst @@ -5,3 +5,4 @@ starlette\_jsonapi.resource module :members: :undoc-members: :show-inheritance: + :inherited-members: diff --git a/docs/source/api_reference/starlette_jsonapi.responses.rst b/docs/source/api_reference/starlette_jsonapi.responses.rst index 80bd38b..3c73dbc 100644 --- a/docs/source/api_reference/starlette_jsonapi.responses.rst +++ b/docs/source/api_reference/starlette_jsonapi.responses.rst @@ -3,5 +3,11 @@ starlette\_jsonapi.responses module .. automodule:: starlette_jsonapi.responses :members: + :exclude-members: JSONAPIResponse :undoc-members: :show-inheritance: + +.. autoclass:: starlette_jsonapi.responses.JSONAPIResponse + :members: + :undoc-members: + :exclude-members: render \ No newline at end of file diff --git a/docs/source/api_reference/starlette_jsonapi.rst b/docs/source/api_reference/starlette_jsonapi.rst deleted file mode 100644 index f44e792..0000000 --- a/docs/source/api_reference/starlette_jsonapi.rst +++ /dev/null @@ -1,24 +0,0 @@ -starlette\_jsonapi package -========================== - -Submodules ----------- - -.. toctree:: - :maxdepth: 4 - - starlette_jsonapi.exceptions - starlette_jsonapi.fields - starlette_jsonapi.meta - starlette_jsonapi.resource - starlette_jsonapi.responses - starlette_jsonapi.schema - starlette_jsonapi.utils - -Module contents ---------------- - -.. automodule:: starlette_jsonapi - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/api_reference/starlette_jsonapi.schema.rst b/docs/source/api_reference/starlette_jsonapi.schema.rst index 0fc3721..0c604c7 100644 --- a/docs/source/api_reference/starlette_jsonapi.schema.rst +++ b/docs/source/api_reference/starlette_jsonapi.schema.rst @@ -4,4 +4,5 @@ starlette\_jsonapi.schema module .. automodule:: starlette_jsonapi.schema :members: :undoc-members: + :exclude-members: BaseSchemaOpts :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py index 0c93458..fa75295 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -58,6 +58,17 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = [] +html_static_path = ['_static'] autodoc_typehints = 'description' + +autodoc_default_options = { + 'member-order': 'bysource', + 'special-members': '__init__', +} + +autodoc_inherit_docstrings = False + + +def setup(app): + app.add_css_file('wide_theme.css') diff --git a/docs/source/how_to.rst b/docs/source/how_to.rst index ef00d9e..a7b5b01 100644 --- a/docs/source/how_to.rst +++ b/docs/source/how_to.rst @@ -77,7 +77,7 @@ mentions: If you intend to use ``uuid`` IDs, set ``id_mask = 'uuid'`` when defining the Resource class, and some validation will be handled by Starlette. -Requests with malformed IDS will likely result in 404 errors. +Requests with malformed IDs will likely result in 404 errors. Top level meta objects ---------------------- @@ -120,5 +120,5 @@ Versioning can be implemented by specifying ``register_as`` on the resource clas ExampleResourceV2.register_routes(app, base_path='/v2/') # both resources are now accessible without conflicts: - assert app.url_path_for('v1-examples:get_all') == '/v1/examples/' - assert app.url_path_for('v2-examples:get_all') == '/v2/examples/' + assert app.url_path_for('v1-examples:get_many') == '/v1/examples/' + assert app.url_path_for('v2-examples:get_many') == '/v2/examples/' diff --git a/docs/source/index.rst b/docs/source/index.rst index 26d7e4f..96c7ad4 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -24,11 +24,11 @@ Features - sparse fields - support for client generated IDs - support top level meta objects +- `pagination helpers `_ Todo ---- -- `pagination helpers `_ - `sorting helpers `_ - `support jsonapi objects `_ - `enforce member name validation `_ diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 1c8fc43..415ea63 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -14,7 +14,7 @@ We are not going to bother with an actual ORM right now, so let's start by defin title: str content: str author: Author - comments: Optional[List[Comment]] # this would be populated dynamically by an ORM + comments: Optional[List['Comment']] # this would be populated dynamically by an ORM class Comment: id: int @@ -57,14 +57,12 @@ Let's take Article as an example: author = JSONAPIRelationship( type_='authors', schema='AuthorSchema', - include_resource_linkage=True, # renders the relationship data required=True, ) comments = JSONAPIRelationship( type_='comments', schema='CommentSchema', - include_resource_linkage=True, many=True, ) @@ -111,17 +109,17 @@ by subclassing :class:`starlette_jsonapi.resource.BaseResource`. # More options available, consult the `starlette` routing documentation. id_mask = 'int' - async def get(self, id=None, *args, **kwargs) -> Response: + async def get(self, id: int, *args, **kwargs) -> Response: """ Will handle GET /articles/ """ article = get_article_by_id(id) # type: Article serialized_article = await self.serialize(data=article) return await self.to_response(serialized_article) - async def patch(self, id=None, *args, **kwargs) -> Response: + async def patch(self, id: int, *args, **kwargs) -> Response: """ Will handle PATCH /articles/ """ ... - async def delete(self, id=None, *args, **kwargs) -> Response: + async def delete(self, id: int, *args, **kwargs) -> Response: """ Will handle DELETE /articles/ """ ... @@ -129,7 +127,7 @@ by subclassing :class:`starlette_jsonapi.resource.BaseResource`. """ Will handle POST /articles/ """ ... - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: """ Will handle GET /articles/ """ ... @@ -147,7 +145,7 @@ the above resource in the Starlette routing mechanism. app = Starlette() - ArticlesResource.register_routes(app=app, base_path='/') + ArticlesResource.register_routes(app=app, base_path='/api/') This will register the following routes: @@ -184,8 +182,8 @@ First, we'll add links by using the route generation available in Starlette # We also indicate the GET /articles/ route, # which is rendered as a link when fetching multiple articles. - # `articles:get_all` is the `ArticlesResource.get_all` handler from above. - self_route_many = 'articles:get_all' + # `articles:get_many` is the `ArticlesResource.get_many` handler from above. + self_route_many = 'articles:get_many' .... @@ -231,12 +229,14 @@ we can implement the :meth:`starlette_jsonapi.resource.BaseResource.get_related` 5. Compound documents --------------------- -That takes care of related resources, but what about compound documents through ``?include=`` requests? -`starlette-jsonapi` helps you with that through the :meth:`starlette_jsonapi.resource.BaseResource.prepare_relations` handler. -For our example, we just need to override the default implementation of ``prepare_relations`` to allow include requests. -That's because the relationship is on the object since we're using plain objects. -However, async ORMs generally can't implement lazy evaluation, -so this method is available to fetch the related resources and make them available to the serialization process. +The previous chapter takes care of related resources, but what about compound documents through ``?include=`` requests? +`starlette-jsonapi` offers :meth:`starlette_jsonapi.resource.BaseResource.include_relations`, which subclasses can override to support compound document requests. +The default implementation will return a 400 Bad Request error, per json:api specifications. + +For our example, we just need to override the default implementation of ``include_relations`` to allow include requests. +That's because the related objects are already populated on the resource in this example, so no additional database operations are required. +However, async ORMs generally can't implement lazy evaluation, so this method should be implemented to fetch the +related resources and make them available during serialization. .. code-block:: python @@ -245,10 +245,10 @@ so this method is available to fetch the related resources and make them availab .... .... - async def prepare_relations(self, obj: Article, relations: List[str]): + async def include_relations(self, obj: Article, relations: List[str]): """ - For our tutorial's Article implementation, we don't need to do anything. - We override the BaseResource implementation to mark support for compound documents. + For our tutorial's Article implementation, we don't need to fetch anything. + We override the base implementation to support compound documents. """ return None @@ -299,7 +299,7 @@ We can also render the link associated to the above relationship resource by pas # The self route is used to generate the relationship's `self` link. self_route='articles:relationships-author', - # The self route looks like this /articles/{id:int}/relationships/author + # The self route looks like this /articles//relationships/author # so we need to indicate the URL path parameters. self_route_kwargs={'parent_id': ''} ) @@ -312,7 +312,7 @@ Just as we did with primary resources, we need to register a relationship resour app = Starlette() - ArticlesResource.register_routes(app=app, base_path='/') + ArticlesResource.register_routes(app=app, base_path='/api/') ArticlesAuthorResource.register_routes(app=app) In the end, our app will have the following routes registered: diff --git a/examples/sample-plain/accounts/resources/organizations.py b/examples/sample-plain/accounts/resources/organizations.py index 3d75c1e..29d74ad 100644 --- a/examples/sample-plain/accounts/resources/organizations.py +++ b/examples/sample-plain/accounts/resources/organizations.py @@ -20,7 +20,7 @@ class Meta: type_ = 'organizations' self_route = 'organizations:get' self_route_kwargs = {'id': ''} - self_route_many = 'organizations:get_all' + self_route_many = 'organizations:get_many' class OrganizationsResource(BaseResource): @@ -71,7 +71,7 @@ async def delete(self, id=None, *args, **kwargs) -> Response: return JSONAPIResponse(status_code=204) - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: organizations = Organization.get_items() return await self.to_response(await self.serialize(data=organizations, many=True, paginate=True)) diff --git a/examples/sample-plain/accounts/resources/teams.py b/examples/sample-plain/accounts/resources/teams.py index c3ff923..c3669fd 100644 --- a/examples/sample-plain/accounts/resources/teams.py +++ b/examples/sample-plain/accounts/resources/teams.py @@ -23,7 +23,6 @@ class TeamSchema(JSONAPISchema): users = JSONAPIRelationship( type_='users', schema='UserSchema', - include_resource_linkage=True, many=True, required=True, self_route='teams:relationships-users', @@ -37,7 +36,7 @@ class Meta: type_ = 'teams' self_route = 'teams:get' self_route_kwargs = {'id': ''} - self_route_many = 'teams:get_all' + self_route_many = 'teams:get_many' class TeamsResource(BaseResource): @@ -45,7 +44,7 @@ class TeamsResource(BaseResource): schema = TeamSchema id_mask = 'int' - async def prepare_relations(self, obj: Team, relations: List[str]): + async def include_relations(self, obj: Team, relations: List[str]): """ We override this to allow `included` requests against this resource, but we don't actually have to do anything here. @@ -99,7 +98,7 @@ async def delete(self, id=None, *args, **kwargs) -> Response: return JSONAPIResponse(status_code=204) - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: teams = Team.get_items() return await self.to_response(await self.serialize(data=teams, many=True)) diff --git a/examples/sample-plain/accounts/resources/users.py b/examples/sample-plain/accounts/resources/users.py index f16185c..a1ab30e 100644 --- a/examples/sample-plain/accounts/resources/users.py +++ b/examples/sample-plain/accounts/resources/users.py @@ -23,7 +23,6 @@ class UserSchema(JSONAPISchema): organization = JSONAPIRelationship( type_='organizations', schema='OrganizationSchema', - include_resource_linkage=True, required=True, related_resource='OrganizationsResource', related_route='users:organization', @@ -34,7 +33,7 @@ class Meta: type_ = 'users' self_route = 'users:get' self_route_kwargs = {'id': ''} - self_route_many = 'users:get_all' + self_route_many = 'users:get_many' class UsersResource(BaseResource): @@ -42,7 +41,7 @@ class UsersResource(BaseResource): schema = UserSchema id_mask = 'int' - async def prepare_relations(self, obj: User, relations: List[str]): + async def include_relations(self, obj: User, relations: List[str]): """ We override this to allow `included` requests against this resource, but we don't actually have to do anything here. @@ -93,7 +92,7 @@ async def delete(self, id=None, *args, **kwargs) -> Response: return JSONAPIResponse(status_code=204) - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: users = User.get_items() return await self.to_response(await self.serialize(data=users, many=True)) diff --git a/examples/sample-sqlalchemy/accounts/resources/organizations.py b/examples/sample-sqlalchemy/accounts/resources/organizations.py index 7b33807..e0f8a79 100644 --- a/examples/sample-sqlalchemy/accounts/resources/organizations.py +++ b/examples/sample-sqlalchemy/accounts/resources/organizations.py @@ -20,7 +20,7 @@ class Meta: type_ = 'organizations' self_route = 'organizations:get' self_route_kwargs = {'id': ''} - self_route_many = 'organizations:get_all' + self_route_many = 'organizations:get_many' class OrganizationsResource(BaseResourceSQLA): @@ -75,7 +75,7 @@ async def delete(self, id=None, *args, **kwargs) -> Response: return JSONAPIResponse(status_code=204) - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: organizations = self.db_session.query(Organization).all() return await self.to_response(await self.serialize(data=organizations, many=True)) diff --git a/examples/sample-sqlalchemy/accounts/resources/teams.py b/examples/sample-sqlalchemy/accounts/resources/teams.py index e0a1e63..b39704e 100644 --- a/examples/sample-sqlalchemy/accounts/resources/teams.py +++ b/examples/sample-sqlalchemy/accounts/resources/teams.py @@ -24,7 +24,6 @@ class TeamSchema(JSONAPISchema): users = JSONAPIRelationship( type_='users', schema='UserSchema', - include_resource_linkage=True, many=True, required=True, self_route='teams:relationships-users', @@ -38,7 +37,7 @@ class Meta: type_ = 'teams' self_route = 'teams:get' self_route_kwargs = {'id': ''} - self_route_many = 'teams:get_all' + self_route_many = 'teams:get_many' class TeamsResource(BaseResourceSQLA): @@ -46,7 +45,7 @@ class TeamsResource(BaseResourceSQLA): schema = TeamSchema id_mask = 'int' - async def prepare_relations(self, obj: Team, relations: List[str]): + async def include_relations(self, obj: Team, relations: List[str]): """ We override this to allow `included` requests against this resource. """ @@ -104,7 +103,7 @@ async def delete(self, id=None, *args, **kwargs) -> Response: return JSONAPIResponse(status_code=204) - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: teams = self.db_session.query(Team).all() return await self.to_response(await self.serialize(data=teams, many=True)) diff --git a/examples/sample-sqlalchemy/accounts/resources/users.py b/examples/sample-sqlalchemy/accounts/resources/users.py index e068f4e..c3d1e85 100644 --- a/examples/sample-sqlalchemy/accounts/resources/users.py +++ b/examples/sample-sqlalchemy/accounts/resources/users.py @@ -25,7 +25,6 @@ class UserSchema(JSONAPISchema): organization = JSONAPIRelationship( type_='organizations', schema='OrganizationSchema', - include_resource_linkage=True, id_attribute='organization_id', required=True, related_route='users:organization', @@ -38,7 +37,7 @@ class Meta: strict = True self_route = 'users:get' self_route_kwargs = {'id': ''} - self_route_many = 'users:get_all' + self_route_many = 'users:get_many' class UsersResource(BaseResourceSQLA): @@ -50,7 +49,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.session = Session() - async def prepare_relations(self, obj: User, relations: List[str]): + async def include_relations(self, obj: User, relations: List[str]): """ We override this to allow include requests. """ # sqlalchemy supports lazy loading of relationships, # so we don't need to load them manually, @@ -106,7 +105,7 @@ async def delete(self, id=None, *args, **kwargs) -> Response: return JSONAPIResponse(status_code=204) - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: users = self.db_session.query(User).all() return await self.to_response(await self.serialize(data=users, many=True)) diff --git a/examples/sample-tortoise-orm/accounts/resources/organizations.py b/examples/sample-tortoise-orm/accounts/resources/organizations.py index 331debf..27c96cd 100644 --- a/examples/sample-tortoise-orm/accounts/resources/organizations.py +++ b/examples/sample-tortoise-orm/accounts/resources/organizations.py @@ -19,7 +19,7 @@ class Meta: type_ = 'organizations' self_route = 'organizations:get' self_route_kwargs = {'id': ''} - self_route_many = 'organizations:get_all' + self_route_many = 'organizations:get_many' class OrganizationsResource(BaseResource): @@ -72,7 +72,7 @@ async def delete(self, id=None, *args, **kwargs) -> Response: return JSONResponse(status_code=204) - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: organizations = await Organization.all() return await self.to_response(await self.serialize(data=organizations, many=True)) diff --git a/examples/sample-tortoise-orm/accounts/resources/teams.py b/examples/sample-tortoise-orm/accounts/resources/teams.py index 1b128a9..ca62395 100644 --- a/examples/sample-tortoise-orm/accounts/resources/teams.py +++ b/examples/sample-tortoise-orm/accounts/resources/teams.py @@ -24,7 +24,6 @@ class TeamSchema(JSONAPISchema): users = JSONAPIRelationship( type_='users', schema='UserSchema', - include_resource_linkage=True, many=True, required=True, self_route='teams:relationships-users', @@ -38,7 +37,7 @@ class Meta: type_ = 'teams' self_route = 'teams:get' self_route_kwargs = {'id': ''} - self_route_many = 'teams:get_all' + self_route_many = 'teams:get_many' class TeamsResource(BaseResource): @@ -46,7 +45,7 @@ class TeamsResource(BaseResource): schema = TeamSchema id_mask = 'int' - async def prepare_relations(self, obj: Team, relations: List[str]): + async def include_relations(self, obj: Team, relations: List[str]): """ We override this to allow `included` requests against this resource. """ @@ -110,7 +109,7 @@ async def delete(self, id=None, *args, **kwargs) -> Response: return JSONAPIResponse(status_code=204) - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: teams = await Team.all().prefetch_related('users') return await self.to_response(await self.serialize(data=teams, many=True)) diff --git a/examples/sample-tortoise-orm/accounts/resources/users.py b/examples/sample-tortoise-orm/accounts/resources/users.py index 67cf7da..32c073a 100644 --- a/examples/sample-tortoise-orm/accounts/resources/users.py +++ b/examples/sample-tortoise-orm/accounts/resources/users.py @@ -23,7 +23,6 @@ class UserSchema(JSONAPISchema): organization = JSONAPIRelationship( type_='organizations', schema='OrganizationSchema', - include_resource_linkage=True, id_attribute='organization_id', required=True, related_route='users:organization', @@ -36,7 +35,7 @@ class Meta: strict = True self_route = 'users:get' self_route_kwargs = {'id': ''} - self_route_many = 'users:get_all' + self_route_many = 'users:get_many' class UsersResource(BaseResource): @@ -44,7 +43,7 @@ class UsersResource(BaseResource): schema = UserSchema id_mask = 'int' - async def prepare_relations(self, obj: User, relations: List[str]): + async def include_relations(self, obj: User, relations: List[str]): if 'organization' in relations: await obj.fetch_related('organization') @@ -95,7 +94,7 @@ async def delete(self, id=None, *args, **kwargs) -> Response: return JSONResponse(status_code=204) - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: users = await User.all() return await self.to_response(await self.serialize(data=users, many=True)) diff --git a/requirements.txt b/requirements.txt index f1fec7b..8dba379 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,43 +4,113 @@ # # pip-compile --output-file=requirements.txt requirements.in # -appdirs==1.4.4 # via virtualenv -attrs==19.3.0 # via pytest -certifi==2020.6.20 # via httpx, requests -chardet==3.0.4 # via httpx, requests -contextvars==2.4 # via sniffio -coverage==5.1 # via pytest-cov -distlib==0.3.1 # via virtualenv -filelock==3.0.12 # via tox, virtualenv -h11==0.9.0 # via httpcore -h2==3.2.0 # via httpcore -hpack==3.0.0 # via h2 -hstspreload==2020.6.30 # via httpx -httpcore==0.9.1 # via httpx -httpx==0.13.3 # via -r requirements.in -hyperframe==5.2.0 # via h2 -idna==2.10 # via httpx, requests -immutables==0.14 # via contextvars -importlib-metadata==1.7.0 # via pluggy, pytest, tox, virtualenv -importlib-resources==3.0.0 # via virtualenv -marshmallow-jsonapi==0.23.1 # via -r requirements.in -marshmallow==3.6.1 # via -r requirements.in, marshmallow-jsonapi -more-itertools==8.4.0 # via pytest -packaging==20.4 # via pytest, tox -pluggy==0.13.1 # via pytest, tox -py==1.9.0 # via pytest, tox -pyparsing==2.4.7 # via packaging -pytest-asyncio==0.14.0 # via -r requirements.in -pytest-cov==2.10.0 # via -r requirements.in -pytest==5.4.3 # via -r requirements.in, pytest-asyncio, pytest-cov -requests==2.24.0 # via -r requirements.in -rfc3986==1.4.0 # via httpx -six==1.15.0 # via packaging, tox, virtualenv -sniffio==1.1.0 # via httpcore, httpx -starlette==0.13.4 # via -r requirements.in -toml==0.10.1 # via tox -tox==3.16.1 # via -r requirements.in -urllib3==1.25.9 # via requests -virtualenv==20.0.25 # via tox -wcwidth==0.2.5 # via pytest -zipp==3.1.0 # via importlib-metadata, importlib-resources +appdirs==1.4.4 + # via virtualenv +attrs==19.3.0 + # via pytest +certifi==2020.6.20 + # via + # httpx + # requests +chardet==3.0.4 + # via + # httpx + # requests +contextvars==2.4 + # via sniffio +coverage==5.1 + # via pytest-cov +distlib==0.3.1 + # via virtualenv +filelock==3.0.12 + # via + # tox + # virtualenv +h11==0.9.0 + # via httpcore +h2==3.2.0 + # via httpcore +hpack==3.0.0 + # via h2 +hstspreload==2020.6.30 + # via httpx +httpcore==0.9.1 + # via httpx +httpx==0.13.3 + # via -r requirements.in +hyperframe==5.2.0 + # via h2 +idna==2.10 + # via + # httpx + # requests +immutables==0.14 + # via contextvars +importlib-metadata==1.7.0 + # via + # pluggy + # pytest + # tox + # virtualenv +importlib-resources==3.0.0 + # via virtualenv +marshmallow-jsonapi==0.23.1 + # via -r requirements.in +marshmallow==3.6.1 + # via + # -r requirements.in + # marshmallow-jsonapi +more-itertools==8.4.0 + # via pytest +packaging==20.4 + # via + # pytest + # tox +pluggy==0.13.1 + # via + # pytest + # tox +py==1.9.0 + # via + # pytest + # tox +pyparsing==2.4.7 + # via packaging +pytest-asyncio==0.14.0 + # via -r requirements.in +pytest-cov==2.10.0 + # via -r requirements.in +pytest==5.4.3 + # via + # -r requirements.in + # pytest-asyncio + # pytest-cov +requests==2.24.0 + # via -r requirements.in +rfc3986==1.4.0 + # via httpx +six==1.15.0 + # via + # packaging + # tox + # virtualenv +sniffio==1.1.0 + # via + # httpcore + # httpx +starlette==0.14.2 + # via -r requirements.in +toml==0.10.1 + # via tox +tox==3.16.1 + # via -r requirements.in +urllib3==1.25.9 + # via requests +virtualenv==20.0.25 + # via tox +wcwidth==0.2.5 + # via pytest +zipp==3.1.0 + # via + # importlib-metadata + # importlib-resources diff --git a/setup.py b/setup.py index 9d663ca..7d72599 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,6 @@ import codecs import re + from setuptools import setup version_regex = r'__version__ = ["\']([^"\']*)["\']' @@ -23,6 +24,9 @@ package_data={'starlette_jsonapi': ['LICENSE', 'README.md']}, package_dir={'starlette_jsonapi': 'starlette_jsonapi'}, include_package_data=True, + install_requires=[ + 'starlette>=0.14.2', + ], license='MIT License', url='https://github.com/vladmunteanu/starlette-jsonapi', classifiers=[ @@ -35,6 +39,7 @@ 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: Implementation :: CPython', ], zip_safe=False, diff --git a/starlette_jsonapi/__init__.py b/starlette_jsonapi/__init__.py index 58d478a..afced14 100644 --- a/starlette_jsonapi/__init__.py +++ b/starlette_jsonapi/__init__.py @@ -1 +1 @@ -__version__ = '1.2.0' +__version__ = '2.0.0' diff --git a/starlette_jsonapi/exceptions.py b/starlette_jsonapi/exceptions.py index 54dcb4d..352d33e 100644 --- a/starlette_jsonapi/exceptions.py +++ b/starlette_jsonapi/exceptions.py @@ -4,15 +4,44 @@ class JSONAPIException(HTTPException): + """ HTTP exception with json:api representation. """ def __init__(self, status_code: int, detail: str = None, errors: List[dict] = None) -> None: + """ + Base json:api exception class. + + :param status_code: HTTP status code + :param detail: Optional, error detail, will be serialized in the final HTTP response. + **DO NOT** include sensitive information here. + If not specified, the HTTP message associated to ``status_code`` + will be used. + :param errors: Optional, list of json:api error representations. + Used if multiple errors are returned. + + .. code-block:: python + + import json + from starlette_jsonapi.utils import serialize_error + + error1 = JSONAPIException(400, 'foo') + error2 = JSONAPIException(400, 'bar') + final_error = JSONAPIException( + 400, 'final', errors=error1.errors + error2.errors + ) + response = serialize_error(final_error) + assert json.loads(response.body)['errors'] == [ + {'detail': 'foo'}, + {'detail': 'bar'}, + {'detail': 'final'}, + ] + """ super().__init__(status_code, detail=detail) - self.errors = errors - if not self.errors: - self.errors = [{'detail': self.detail}] + self.errors = errors or [] + self.errors.append({'detail': self.detail}) class ResourceNotFound(JSONAPIException): + """ HTTP 404 error, serialized according to json:api. """ detail: str = 'Resource object not found.' def __init__(self, status_code: int = 404, detail: str = None) -> None: diff --git a/starlette_jsonapi/fields.py b/starlette_jsonapi/fields.py index a3cafe3..a5fdf3d 100644 --- a/starlette_jsonapi/fields.py +++ b/starlette_jsonapi/fields.py @@ -1,3 +1,5 @@ +from typing import Union, Type + from marshmallow_jsonapi.fields import Relationship as __BaseRelationship from marshmallow_jsonapi.utils import resolve_params from starlette.applications import Starlette @@ -8,22 +10,95 @@ class JSONAPIRelationship(__BaseRelationship): """ - Mostly marshmallow_jsonapi.fields.Relationship, but friendlier with async ORMs. - Accepts the `id_attribute` attribute which should point to the id field corresponding to this relationship. + Mostly :class:`marshmallow_jsonapi.fields.Relationship`, but friendlier with async ORMs. + Accepts ``id_attribute`` which should point to the id field corresponding to a relationship object. In most cases, this attribute is available even if the relationship is not loaded. """ def __init__( self, - id_attribute=None, - related_resource=None, - related_route=None, - related_route_kwargs=None, + id_attribute: str = None, + related_resource: Union[str, Type] = None, + related_route: str = None, + related_route_kwargs: dict = None, *, - self_route=None, - self_route_kwargs=None, + self_route: str = None, + self_route_kwargs: dict = None, **kwargs ): + """ + Serializes a related object, according to the json:api standard. + + Example definition: + + .. code-block:: python + :linenos: + :emphasize-lines: 32,33,34,35,36 + + from starlette_jsonapi.schema import JSONAPISchema + + # example "models" + class Author: + id: int + name: str + + class Article: + id: int + title: str + content: str + + author: Author + author_id: int + + # example schemas with relationship + class AuthorSchema(JSONAPISchema): + class Meta: + type_ = 'authors' + + id = fields.Str(dump_only=True) + name = fields.Str(required=True) + + class ArticleSchema(JSONAPISchema): + class Meta: + type_ = 'articles' + + id = fields.Str(dump_only=True) + title = fields.Str(required=True) + content = fields.Str(required=True) + + author = JSONAPIRelationship( + type_='authors', + schema='AuthorSchema', + id_attribute='author_id', + ) + + :param id_attribute: Represents the attribute name of a relationship's id. + Useful if the related object is not fetched. + Otherwise, the id attribute of a related object is accessed + by resolving the model attribute represented by this relationship, + then accessing as ``related_object.id``. When the related object + is populated already, or when it is lazy loaded, this parameter + shouldn't be needed. It should be used when the ORM of choice + is async and does not support lazy loading, or when fetching + would be considered too expensive. + :param related_resource: The related resource, or its name, required if you wish + to enable related links inside the json:api `links` object. + :param related_route: The related route name, such that ``app.url_path_for`` + will match back to it. Renders as `related` inside the `links` object. + For example, ``ArticleSchema.author`` would specify + ``related_route='articles:author'``, which would render as + `/articles//author`. + :param related_route_kwargs: Additional :attr:``related_route`` kwargs that should be + passed when calling ``app.url_path_for`` to build path params. + :param self_route: Same as :attr:`related_route`, but refers to the relationship + resource GET handler. Renders as `self` inside the `links` object. + For example, ``ArticleSchema.author`` would specify + ``self_route='articles:relationships-author'``, which would render as + `/articles//relationships/author`. + :param self_route_kwargs: Additional :attr:`self_route` kwargs that should be + passed when calling ``app.url_path_for`` to build path params. + :param kwargs: Other keyword arguments passed to the base class + """ self.id_attribute = id_attribute self.related_resource = related_resource self.related_route = related_route @@ -32,7 +107,8 @@ def __init__( self.self_route_kwargs = self_route_kwargs or {} # When doing a PATCH on a relationship, `data` is allowed to be None # if the client wishes to empty a relation. - kwargs.update(allow_none=kwargs.get('allow_none', True)) + kwargs.setdefault('allow_none', True) + kwargs.setdefault('include_resource_linkage', True) super().__init__(**kwargs) # We override serialize because we want to allow asynchronous ORMs to do serialization diff --git a/starlette_jsonapi/pagination.py b/starlette_jsonapi/pagination.py index 17b8651..72df757 100644 --- a/starlette_jsonapi/pagination.py +++ b/starlette_jsonapi/pagination.py @@ -7,42 +7,49 @@ class Pagination(NamedTuple): + """ Represents the result of a pagination strategy. """ + #: Sequence of items representing a single page data: Sequence + #: Dictionary of pagination links links: Dict[str, Optional[str]] class BasePagination: """ - Base class used to easily add pagination support for resources + Base class used to easily add pagination support for resources. This class is agnostic about the pagination strategy, and can be effectively subclassed to accommodate for any variant. Implementation will require overriding the following methods: - - slice_data - - process_query_params - - generate_pagination_links + + - :meth:`slice_data` + - :meth:`process_query_params` + - :meth:`generate_pagination_links` """ def __init__(self, request: Request, data: Sequence, **kwargs): + """ Constructs a paginator object with Starlette support. """ + #: Data before pagination self.data = data + #: The Starlette HTTP request object self.request = request self.process_query_params() def process_query_params(self): """ - Parse the request to store the pagination parameters for later usage - Should be implemented in subclasses + Parse the request to store the pagination parameters for later usage. + Should be implemented in subclasses. """ return def slice_data(self, params: dict = None) -> Sequence: """ This method should be implemented in subclasses in order to accommodate for different ORM's - and optimize the database operations + and optimize database operations. """ raise NotImplementedError() def get_pagination(self, params: dict = None) -> Pagination: - """Slice the queryset according to the pagination rules, and create pagination links""" + """ Slice the queryset according to the pagination rules, and create pagination links """ data = self.slice_data(params) links = self.generate_pagination_links(params) return Pagination(data=data, links=links) @@ -55,29 +62,45 @@ def generate_pagination_links(self, params: dict = None) -> Dict[str, Optional[s class BasePageNumberPagination(BasePagination): """ Base class for accommodating the page number pagination strategy using the standard parameters - under the JSON:API format - - page[number] - - page[size] + under the JSON:API format: + + * page[number] + * page[size] Implementation will require overriding the following methods: - - slice_data - - generate_pagination_links - """ - page_number_param = 'number' - page_size_param = 'size' + - :meth:`slice_data` + - :meth:`generate_pagination_links` + """ + #: The query parameter corresponding to the page number. + page_number_param = 'page[number]' + #: The query parameter corresponding to the page size. + page_size_param = 'page[size]' + #: The default page number. + #: Used if no client configured value is found. default_page_number = 1 + #: The default page size. + #: Used if no client configured value is found. default_page_size = 50 + #: The maximum allowed page size. + #: Can override client configured values. max_page_size = 100 + def __init__(self, *args, **kwargs): + #: Processed page size, initially :attr:`default_page_size` + self.page_size = self.default_page_size + #: Processed page number, initially :attr:`default_page_number` + self.page_number = self.default_page_number + super().__init__(*args, **kwargs) + def process_query_params(self): - """Extract the page number and page size from the query parameters""" + """ Process HTTP query parameters to set :attr:`page_number` and :attr:`page_size`. """ page_number = self.request.query_params.get( - f'page[{self.page_number_param}]', + self.page_number_param, self.default_page_number ) page_size = self.request.query_params.get( - f'page[{self.page_size_param}]', + self.page_size_param, self.default_page_size ) @@ -93,10 +116,10 @@ def process_query_params(self): self.page_number = page_number def create_pagination_link(self, page_number: int, page_size: int) -> str: - """Helper method used to easily generate links used in pagination""" + """ Helper method used to easily generate a link with pagination details for this strategy. """ params = { - f'page[{self.page_number_param}]': page_number, - f'page[{self.page_size_param}]': page_size + self.page_number_param: page_number, + self.page_size_param: page_size, } return str(self.request.url.replace_query_params(**params)) @@ -105,28 +128,44 @@ class BaseOffsetPagination(BasePagination): """ Base class for accommodating the offset pagination strategy using the standard parameters under the JSON:API format - - page[offset] - - page[size] + + * page[offset] + * page[size] Implementation will require overriding the following methods: - - slice_data - - generate_pagination_links - """ - page_offset_param = 'offset' - page_size_param = 'size' + - :meth:`slice_data` + - :meth:`generate_pagination_links` + """ + #: The query parameter corresponding to the page offset. + page_offset_param = 'page[offset]' + #: The query parameter corresponding to the page size. + page_size_param = 'page[size]' + #: The default page offset. + #: Used if no client configured value is found. default_page_offset = 0 + #: The default page size. + #: Used if no client configured value is found. default_page_size = 50 + #: The maximum allowed page size. + #: Can override client configured values. max_page_size = 100 + def __init__(self, *args, **kwargs): + #: Processed page size, initially :attr:`default_page_size` + self.page_size = self.default_page_size + #: Processed page offset, initially :attr:`default_page_offset` + self.page_offset = self.default_page_offset + super().__init__(*args, **kwargs) + def process_query_params(self): - """Extract the page offset and page size from the query parameters""" + """ Process HTTP query parameters to set :attr:`page_offset` and :attr:`page_size`. """ page_offset = self.request.query_params.get( - f'page[{self.page_offset_param}]', + self.page_offset_param, self.default_page_offset ) page_size = self.request.query_params.get( - f'page[{self.page_size_param}]', + self.page_size_param, self.default_page_size ) @@ -142,10 +181,10 @@ def process_query_params(self): self.page_offset = page_offset def create_pagination_link(self, page_offset: int, page_size: int) -> str: - """Helper method used to easily generate links used in pagination""" + """ Helper method used to easily generate a link with pagination details for this strategy. """ params = { - f'page[{self.page_offset_param}]': page_offset, - f'page[{self.page_size_param}]': page_size + self.page_offset_param: page_offset, + self.page_size_param: page_size, } return str(self.request.url.replace_query_params(**params)) @@ -154,27 +193,48 @@ class BaseCursorPagination(BasePagination): """ Base class for accommodating the cursor pagination strategy using the standard parameters under the JSON:API format - - page[after] - - page[before] - - page[size] + + * page[after] + * page[before] + * page[size] Implementation will require overriding the following methods: - - slice_data - - generate_pagination_links - """ - page_after_param = 'after' - page_before_param = 'before' - page_size_param = 'size' + - :meth:`slice_data` + - :meth:`generate_pagination_links` + """ + #: The query parameter corresponding to the page after. + page_after_param = 'page[after]' + #: The query parameter corresponding to the page before. + page_before_param = 'page[before]' + #: The query parameter corresponding to the page size. + page_size_param = 'page[size]' + #: The default page after. + #: Used if no client configured value is found. default_page_after = 0 + #: The default page before. + #: Used if no client configured value is found. default_page_before = None + #: The default page size. + #: Used if no client configured value is found. default_page_size = 50 + #: The maximum allowed page size. + #: Can override client configured values. max_page_size = 100 + def __init__(self, *args, **kwargs): + #: Processed page size, initially :attr:`default_page_size` + self.page_size = self.default_page_size + #: Processed page after, initially :attr:`default_page_after` + self.page_after = self.default_page_after + #: Processed page before, initially :attr:`default_page_before` + self.page_before = self.default_page_before + super().__init__(*args, **kwargs) + def process_query_params(self): - """Extract the cursor positions and page size from the query parameters""" + """ Process HTTP query parameters to set :attr:`page_after`, :attr:`page_before` and :attr:`page_size`. """ page_size = self.request.query_params.get( - f'page[{self.page_size_param}]', + self.page_size_param, self.default_page_size ) # perform sanity checks for page size values @@ -183,11 +243,11 @@ def process_query_params(self): self.page_size = int(page_size) self.page_after = self.request.query_params.get( - f'page[{self.page_after_param}]', + self.page_after_param, self.default_page_after ) self.page_before = self.request.query_params.get( - f'page[{self.page_before_param}]', + self.page_before_param, self.default_page_before ) @@ -196,13 +256,13 @@ def create_pagination_link( page_after: Union[str, int, None] = None, page_before: Union[str, int, None] = None, ) -> str: - """Helper method used to easily generate links used in pagination""" + """ Helper method used to easily generate a link with pagination details for this strategy. """ params = { - f'page[{self.page_size_param}]': page_size + self.page_size_param: page_size, } # type: Dict[str, Union[str, int]] if page_after is not None: - params[f'page[{self.page_after_param}]'] = page_after + params[self.page_after_param] = page_after if page_before is not None: - params[f'page[{self.page_before_param}]'] = page_before + params[self.page_before_param] = page_before return str(self.request.url.replace_query_params(**params)) diff --git a/starlette_jsonapi/resource.py b/starlette_jsonapi/resource.py index f352ed7..7a1c5cf 100644 --- a/starlette_jsonapi/resource.py +++ b/starlette_jsonapi/resource.py @@ -1,3 +1,4 @@ +import functools import logging from typing import Type, Any, List, Optional, Union, Sequence, Dict @@ -14,107 +15,316 @@ from starlette_jsonapi.schema import JSONAPISchema from starlette_jsonapi.pagination import BasePagination, Pagination from starlette_jsonapi.utils import ( - parse_included_params, - parse_sparse_fields_params, filter_sparse_fields, - serialize_error, + parse_included_params, serialize_error, process_sparse_fields, parse_sparse_fields_params, ) logger = logging.getLogger(__name__) -class BaseResource(metaclass=RegisteredResourceMeta): - """ A basic json:api resource implementation, data layer agnostic. """ +class _BaseResourceHandler: + """ + Base implementation of common json:api resource handler logic. + You should look at BaseResource or BaseRelationshipResource instead. + """ + + #: High level filter for HTTP requests. + #: If you specify a smaller subset, any request with a method + #: not listed here will result in a 405 error. + allowed_methods = {'GET', 'PATCH', 'POST', 'DELETE'} + + def __init__(self, request: Request, request_context: dict, *args, **kwargs) -> None: + """ + A Resource instance is created for each HTTP request, + and the :class:`starlette.requests.Request` + is passed, as well as the context, which can be used to store information + without altering the request object. + """ + #: Instance attribute representing the current HTTP request. + self.request: Request = request + #: Instance attribute representing the context of the current HTTP request. + #: Can be used to store additional information for the duration of a request. + self.request_context: dict = request_context + + @classmethod + async def before_request(cls, request: Request, request_context: dict) -> None: + """ + Optional hook that can be implemented by subclasses to execute logic before a request is handled. + This will not run if an exception is raised before :meth:`handle_request` is called. + + For more advanced hooks, check starlette middleware. + + :param request: The current HTTP request + :param request_context: The current request's context. + """ + return + + @classmethod + async def after_request(cls, request: Request, request_context: dict, response: Response) -> None: + """ + Optional hook that can be implemented by subclasses to execute logic after a request is handled. + This will not run if an exception is raised before :meth:`handle_request` is called, or if + :meth:`before_request` throws an error. - # The json:api type, used to compute the path for this resource - # such that BaseResource.register_routes(app=app, base_path='/api/') will register - # the following routes: - # - GET `/api//` - # - POST `/api//` - # - GET `/api//{id:str}` - # - PATCH `/api//{id:str}` - # - DELETE `/api//{id:str}` + For more advanced hooks, check starlette middleware. + + :param request: The current HTTP request + :param request_context: The current request's context. + :param response: The Starlette Response object + """ + return + + @classmethod + async def handle_error(cls, request: Request, request_context: dict, exc: Exception) -> JSONAPIResponse: + """ + Handles errors that may appear while a request is processed, taking care of serializing them + to ensure the final response is json:api compliant. + + Subclasses can override this to add custom error handling. + + :param request: current HTTP request + :param request_context: current request context + :param exc: encountered error + """ + if not isinstance(exc, HTTPException): + logger.exception('Encountered an error while handling request.') + return serialize_error(exc) + + async def to_response(self, data: dict, meta: dict = None, *args, **kwargs) -> JSONAPIResponse: + """ + Wraps ``data`` in a :class:`starlette_jsonapi.responses.JSONAPIResponse` object and returns it. + If ``meta`` is specified, it will be included as the top level ``"meta"`` object in the json:api response. + Additional args and kwargs are passed when instantiating a new :class:`JSONAPIResponse`. + + :param data: Serialized resources / errors, as returned by :meth:`serialize` or :meth:`serialize_related`. + :param meta: Optional dictionary with meta information. Overwrites any existing top level `meta` in ``data``. + """ + if meta: + data = data.copy() + data.update(meta=meta) + return JSONAPIResponse( + content=data, + *args, **kwargs, + ) + + @classmethod + async def handle_request( + cls, handler_name: str, request: Request, request_context: dict = None, + extract_params: List[str] = None, *args, **kwargs + ) -> Response: + """ + Handles a request by calling the appropriate handler. + Additional args and kwargs are passed to the handler method, which is usually one of: + :meth:`get`, :meth:`patch`, :meth:`delete`, :meth:`get_many` or :meth:`post`. + """ + request_context = request_context or {} + extract_params = extract_params or [] + for path_param in extract_params: + value = request.path_params.get(path_param) + kwargs.update({path_param: value}) + request_context.update({path_param: value}) + + # run before request hook + try: + await cls.before_request(request=request, request_context=request_context) + except Exception as before_request_exc: + response: Response = await cls.handle_error(request, request_context, exc=before_request_exc) + else: + # safely execute the handler + try: + if request.method not in cls.allowed_methods: + raise JSONAPIException(status_code=405) + resource = cls(request, request_context, *args, **kwargs) + handler = getattr(resource, handler_name, None) + response = await handler(*args, **kwargs) + except Exception as e: + response = await cls.handle_error(request, request_context, exc=e) + + # run after request hook + try: + await cls.after_request(request=request, request_context=request_context, response=response) + except Exception as after_request_exc: + response = await cls.handle_error(request, request_context, exc=after_request_exc) + + return response + + def process_sparse_fields_request(self, serialized_data: dict, many: bool = False) -> dict: + """ + Processes sparse fields requests by calling + :func:`starlette_jsonapi.utils.process_sparse_fields`. + + Can be overridden in subclasses if custom behavior is needed. + + :param serialized_data: The complete json:api dict representation. + :param many: Whether ``serialized_data`` should be treated as a collection. + """ + return process_sparse_fields( + serialized_data, many=many, + sparse_fields=parse_sparse_fields_params(self.request), + ) + + +class BaseResource(_BaseResourceHandler, metaclass=RegisteredResourceMeta): + """A basic json:api resource implementation, data layer agnostic. + + Subclasses can achieve basic functionality by implementing: + + :meth:`get` :meth:`patch` :meth:`delete` :meth:`get_many` :meth:`post` + + Additionally: + + - requests for compound documents (Example: ``GET /api/v1/articles?include=author``) can be + supported by overriding :meth:`include_relations` to pre-populate + the related objects before serializing. + + - requests for related objects (Example: ``GET /api/v1/articles/123/author``), can be supported + by overriding the :meth:`get_related` handler. + Related objects should be serialized with :meth:`serialize_related`. + + By default, requests for sparse fields will be handled by the :class:`BaseResource` implementation, + without any effort required. + + Example subclass: + + .. code-block:: python + + class ExampleResource(BaseResource): + type_ = 'examples' + allowed_methods = {'GET'} + + async def get(self, id: str, *args, **kwargs) -> Response: + obj = Example.objects.get(id) + serialized_obj = await self.serialize(obj) + return await self.to_response(serialized_obj) + + async def get_many(self, *args, **kwargs) -> Response: + objects = Example.objects.all() + serialized_objects = await self.serialize(objects, many=True) + return await self.to_response(serialized_objects) + + """ + + #: The json:api type, used to compute the path for this resource + #: such that ``BaseResource.register_routes(app=app, base_path='/api/')`` will register + #: the following routes: + #: + #: - ``GET /api//`` + #: - ``POST /api//`` + #: - ``GET /api//{id:str}`` + #: - ``PATCH /api//{id:str}`` + #: - ``DELETE /api//{id:str}`` type_: str = '' - # The json:api serializer, a subclass of JSONAPISchema. + #: The json:api serializer, a subclass of :class:`JSONAPISchema`. schema: Type[JSONAPISchema] = JSONAPISchema - # High level filter for HTTP requests. - # If you specify a smaller subset, any request that specifies a method - # not listed here will result in a 405 error. - allowed_methods = {'GET', 'PATCH', 'POST', 'DELETE'} - - # By default `str`, but other options are documented in Starlette: - # 'str', 'int', 'float', 'uuid', 'path' + #: By default `str`, but other options are documented in Starlette: + #: ``'str', 'int', 'float', 'uuid', 'path'`` id_mask: str = 'str' - # Pagination class, subclass of BasePagination + #: Pagination class, subclass of :class:`BasePagination` pagination_class: Optional[Type[BasePagination]] = None - # Optional, by default this will equal `type_` and will be used as the `mount` name. - # Impacts the result of `url_path_for`, so it can be used to support multiple resource versions. - # For example: - # ``` - # from starlette.applications import Starlette - # - # class SomeResource(BaseResource): - # type_ = 'examples' - # register_as = 'v2-examples' - # - # app = Starlette() - # SomeResource.register_routes(app=app, base_path='/api/v2') - # assert app.url_path_for('v2-examples:get_all') == '/api/v2/examples/' - # ``` - # `url_path_for` will register_as: str = '' + """ + Optional, by default this will equal :attr:`type_` and will be used as the :attr:`mount` name. + Impacts the result of ``url_path_for``, so it can be used to support multiple resource versions. + + .. code-block:: python + + from starlette.applications import Starlette + + class ExamplesResource(BaseResource): + type_ = 'examples' + register_as = 'v2-examples' + + app = Starlette() + ExamplesResource.register_routes(app=app, base_path='/api/v2') + assert app.url_path_for('v2-examples:get_many') == '/api/v2/examples/' + """ + + #: The underlying :class:`starlette.routing.Mount` object used for registering routes. mount: Mount - # Switch for controlling meta class registration. - # Being able to refer to another resource via its name, - # rather than directly passing it, will prevent circular imports in projects. - # By default, subclasses are registered. + #: Switch for controlling meta class registration. + #: Being able to refer to another resource via its name, + #: rather than directly passing it, will prevent circular imports in projects. + #: By default, subclasses are registered. register_resource = False - # This will be populated when routes are registered and we detect related resources. - # Used in `serialize_related`. + #: This will be populated when routes are registered and we detect related resources. + #: Used in :meth:`serialize_related`. _related: Dict[str, Type['BaseResource']] - def __init__(self, request: Request, request_context: dict, *args, **kwargs) -> None: - self.request = request - self.request_context = request_context - - async def get(self, id=None, *args, **kwargs) -> Response: + async def get(self, id: Any, *args, **kwargs) -> Response: + """ Subclasses should implement this to handle ``GET /`` requests. """ raise JSONAPIException(status_code=405) - async def patch(self, id=None, *args, **kwargs) -> Response: + async def patch(self, id: Any, *args, **kwargs) -> Response: + """ Subclasses should implement this to handle ``PATCH /`` requests. """ raise JSONAPIException(status_code=405) - async def delete(self, id=None, *args, **kwargs) -> Response: + async def delete(self, id: Any, *args, **kwargs) -> Response: + """ Subclasses should implement this to handle ``DELETE /`` requests. """ raise JSONAPIException(status_code=405) - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: + """ Subclasses should implement this to handle ``GET /`` requests. """ raise JSONAPIException(status_code=405) async def post(self, *args, **kwargs) -> Response: + """ Subclasses should implement this to handle ``POST /`` requests. """ raise JSONAPIException(status_code=405) async def get_related(self, id: Any, relationship: str, related_id: Any = None, *args, **kwargs) -> Response: """ - Subclasses should implement this if they specify relationships - and want to support fetching related resources. + Subclasses should implement this to handle ``GET //[/]``. By default returns a 405 error. + + :param id: the resource id + :param relationship: name of the relationship + :param related_id: optional, an id can be specified to identify a specific related resource, + in case of one-to-many relationships. """ raise JSONAPIException(status_code=405) + async def include_relations(self, obj: Any, relations: List[str]) -> None: + """ + Subclasses should implement this to support requests for compound documents. + ``_ + + By default returns a 400 error, according to the json:api specification. + + Example request URL: ``GET /?include=relationship1,relationship1.child_relationship`` + Example relations: ``['relationship1', 'relationship1.child_relationship']`` + + :param obj: an object that was passed to :meth:`serialize` + :param relations: list of relations described above + """ + raise JSONAPIException(status_code=400) + async def deserialize_body(self, partial=None) -> dict: - """ Returns the request body as defined by this Resource's `schema`.""" + """ + Deserializes the request body according to :attr:`schema`. + + :param partial: Can be set to ``True`` during PATCH requests, to ignore missing fields. + For more advanced uses, like a specific iterable of missing fields, + you should check the marshmallow documentation. + :raises: :exc:`starlette_jsonapi.exceptions.JSONAPIException` + """ raw_body = await self.validate_body(partial=partial) deserialized_body = self.schema(app=self.request.app).load(raw_body, partial=partial) return deserialized_body async def validate_body(self, partial=None) -> dict: """ - Validates the raw request body, raising JSONAPIException 400 errors if the body is not valid. - Otherwise, the request.json() content is returned. + Validates the raw request body, raising :exc:`JSONAPIException` 400 errors + if the body is not valid according to :attr:`schema`. + Otherwise, the whole request body is loaded as a ``dict`` and returned. + + :param partial: Can be set to ``True`` during PATCH requests, to ignore missing fields. + For more advanced uses, like a specific iterable of missing fields, + you should check the marshmallow documentation. + :raises: :exc:`starlette_jsonapi.exceptions.JSONAPIException` """ content_type = self.request.headers.get('content-type') if self.request.method in ('POST', 'PATCH') and content_type != 'application/vnd.api+json': @@ -143,10 +353,15 @@ async def serialize( ) -> dict: """ Serializes data as a JSON:API payload and returns a `dict` - which can be passed when calling `BaseResource.to_response`. + which can be passed when calling :meth:`to_response`. + + Extra parameters can be sent inside the pagination process via ``pagination_kwargs`` + Additional args and kwargs are passed when initializing a new :attr:`schema`. - Extra parameters can be sent inside the pagination process via `pagination_kwargs` - Additional args and kwargs are passed to the `marshmallow` based Schema. + :param data: an object, or a sequence of objects to be serialized + :param many: whether ``data`` should be serialized as a collection + :param paginate: whether to apply pagination to the given ``data`` + :param pagination_kwargs: additional parameters which are passed to :meth:`paginate_request`. """ links = None if paginate: @@ -155,7 +370,7 @@ async def serialize( included_relations = await self._prepare_included(data=data, many=many) schema = self.schema(app=self.request.app, include_data=included_relations, *args, **kwargs) body = schema.dump(data, many=many) - sparse_body = await self.process_sparse_fields(body, many=many) + sparse_body = self.process_sparse_fields_request(body, many=many) if links: sparse_body['links'] = links @@ -163,12 +378,16 @@ async def serialize( async def serialize_related(self, data: Any, many=False, *args, **kwargs) -> dict: """ - Serializes related data as a JSON:API payload and returns a `dict` - which can be passed when calling `BaseResource.to_response`. + Serializes related data as a JSON:API payload and returns a ``dict`` + which can be passed when calling :meth:`to_response`. - When serializing related resources, the related items are passed as `data` instead of the parent objects. + When serializing related resources, the related items are passed as ``data``, + instead of the parent objects. - Additional args and kwargs are passed to the `marshmallow` based Schema. + Additional args and kwargs are passed when initializing a new :attr:`schema`. + + :param data: an object, or a sequence of objects to be serialized + :param many: whether ``data`` should be serialized as a collection """ relationship = self.request_context['relationship'] parent_id = self.request_context['id'] @@ -176,7 +395,6 @@ async def serialize_related(self, data: Any, many=False, *args, **kwargs) -> dic related_route = f'{self.mount.name}:{relationship}' related_route_kwargs = { 'id': parent_id, - # 'relationship': relationship, } if self.request_context.get('related_id'): related_route += '-id' @@ -189,27 +407,13 @@ async def serialize_related(self, data: Any, many=False, *args, **kwargs) -> dic *args, **kwargs, ) # type: JSONAPISchema body = related_schema.dump(data, many=many) - sparse_body = await self.process_sparse_fields(body, many=many) + sparse_body = self.process_sparse_fields_request(body, many=many) return sparse_body - async def to_response(self, data: dict, meta: dict = None, *args, **kwargs) -> JSONAPIResponse: - """ - Wraps `data` in a JSONAPIResponse object and returns it. - If `meta` is specified, it will be included as the top level `meta` object in the json:api response. - Additional args and kwargs are passed to the `starlette` based Response. - """ - if meta: - data = data.copy() - data.update(meta=meta) - return JSONAPIResponse( - content=data, - *args, **kwargs, - ) - async def paginate_request(self, object_list: Sequence, pagination_kwargs: dict = None) -> Pagination: """ - Apply pagination using the helper class defined on the resource - Additional parameters can pe saved on the `paginator` instance using pagination_kwargs + Applies pagination using the helper class defined by :attr:`pagination_class`. + Additional parameters can pe saved on the ``paginator`` instance using ``pagination_kwargs``. """ if not self.pagination_class: raise Exception('Pagination class must be defined to use pagination') @@ -220,106 +424,30 @@ async def paginate_request(self, object_list: Sequence, pagination_kwargs: dict return pagination @classmethod - async def before_request(cls, request: Request, request_context: dict) -> None: + def register_routes(cls, app: Starlette, base_path: str = ''): """ - Optional hook that can be implemented by subclasses to execute logic before a request is handled. - This will not run if an exception is raised before `handle_request` is called. + Registers URL routes associated to this resource, using a :class:`starlette.routing.Mount`. + The mount name will be set based on :attr:`type_`, or :attr:`register_as`, if defined. + All routes will then be registered under this mount. - For more advanced hooks, check starlette middleware. + If the configured :attr:`schema` defines relationships, then routes for related objects + will also be registered. - :param request: The Starlette Request object - :param request_context: The current request's context. - """ - return + Let's take the articles resource as an example:. - @classmethod - async def after_request(cls, request: Request, request_context: dict, response: Response) -> None: - """ - Optional hook that can be implemented by subclasses to execute logic after a request is handled. - This will not run if an exception is raised before `handle_request` is called. + .. csv-table:: Registered Routes + :header: "Name", "URL", "HTTP method", "Description" - For more advanced hooks, check starlette middleware. + "articles:get_many", "/articles/", "GET", "Retrieve articles" + "articles:post", "/articles/", "POST", "Create an article" + "articles:get", "/articles/", "GET", "Retrieve an article by ID" + "articles:patch", "/articles/", "PATCH", "Update an article by ID" + "articles:delete", "/articles/", "DELETE", "Delete an article by ID" + "articles:author", "/articles//author", "GET", "Retrieve an article's author" + "articles:comments", "/articles//comments", "GET", "Retrieve an article's comments" + "articles:comments-id", "/articles//comments/", "GET", "Retrieve an article comment by ID" - :param request: The Starlette Request object - :param request_context: The current request's context. - :param response: The Starlette Response object """ - return - - @classmethod - async def handle_error(cls, request: Request, exc: Exception) -> JSONAPIResponse: - if not isinstance(exc, HTTPException): - logger.exception('Encountered an error while handling request.') - return serialize_error(exc) - - @classmethod - async def handle_request( - cls, handler_name: str, request: Request, request_context: dict = None, - extract_id: bool = False, *args, **kwargs - ) -> Response: - """ - Handles a request by calling the appropriate handler. - Additional args and kwargs are passed to the handler method, - which is usually one of: `get`, `patch`, `delete`, `get_all` or `post`. - """ - request_context = request_context or {} - if extract_id: - id_ = request.path_params.get('id') - kwargs.update({'id': id_}) - request_context.update({'id': id_}) - - # run before request hook - await cls.before_request(request=request, request_context=request_context) - - # safely execute the handler - try: - if request.method not in cls.allowed_methods: - raise JSONAPIException(status_code=405) - resource = cls(request, request_context=request_context) - handler = getattr(resource, handler_name, None) - response = await handler(*args, **kwargs) # type: Response - except Exception as e: - response = await cls.handle_error(request=request, exc=e) - - # run after request hook - await cls.after_request(request=request, request_context=request_context, response=response) - - return response - - @classmethod - async def handle_get(cls, request: Request): - return await cls.handle_request(handler_name='get', request=request, extract_id=True) - - @classmethod - async def handle_patch(cls, request: Request): - return await cls.handle_request(handler_name='patch', request=request, extract_id=True) - - @classmethod - async def handle_delete(cls, request: Request): - return await cls.handle_request(handler_name='delete', request=request, extract_id=True) - - @classmethod - async def handle_get_all(cls, request: Request): - return await cls.handle_request(handler_name='get_all', request=request) - - @classmethod - async def handle_post(cls, request: Request): - return await cls.handle_request(handler_name='post', request=request) - - @classmethod - async def handle_get_related(cls, request: Request, relationship: str = None): - """ Handles related resources requests, such as /articles/1/author. """ - related_id = request.path_params.get('related_id') - request_context = {'relationship': relationship, 'related_id': related_id} - return await cls.handle_request( - handler_name='get_related', request=request, - relationship=relationship, related_id=related_id, - request_context=request_context, - extract_id=True, - ) - - @classmethod - def register_routes(cls, app: Starlette, base_path: str): if not cls.type_ or not cls.schema: raise Exception('Cannot register a resource without specifying its `type_` and its `schema`.') @@ -334,7 +462,13 @@ def register_routes(cls, app: Starlette, base_path: str): routes = [ Route( '/{{id:{}}}/{}/{{related_id:{}}}'.format(cls.id_mask, rel_name, rel_class.id_mask), - _partial(relationship=rel_name)(cls.handle_get_related), + functools.partial( + cls.handle_request, + 'get_related', + relationship=rel_name, + extract_params=['id', 'related_id'], + request_context={'relationship': rel_name}, + ), methods=['GET'], name=f'{rel_name}-id', ) @@ -344,7 +478,13 @@ def register_routes(cls, app: Starlette, base_path: str): routes += [ Route( '/{{id:{}}}/{}'.format(cls.id_mask, rel_name), - _partial(relationship=rel_name)(cls.handle_get_related), + functools.partial( + cls.handle_request, + 'get_related', + relationship=rel_name, + extract_params=['id'], + request_context={'relationship': rel_name}, + ), methods=['GET'], name=rel_name, ) @@ -354,23 +494,28 @@ def register_routes(cls, app: Starlette, base_path: str): # attach primary routes, example: /articles/ and /articles/1 routes += [ Route( - '/{{id:{}}}'.format(cls.id_mask), cls.handle_get, + '/{{id:{}}}'.format(cls.id_mask), + functools.partial(cls.handle_request, 'get', extract_params=['id']), methods=['GET'], name='get', ), Route( - '/{{id:{}}}'.format(cls.id_mask), cls.handle_patch, + '/{{id:{}}}'.format(cls.id_mask), + functools.partial(cls.handle_request, 'patch', extract_params=['id']), methods=['PATCH'], name='patch', ), Route( - '/{{id:{}}}'.format(cls.id_mask), cls.handle_delete, + '/{{id:{}}}'.format(cls.id_mask), + functools.partial(cls.handle_request, 'delete', extract_params=['id']), methods=['DELETE'], name='delete', ), Route( - '/', cls.handle_get_all, - methods=['GET'], name='get_all', + '/', + functools.partial(cls.handle_request, 'get_many'), + methods=['GET'], name='get_many', ), Route( - '/', cls.handle_post, + '/', + functools.partial(cls.handle_request, 'post'), methods=['POST'], name='post', ), ] @@ -384,110 +529,50 @@ def register_routes(cls, app: Starlette, base_path: str): app.routes.append(cls.mount) - # Methods used to generate compound documents - # https://jsonapi.org/format/#document-compound-documents async def _prepare_included(self, data: Any, many: bool) -> Optional[List[str]]: + """ + Processes the ``include`` query parameter and calls :meth:`include_relations` + for every object in ``data``, to enable requests for compound documents. + """ include_param = parse_included_params(self.request) if not include_param: return None include_param_list = list(include_param) if many is True: for item in data: - try: - await self.prepare_relations(obj=item, relations=include_param_list) - except _StopInclude: - return None + await self.include_relations(obj=item, relations=include_param_list) else: - try: - await self.prepare_relations(obj=data, relations=include_param_list) - except _StopInclude: - return None + await self.include_relations(obj=data, relations=include_param_list) return include_param_list - async def prepare_relations(self, obj: Any, relations: List[str]) -> None: - """ - Should be implemented by subclasses in order to support compound documents - for asynchronous objects that may need fetching. - - Example `relations`: - url = /some-url?include=resource1,resource1.resource2 - relations = ['resource1', 'resource1.resource2'] - - :param obj: an object that was passed to `serialize` - :param relations: list of relations, ex: ['resource1', 'resource1.resource2'] - """ - raise _StopInclude - - # Methods used to implement sparse fields - # https://jsonapi.org/format/#fetching-sparse-fieldsets - async def process_sparse_fields(self, serialized_data: dict, many: bool = False) -> dict: - """ - Processes sparse fields requests by cleaning the serialized - data of extra attributes and relationships. - """ - sparse_fields = parse_sparse_fields_params(self.request) - if not sparse_fields or not serialized_data.get('data'): - return serialized_data - - data = serialized_data['data'] - new_data = [] if many else {} # type: Union[List, dict] - - included = serialized_data.get('included', None) - new_included = [] - - for resource_name, fields in sparse_fields.items(): - # filter sparse fields in `data` - if many: - for item in data: - if item['type'] == resource_name: - new_data.append(filter_sparse_fields(item, fields)) # type: ignore - else: - if data['type'] == resource_name: - new_data = filter_sparse_fields(data, fields) - - # filter sparse fields in `included` - if included: - for item in included: - if item['type'] == resource_name: - new_included.append(filter_sparse_fields(item, fields)) - - new_serialized_data = serialized_data.copy() - new_serialized_data['data'] = new_data - if new_included: - new_serialized_data['included'] = new_included - - return serialized_data - class _StopInclude(Exception): pass -class BaseRelationshipResource: +class BaseRelationshipResource(_BaseResourceHandler): """ A basic json:api relationships resource implementation, data layer agnostic. """ - # The parent resource that this relationship belongs to + + #: The parent resource that this relationship belongs to parent_resource: Type[BaseResource] - # The relationship name, as found on the parent resource schema - relationship_name: str - # High level filter for HTTP requests. - # If you specify a smaller subset, any request that specifies a method - # not listed here will result in a 405 error. - allowed_methods = {'GET', 'PATCH', 'POST', 'DELETE'} - def __init__(self, request: Request, request_context: dict, *args, **kwargs) -> None: - self.request = request - self.request_context = request_context + #: The relationship name, as found on the parent resource schema + relationship_name: str async def post(self, parent_id: Any, *args, **kwargs) -> Response: + """ Subclasses should implement this to handle POST //relationships/ requests. """ raise JSONAPIException(status_code=405) async def get(self, parent_id: Any, *args, **kwargs) -> Response: + """ Subclasses should implement this to handle GET //relationships/ requests. """ raise JSONAPIException(status_code=405) async def patch(self, parent_id: Any, *args, **kwargs) -> Response: + """ Subclasses should implement this to handle PATCH //relationships/ requests. """ raise JSONAPIException(status_code=405) async def delete(self, parent_id: Any, *args, **kwargs) -> Response: + """ Subclasses should implement this to handle DELETE //relationships/ requests. """ raise JSONAPIException(status_code=405) def _get_relationship_field(self) -> JSONAPIRelationship: @@ -508,20 +593,6 @@ async def serialize(self, data: Any) -> dict: body = relationship.serialize(self.relationship_name, data) return body - async def to_response(self, data: dict, meta: dict = None, *args, **kwargs) -> JSONAPIResponse: - """ - Wraps `data` in a JSONAPIResponse object and returns it. - If `meta` is specified, it will be included as the top level `meta` object in the json:api response. - Additional args and kwargs are passed to the `starlette` based Response. - """ - if meta: - data = data.copy() - data.update(meta=meta) - return JSONAPIResponse( - content=data, - *args, **kwargs, - ) - async def deserialize_ids(self) -> Union[None, str, List[str]]: """ Parses the request body to find relationship ids. @@ -554,66 +625,24 @@ async def deserialize_ids(self) -> Union[None, str, List[str]]: return deserialized_ids @classmethod - async def before_request(cls, request: Request, request_context: dict) -> None: - """ - Optional hook that can be implemented by subclasses to execute logic before a request is handled. - This will not run if an exception is raised before `handle_request` is called. - - For more advanced hooks, check starlette middleware. - - :param request: The Starlette Request object - :param request_context: The current request's context. - """ - return - - @classmethod - async def after_request(cls, request: Request, request_context: dict, response: Response) -> None: + def register_routes(cls, *args, **kwargs): """ - Optional hook that can be implemented by subclasses to execute logic after a request is handled. - This will not run if an exception is raised before `handle_request` is called. + Registers URL routes associated to this resource. + Should be called after calling register_routes for the parent resource. - For more advanced hooks, check starlette middleware. + The following URL routes will be registered, relative to :attr:`parent_resource`: - :param request: The Starlette Request object - :param request_context: The current request's context. - :param response: The Starlette Response object - """ - return + - **Relative name:** ``relationships-`` + - **Relative URL:** ``//relationships/`` - @classmethod - async def handle_error(cls, request: Request, exc: Exception) -> JSONAPIResponse: - if not isinstance(exc, HTTPException): - logger.exception('Encountered an error while handling request.') - return serialize_error(exc) + For example, a relationship resource that would handle article authors + would be registered relative to the articles resource as: - @classmethod - async def handle_request(cls, request: Request, request_context: dict = None, *args, **kwargs) -> Response: + - **Relative name:** ``relationships-author`` + - **Full name:** ``articles:relationships-author`` + - **Relative URL:** ``//relationships/author`` + - **Full URL:** ``/articles//relationships/author`` """ - Handles a request by calling the appropriate handler based on the request method. - Additional args and kwargs are passed to the handler method, - which is usually one of: `get`, `patch`, `delete`, or `post`. - """ - request_context = request_context or {} - # run before request hook - await cls.before_request(request=request, request_context=request_context) - - try: - if request.method not in cls.allowed_methods: - raise JSONAPIException(status_code=405) - kwargs.update(parent_id=request.path_params['parent_id']) - resource = cls(request, request_context=request_context) - handler = getattr(resource, request.method.lower(), None) - response = await handler(*args, **kwargs) # type: Response - except Exception as e: - response = await cls.handle_error(request=request, exc=e) - - # run after request hook - await cls.after_request(request=request, request_context=request_context, response=response) - - return response - - @classmethod - def register_routes(cls, *args, **kwargs): if not cls.parent_resource.type_: raise Exception( 'Cannot register a relationship resource if the `parent_resource` does not specify a `type_`.' @@ -623,26 +652,15 @@ def register_routes(cls, *args, **kwargs): raise Exception('Parent resource should be registered first.') name = f'relationships-{cls.relationship_name}' - cls.parent_resource.mount.routes.append( - Route( - name=name, - path='/{{parent_id:{}}}/relationships/{}'.format( - cls.parent_resource.id_mask, - cls.relationship_name - ), - endpoint=cls.handle_request, - methods=['GET', 'POST', 'PATCH', 'DELETE'], + for method in cls.allowed_methods: + cls.parent_resource.mount.routes.append( + Route( + name=name, + path='/{{parent_id:{}}}/relationships/{}'.format( + cls.parent_resource.id_mask, + cls.relationship_name + ), + endpoint=functools.partial(cls.handle_request, method.lower(), extract_params=['parent_id']), + methods=[method], + ) ) - ) - - -def _partial(*args, **kwargs): - """ - This is a temporary partial, since we cannot use functools.partial with Starlette due to asyncio bugs. - https://github.com/encode/starlette/pull/984 - """ - def outer(f): - async def inner(request: Request): - return await f(request, *args, **kwargs) - return inner - return outer diff --git a/starlette_jsonapi/responses.py b/starlette_jsonapi/responses.py index 24704a4..10c344b 100644 --- a/starlette_jsonapi/responses.py +++ b/starlette_jsonapi/responses.py @@ -4,6 +4,11 @@ class JSONAPIResponse(JSONResponse): + """ + Base response class for json:api requests, sets `Content-Type: application/vnd.api+json`. + + For detailed information, see `Starlette responses `_. + """ media_type = 'application/vnd.api+json' def render(self, content: Any) -> bytes: diff --git a/starlette_jsonapi/schema.py b/starlette_jsonapi/schema.py index e6837b3..237a4c9 100644 --- a/starlette_jsonapi/schema.py +++ b/starlette_jsonapi/schema.py @@ -43,20 +43,51 @@ def __init__(self, meta, *args, **kwargs): class JSONAPISchema(__Schema): + """ + Extends :class:`marshmallow_jsonapi.Schema` to offer Starlette support. + + For extended information on what fields are required, or how to configure each field + in a schema, you should consult the docs for: + + - `marshmallow_jsonapi `_ + - `marshmallow `_ + + When specifying related objects, the :class:`starlette_jsonapi.fields.JSONAPIRelationship` + field should be used, to support generating URLs from Starlette routes. + + Example definition: + + .. code-block:: python + + from marshmallow_jsonapi import fields + + # example model + class User: + id: int + name: str + + # example schema + class UserSchema(JSONAPISchema): + class Meta: + type_ = 'users' + + id = fields.Str() # Exposed as a string, according to the json:api spec + name = fields.Str() + """ OPTIONS_CLASS = BaseSchemaOpts class Meta: """ - Options object that takes the same options as `marshmallow-jsonapi.Schema`, + Options object that takes the same options as :class:`marshmallow-jsonapi.Schema`, but instead of ``self_url``, ``self_url_kwargs`` and ``self_url_many`` has the following options to resolve the URLs from Starlette route names: * ``self_route`` - Route name to resolve the self URL link from. - * ``self_route_kwargs`` - Replacement fields for ``self_route``. String - attributes enclosed in ``< >`` will be interpreted as attributes to - pull from the schema data. + * ``self_route_kwargs`` - Replacement fields for ``self_route``. + String attributes enclosed in ``< >`` will be + interpreted as attributes of the serialized object. * ``self_route_many`` - Route name to resolve the self URL link when a - collection of resources is returned. + collection of resources is returned. """ pass diff --git a/starlette_jsonapi/utils.py b/starlette_jsonapi/utils.py index 2f23e55..b578ef7 100644 --- a/starlette_jsonapi/utils.py +++ b/starlette_jsonapi/utils.py @@ -1,4 +1,4 @@ -from typing import Optional, Set, Dict, List +from typing import Optional, Set, Dict, List, Union from starlette.applications import Starlette from starlette.exceptions import HTTPException @@ -12,7 +12,7 @@ def serialize_error(exc: Exception) -> JSONAPIResponse: """ Serializes exception according to the json:api spec - and returns the equivalent JSONAPIResponse. + and returns the equivalent :class:`JSONAPIResponse`. """ if isinstance(exc, JSONAPIException): status_code = exc.status_code @@ -44,13 +44,15 @@ async def _serialize_error(request: Request, exc: Exception) -> Response: def parse_included_params(request: Request) -> Optional[Set[str]]: """ - Parses a request's `include` query parameter, if present, + Parses a request's ``include`` query parameter, if present, and returns a sequence of included relations. - For example, if a request were to reach - `/some-resource/?include=foo,foo.bar` - then: - `parse_included_params(request) == {'foo', 'foo.bar'}` + Example: + + .. code-block:: python + + # request URL /some-resource/?include=foo,foo.bar + assert parse_included_params(request) == {'foo', 'foo.bar'} """ include = request.query_params.get('include') if include: @@ -61,13 +63,15 @@ def parse_included_params(request: Request) -> Optional[Set[str]]: def parse_sparse_fields_params(request: Request) -> Dict[str, List[str]]: """ - Parses a request's `fields` query parameter, if present, + Parses a request's ``fields`` query parameter, if present, and returns a dictionary of resource type -> sparse fields. - For example, if a request were to reach - `/some-resource/?fields[some-resource]=foo,bar` - then: - `parse_sparse_fields_params(request) == {'some-resource': ['foo', 'bar']}` + Example: + + .. code-block:: python + + # request URL: /articles/?fields[articles]=title,content + assert parse_sparse_fields_params(request) == {'articles': ['title', 'content']} """ sparse_fields = dict() for qp_name, qp_value in request.query_params.items(): @@ -82,38 +86,95 @@ def parse_sparse_fields_params(request: Request) -> Dict[str, List[str]]: return sparse_fields -def filter_sparse_fields(item: dict, sparse_fields) -> dict: +def filter_sparse_fields(item: dict, sparse_fields: List[str]) -> dict: """ - Given a dictionary representation of an item, - mutate in place to drop fields according to a sparse fields request. + Given a dictionary with the json:api representation of an item, + drops any attributes or relationships that are not found in ``sparse_fields`` + and returns a new dictionary. - https://jsonapi.org/format/#fetching-sparse-fieldsets + For detailed information, check the json:api + `spec `_. """ + new_item = item.copy() # filter `attributes` - item_attributes = item.get('attributes') + item_attributes = new_item.get('attributes') if item_attributes: new_attributes = item_attributes.copy() for attr_name in item_attributes: if attr_name not in sparse_fields: new_attributes.pop(attr_name, None) if new_attributes: - item['attributes'] = new_attributes + new_item['attributes'] = new_attributes else: - del item['attributes'] + del new_item['attributes'] # filter `relationships` - item_relationships = item.get('relationships') + item_relationships = new_item.get('relationships') if item_relationships: new_relationships = item_relationships.copy() for rel_name in item_relationships: if rel_name not in sparse_fields: new_relationships.pop(rel_name, None) if new_relationships: - item['relationships'] = new_relationships + new_item['relationships'] = new_relationships else: - del item['relationships'] - return item + del new_item['relationships'] + return new_item + + +def process_sparse_fields(serialized_data: dict, many: bool = False, sparse_fields: dict = None) -> dict: + """ + Processes sparse fields requests by removing extra attributes + and relationships from the final serialized data. + + If a client does not specify the set of fields for a given resource type, + all fields will be included. + + Consult the `json:api spec `_ + for more information. + """ + if not sparse_fields or not serialized_data.get('data'): + return serialized_data + + data = serialized_data['data'] + new_data = [] if many else {} # type: Union[List, dict] + + included = serialized_data.get('included', None) + new_included = [] + + # process sparse-fields for `data` + if many: + new_data = [] + for item in data: + if item['type'] in sparse_fields: + new_data.append(filter_sparse_fields(item, sparse_fields[item['type']])) + else: + new_data.append(item) + else: + if data['type'] in sparse_fields: + new_data = filter_sparse_fields(data, sparse_fields[data['type']]) + else: + new_data = data + + # process sparse-fields for `included` + if included: + for item in included: + if item['type'] in sparse_fields: + new_included.append(filter_sparse_fields(item, sparse_fields[item['type']])) + else: + new_included.append(item) + + new_serialized_data = serialized_data.copy() + new_serialized_data['data'] = new_data + if new_included: + new_serialized_data['included'] = new_included + + return new_serialized_data def prefix_url_path(app: Starlette, path: str, **kwargs): + """ + Prefixes all URLs generated by the framework with the value of ``app.url_prefix``, if set. + Can be used to generate absolute links. + """ prefix = getattr(app, 'url_prefix', '') return f'{prefix}{app.url_path_for(path, **kwargs)}' diff --git a/tests/test_fields.py b/tests/test_fields.py index 1eac23b..11a1d89 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -19,8 +19,7 @@ class Meta: rel = JSONAPIRelationship( id_attribute='rel_id', schema=OtherSchema, - include_resource_linkage=True, - type_='bar' + type_='bar', ) d = TestSchema().dump(dict(rel=dict(id='bar'), rel_id='bar_id', id='foo')) @@ -47,6 +46,7 @@ class Meta: id = fields.Str() rel = JSONAPIRelationship( schema=OtherSchema, + include_resource_linkage=False, type_='bar' ) @@ -63,7 +63,7 @@ class Meta: type_ = 'others' self_route = 'others:get' self_route_kwargs = {'id': ''} - self_route_many = 'others:get_all' + self_route_many = 'others:get_many' class OtherResource(BaseResource): type_ = 'others' @@ -76,7 +76,6 @@ class FooSchema(JSONAPISchema): id = fields.Str() rel = JSONAPIRelationship( schema='OtherSchema', - include_resource_linkage=True, type_='others', self_route='', related_resource='OtherResource', @@ -89,7 +88,7 @@ class Meta: type_ = 'foo' self_route = 'foo:get' self_route_kwargs = {'id': ''} - self_route_many = 'foo:get_all' + self_route_many = 'foo:get_many' class FooResource(BaseResource): type_ = 'foo' diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 74aea72..d0018ac 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -195,7 +195,7 @@ class TResource(BaseResource): schema = TSchema pagination_class = TPagination - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: data = [ dict(id=1, name='foo'), dict(id=2, name='foo'), diff --git a/tests/test_related.py b/tests/test_related.py index 9cc7ec9..90398a9 100644 --- a/tests/test_related.py +++ b/tests/test_related.py @@ -1,4 +1,8 @@ +import json import logging +from asyncio import Future +from typing import Any +from unittest import mock import pytest from marshmallow_jsonapi import fields @@ -20,7 +24,6 @@ class TSchema(JSONAPISchema): rel = JSONAPIRelationship( schema='TRelatedSchema', type_='test-related-resource', - include_resource_linkage=True, ) class Meta: @@ -124,7 +127,8 @@ def test_relationship_resource(relationship_app: Starlette): assert rv.headers['Content-Type'] == 'application/vnd.api+json' assert rv.json() == { 'errors': [ - {'detail': 'Must include a `data` key'} + {'detail': 'Must include a `data` key'}, + {'detail': 'Bad Request'}, ] } @@ -142,7 +146,8 @@ def test_relationship_resource(relationship_app: Starlette): assert rv.headers['Content-Type'] == 'application/vnd.api+json' assert rv.json() == { 'errors': [ - {'detail': 'Must have an `id` field'} + {'detail': 'Must have an `id` field'}, + {'detail': 'Bad Request'}, ] } @@ -156,7 +161,6 @@ class TSchema(JSONAPISchema): rel_many = JSONAPIRelationship( schema='TRelatedSchema', type_='test-related-resource', - include_resource_linkage=True, many=True ) @@ -291,7 +295,8 @@ def test_relationship_many_resource(relationship_many_app: Starlette): assert rv.headers['Content-Type'] == 'application/vnd.api+json' assert rv.json() == { 'errors': [ - {'detail': 'Must include a `data` key'} + {'detail': 'Must include a `data` key'}, + {'detail': 'Bad Request'}, ] } @@ -309,7 +314,8 @@ def test_relationship_many_resource(relationship_many_app: Starlette): assert rv.headers['Content-Type'] == 'application/vnd.api+json' assert rv.json() == { 'errors': [ - {'detail': 'Must have an `id` field'} + {'detail': 'Must have an `id` field'}, + {'detail': 'Bad Request'}, ] } @@ -328,7 +334,8 @@ def test_relationship_many_resource(relationship_many_app: Starlette): assert rv.headers['Content-Type'] == 'application/vnd.api+json' assert rv.json() == { 'errors': [ - {'detail': 'Relationship is list-like'} + {'detail': 'Relationship is list-like'}, + {'detail': 'Bad Request'}, ] } @@ -486,9 +493,6 @@ async def get(self, parent_id: str, *args, **kwargs) -> Response: test_client = TestClient(app) rv = test_client.get('/test-resource/foo/relationships/rel') assert rv.status_code == 405 - assert rv.json() == { - 'errors': [{'detail': 'Method Not Allowed'}] - } def test_relationship_resource_register_routes_missing_parent_type(app: Starlette): @@ -540,7 +544,7 @@ class Meta: type_ = 'test-related-resource' self_route = 'test-related-resource:get' self_route_kwargs = {'id': ''} - self_route_many = 'test-related-resource:get_all' + self_route_many = 'test-related-resource:get_many' class TRelatedResource(BaseResource): type_ = 'test-related-resource' @@ -558,14 +562,13 @@ class TSchema(JSONAPISchema): related_resource='TRelatedResource', related_route='test-resource:rel', related_route_kwargs={'id': ''}, - include_resource_linkage=True, ) class Meta: type_ = 'test-resource' self_route = 'test-resource:get' self_route_kwargs = {'id': ''} - self_route_many = 'test-resource:get_all' + self_route_many = 'test-resource:get_many' class TResource(BaseResource): type_ = 'test-resource' @@ -735,7 +738,6 @@ class TSchema(JSONAPISchema): rel = JSONAPIRelationship( schema='TRelatedSchema', type_='test-related-resource', - include_resource_linkage=True, ) class Meta: @@ -780,3 +782,113 @@ async def get(self, parent_id: str, *args, **kwargs) -> Response: }, 'meta': {'some-meta-attribute': 'some-meta-value'}, } + + +@pytest.mark.asyncio +async def test_relationship_resource_before_request_called(monkeypatch): + f = Future() # type: Future + f.set_result('foo') + fake_before_request = mock.MagicMock(return_value=f) + fake_request = mock.MagicMock() + fake_request.method = 'GET' + fake_request.path_params = {'parent_id': '123'} + + class TRelationshipResource(BaseRelationshipResource): + pass + + monkeypatch.setattr(TRelationshipResource, 'before_request', fake_before_request) + + assert fake_before_request.call_count == 0 + + await TRelationshipResource.handle_request('get', fake_request) + + assert fake_before_request.call_count == 1 + assert fake_before_request.call_args_list[0][1] == { + 'request': fake_request, + 'request_context': {}, + } + + +@pytest.mark.asyncio +async def test_relationship_resource_before_request_error_caught(monkeypatch): + f = Future() # type: Future + f.set_exception(Exception('foo')) + fake_before_request = mock.MagicMock(return_value=f) + fake_request = mock.MagicMock() + fake_request.path_params = {'parent_id': '123'} + + class TRelationshipResource(BaseRelationshipResource): + pass + + monkeypatch.setattr(TRelationshipResource, 'before_request', fake_before_request) + + assert fake_before_request.call_count == 0 + resp = await TRelationshipResource.handle_request('get', fake_request) + assert fake_before_request.call_count == 1 + + assert json.loads(resp.body)['errors'] == [{'detail': 'Internal server error'}] + assert resp.status_code == 500 + + +@pytest.mark.asyncio +async def test_relationship_resource_after_request_called(monkeypatch): + f = Future() # type: Future + f.set_result('foo') + fake_after_request = mock.MagicMock(return_value=f) + fake_request = mock.MagicMock() + fake_request.method = 'GET' + fake_request.path_params = {'parent_id': '123'} + fake_response = mock.MagicMock() + + class TRelationshipResource(BaseRelationshipResource): + + async def get(self, parent_id, *args, **kwargs) -> Response: + return fake_response + + monkeypatch.setattr(TRelationshipResource, 'after_request', fake_after_request) + + assert fake_after_request.call_count == 0 + await TRelationshipResource.handle_request('get', fake_request, extract_params=['parent_id']) + assert fake_after_request.call_count == 1 + assert fake_after_request.call_args_list[0][1] == { + 'request': fake_request, + 'request_context': {'parent_id': '123'}, + 'response': fake_response, + } + + # check that after_request is called when an exception is raised + class TBuggyResource(BaseRelationshipResource): + + async def get(self, parent_id, *args, **kwargs) -> Response: + raise Exception('something bad happened') + + monkeypatch.setattr(TBuggyResource, 'after_request', fake_after_request) + + assert fake_after_request.call_count == 1 + r = await TBuggyResource.handle_request('get', fake_request, extract_params=['parent_id']) + assert r.status_code == 500 + assert fake_after_request.call_count == 2 + + +@pytest.mark.asyncio +async def test_relationship_resource_after_request_error_caught(monkeypatch): + f = Future() # type: Future + f.set_exception(Exception('foo')) + fake_after_request = mock.MagicMock(return_value=f) + fake_request = mock.MagicMock() + fake_request.method = 'GET' + fake_request.path_params = {'parent_id': '123'} + + class TRelationshipResource(BaseRelationshipResource): + + async def get(self, parent_id: Any, *args, **kwargs) -> Response: + return Response(status_code=200) + + monkeypatch.setattr(TRelationshipResource, 'after_request', fake_after_request) + + assert fake_after_request.call_count == 0 + resp = await TRelationshipResource.handle_request('get', fake_request) + assert fake_after_request.call_count == 1 + + assert resp.status_code == 500 + assert json.loads(resp.body)['errors'] == [{'detail': 'Internal server error'}] diff --git a/tests/test_resource.py b/tests/test_resource.py index 6604df3..bc470b4 100644 --- a/tests/test_resource.py +++ b/tests/test_resource.py @@ -1,3 +1,4 @@ +import json import logging import uuid from asyncio import Future @@ -35,21 +36,21 @@ class T3Resource(BaseResource): assert app.url_path_for('test-resource:get', id='foo') == '/some-base-path/test-resource/foo' assert app.url_path_for('test-resource:patch', id='foo') == '/some-base-path/test-resource/foo' assert app.url_path_for('test-resource:delete', id='foo') == '/some-base-path/test-resource/foo' - assert app.url_path_for('test-resource:get_all') == '/some-base-path/test-resource/' + assert app.url_path_for('test-resource:get_many') == '/some-base-path/test-resource/' assert app.url_path_for('test-resource:post') == '/some-base-path/test-resource/' # test routes for T2Resource assert app.url_path_for('test-resource-2:get', id='foo') == '/test-resource-2/foo' assert app.url_path_for('test-resource-2:patch', id='foo') == '/test-resource-2/foo' assert app.url_path_for('test-resource-2:delete', id='foo') == '/test-resource-2/foo' - assert app.url_path_for('test-resource-2:get_all') == '/test-resource-2/' + assert app.url_path_for('test-resource-2:get_many') == '/test-resource-2/' assert app.url_path_for('test-resource-2:post') == '/test-resource-2/' # test routes for T3Resource assert app.url_path_for('v2-test-resource-2:get', id='foo') == '/v2/test-resource-2/foo' assert app.url_path_for('v2-test-resource-2:patch', id='foo') == '/v2/test-resource-2/foo' assert app.url_path_for('v2-test-resource-2:delete', id='foo') == '/v2/test-resource-2/foo' - assert app.url_path_for('v2-test-resource-2:get_all') == '/v2/test-resource-2/' + assert app.url_path_for('v2-test-resource-2:get_many') == '/v2/test-resource-2/' assert app.url_path_for('v2-test-resource-2:post') == '/v2/test-resource-2/' @@ -161,7 +162,7 @@ class TResource(BaseResource): type_ = 'test-resource' schema = TSchema - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: return await self.to_response(await self.serialize([dict(id=1, name='foo')], many=True)) async def get(self, id=None, *args, **kwargs) -> Response: @@ -252,8 +253,11 @@ def test_deserialize_raises_validation_errors(serialization_app: Starlette): 'errors': [ { 'detail': '`data` object must include `type` key.', - 'source': {'pointer': '/data'} - } + 'source': {'pointer': '/data'}, + }, + { + 'detail': 'Bad Request', + }, ] } @@ -281,7 +285,6 @@ class TSchema(JSONAPISchema): rel = JSONAPIRelationship( schema='TRelatedSchema', type_='test-related-resource', - include_resource_linkage=True, ) class Meta: @@ -298,7 +301,7 @@ class TResource(BaseResource): type_ = 'test-resource' schema = TSchema - async def prepare_relations(self, obj: Any, relations: List[str]) -> None: + async def include_relations(self, obj: Any, relations: List[str]) -> None: return None async def get(self, id=None, *args, **kwargs) -> Response: @@ -306,7 +309,7 @@ async def get(self, id=None, *args, **kwargs) -> Response: dict(id='foo', name='foo-name', rel=dict(id='bar', description='bar-description')) )) - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: return await self.to_response(await self.serialize( [ dict(id='foo', name='foo-name', rel=dict(id='bar', description='bar-description')), @@ -324,7 +327,7 @@ async def get(self, id=None, *args, **kwargs) -> Response: dict(id='foo', name='foo-name', rel=dict(id='bar', description='bar-description')) )) - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: return await self.to_response(await self.serialize( [dict(id='foo2', name='foo2-name', rel=dict(id='bar2', description='bar2-description'))], many=True @@ -424,47 +427,27 @@ def test_included_data_many(included_app: Starlette): def test_no_included_data(included_app: Starlette): - # if resource does not override `prepare_relations`, - # compound documents will not be generated + # if resource does not override `include_relations`, + # a 400 error should be returned. test_client = TestClient(app=included_app) rv = test_client.get('/test-resource-not-included/foo?include=rel') - assert rv.status_code == 200 + assert rv.status_code == 400 assert rv.json() == { - 'data': { - 'id': 'foo', - 'type': 'test-resource', - 'attributes': { - 'name': 'foo-name', - }, - 'relationships': { - 'rel': { - 'data': { - 'type': 'test-related-resource', - 'id': 'bar', - } - } + 'errors': [ + { + 'detail': 'Bad Request', } - } + ] } rv = test_client.get('/test-resource-not-included/?include=rel') - assert rv.status_code == 200 + assert rv.status_code == 400 assert rv.json() == { - 'data': [{ - 'id': 'foo2', - 'type': 'test-resource', - 'attributes': { - 'name': 'foo2-name', - }, - 'relationships': { - 'rel': { - 'data': { - 'type': 'test-related-resource', - 'id': 'bar2', - } - } + 'errors': [ + { + 'detail': 'Bad Request', } - }] + ] } @@ -498,7 +481,7 @@ class TResource(BaseResource): type_ = 'test-resource' allowed_methods = {'GET'} - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: return Response(status_code=200) async def post(self, *args, **kwargs) -> Response: @@ -732,6 +715,90 @@ def test_sparse_fields_many(included_app: Starlette): } +def test_sparse_fields_includes_missing_types(included_app: Starlette): + test_client = TestClient(included_app) + rv = test_client.get( + '/test-resource/' + '?include=rel' + '&fields[test-related-resource]=nothing' + ) + assert rv.status_code == 200 + assert rv.json() == { + 'data': [ + { + 'id': 'foo', + 'type': 'test-resource', + 'attributes': { + 'name': 'foo-name', + }, + 'relationships': { + 'rel': { + 'data': { + 'type': 'test-related-resource', + 'id': 'bar', + } + } + } + }, + { + 'id': 'foo2', + 'type': 'test-resource', + 'attributes': { + 'name': 'foo2-name', + }, + 'relationships': { + 'rel': { + 'data': { + 'type': 'test-related-resource', + 'id': 'bar2', + } + } + } + }, + ], + 'included': [ + { + 'id': 'bar', + 'type': 'test-related-resource', + }, + { + 'id': 'bar2', + 'type': 'test-related-resource', + } + ] + } + + rv = test_client.get( + '/test-resource/foo' + '?include=rel' + '&fields[test-related-resource]=nothing' + ) + assert rv.status_code == 200 + assert rv.json() == { + 'data': { + 'id': 'foo', + 'type': 'test-resource', + 'attributes': { + 'name': 'foo-name', + }, + 'relationships': { + 'rel': { + 'data': { + 'type': 'test-related-resource', + 'id': 'bar', + } + } + } + }, + 'included': [ + { + 'id': 'bar', + 'type': 'test-related-resource', + } + ] + } + + def test_client_generated_ids(app: Starlette): class TSchema(JSONAPISchema): id = fields.Str() @@ -794,7 +861,7 @@ class TResource(BaseResource): type_ = 'test-resource' schema = TSchema - async def get_all(self, *args, **kwargs) -> Response: + async def get_many(self, *args, **kwargs) -> Response: return await self.to_response( await self.serialize([dict(id=1, name='foo')], many=True), meta={'some-meta-attribute': 'some-meta-value'}, @@ -833,7 +900,7 @@ class TResource(BaseResource): assert fake_before_request.call_count == 0 - await TResource.handle_get(fake_request) + await TResource.handle_request('get', fake_request, extract_params=['id']) assert fake_before_request.call_count == 1 assert fake_before_request.call_args_list[0][1] == { @@ -861,7 +928,7 @@ async def get(self, id=None, *args, **kwargs) -> Response: assert fake_after_request.call_count == 0 - await TResource.handle_get(fake_request) + await TResource.handle_request('get', fake_request, extract_params=['id']) assert fake_after_request.call_count == 1 assert fake_after_request.call_args_list[0][1] == { @@ -879,6 +946,51 @@ async def get(self, id=None, *args, **kwargs) -> Response: monkeypatch.setattr(TBuggyResource, 'after_request', fake_after_request) assert fake_after_request.call_count == 1 - r = await TBuggyResource.handle_get(fake_request) + r = await TBuggyResource.handle_request('get', fake_request, extract_params=['id']) assert r.status_code == 500 assert fake_after_request.call_count == 2 + + +@pytest.mark.asyncio +async def test_before_request_error_caught(monkeypatch): + f = Future() # type: Future + f.set_exception(Exception('foo')) + fake_before_request = mock.MagicMock(return_value=f) + fake_request = mock.MagicMock() + fake_request.path_params = {'id': '123'} + + class TResource(BaseResource): + pass + + monkeypatch.setattr(TResource, 'before_request', fake_before_request) + + assert fake_before_request.call_count == 0 + resp = await TResource.handle_request('get', fake_request, extract_params=['id']) + assert fake_before_request.call_count == 1 + + assert resp.status_code == 500 + assert json.loads(resp.body)['errors'] == [{'detail': 'Internal server error'}] + + +@pytest.mark.asyncio +async def test_after_request_error_caught(monkeypatch): + f = Future() # type: Future + f.set_exception(Exception('foo')) + fake_after_request = mock.MagicMock(return_value=f) + fake_request = mock.MagicMock() + fake_request.method = 'GET' + fake_request.path_params = {'id': '123'} + + class TResource(BaseResource): + + async def get(self, id=None, *args, **kwargs) -> Response: + return Response(status_code=200) + + monkeypatch.setattr(TResource, 'after_request', fake_after_request) + + assert fake_after_request.call_count == 0 + resp = await TResource.handle_request('get', fake_request, extract_params=['id']) + assert fake_after_request.call_count == 1 + + assert resp.status_code == 500 + assert json.loads(resp.body)['errors'] == [{'detail': 'Internal server error'}] diff --git a/tests/test_schema.py b/tests/test_schema.py index 7b9ac66..65990aa 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -19,7 +19,7 @@ class Meta: type_ = 'test-resource' self_route = 'test-resource:get' self_route_kwargs = {'id': ''} - self_route_many = 'test-resource:get_all' + self_route_many = 'test-resource:get_many' rv = TSchema().dump(dict(id='foo', name='foo-name')) assert rv == { @@ -62,7 +62,7 @@ class Meta: type_ = 'test-resource' self_route = 'test-resource:get' self_route_kwargs = {'id': ''} - self_route_many = 'test-resource:get_all' + self_route_many = 'test-resource:get_many' app.url_prefix = 'https://example.com' rv = TSchema(app=app).dump(dict(id='foo', name='foo-name')) diff --git a/tests/test_utils.py b/tests/test_utils.py index 4eab905..b031ac4 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,7 +5,10 @@ from starlette_jsonapi.exceptions import JSONAPIException from starlette_jsonapi.responses import JSONAPIResponse -from starlette_jsonapi.utils import serialize_error, register_jsonapi_exception_handlers +from starlette_jsonapi.utils import ( + serialize_error, register_jsonapi_exception_handlers, filter_sparse_fields, + process_sparse_fields, +) def test_serialize_error(): @@ -30,7 +33,17 @@ def test_serialize_error(): assert isinstance(response, JSONAPIResponse) assert response.status_code == 400 assert json.loads(response.body) == { - 'errors': [{'detail': 'foo'}, {'detail': 'bar'}] + 'errors': [{'detail': 'foo'}, {'detail': 'bar'}, {'detail': 'Bad Request'}] + } + + error1 = JSONAPIException(404, 'error1') + error2 = JSONAPIException(400, 'error2') + final_error = JSONAPIException(400, 'final', errors=error1.errors + error2.errors) + response = serialize_error(final_error) + assert isinstance(response, JSONAPIResponse) + assert response.status_code == 400 + assert json.loads(response.body) == { + 'errors': [{'detail': 'error1'}, {'detail': 'error2'}, {'detail': 'final'}] } @@ -47,3 +60,231 @@ def test_uncaught_exceptions(app): } ] } + + +def test_filter_sparse_fields_removes_fields(): + jsonapi_repr = { + 'id': '123', + 'type': 'users', + 'attributes': { + 'name': 'User 1', + 'country': 'US', + }, + 'relationships': { + 'organization': { + 'data': { + 'type': 'organizations', + 'id': '456', + } + } + } + } + + assert filter_sparse_fields(jsonapi_repr, ['name']) == { + 'id': '123', + 'type': 'users', + 'attributes': { + 'name': 'User 1', + } + } + + # check that the original data has not been mutated + assert 'country' in jsonapi_repr['attributes'] + assert 'relationships' in jsonapi_repr + + +def test_filter_sparse_fields_unknown_field(): + jsonapi_repr = { + 'id': '123', + 'type': 'users', + 'attributes': { + 'name': 'User 1', + 'country': 'US', + }, + 'relationships': { + 'organization': { + 'data': { + 'type': 'organizations', + 'id': '456', + } + } + } + } + + assert filter_sparse_fields(jsonapi_repr, ['unknown']) == { + 'id': '123', + 'type': 'users', + } + + # check that the original data has not been mutated + assert 'attributes' in jsonapi_repr + assert 'relationships' in jsonapi_repr + + +def test_process_sparse_fields_single(): + complete_jsonapi_repr = { + 'data': { + 'id': '123', + 'type': 'users', + 'attributes': { + 'name': 'User 1', + 'country': 'US', + }, + 'relationships': { + 'organization': { + 'data': { + 'type': 'organizations', + 'id': '456', + } + } + } + }, + 'meta': { + 'key': 'value', + } + } + + assert process_sparse_fields( + complete_jsonapi_repr, + many=False, sparse_fields={'users': 'unknown'}, + ) == { + 'data': { + 'id': '123', + 'type': 'users', + }, + 'meta': { + 'key': 'value', + } + } + + +def test_process_sparse_fields_many(): + complete_jsonapi_repr = { + 'data': [ + { + 'id': '1', + 'type': 'users', + 'attributes': { + 'name': 'User 1', + 'country': 'US', + }, + 'relationships': { + 'organization': { + 'data': { + 'type': 'organizations', + 'id': '1', + } + } + } + }, + { + 'id': '2', + 'type': 'users', + 'attributes': { + 'name': 'User 2', + 'country': 'RO', + }, + 'relationships': { + 'organization': { + 'data': { + 'type': 'organizations', + 'id': '1', + } + } + } + }, + ], + 'meta': { + 'key': 'value', + } + } + + assert process_sparse_fields( + complete_jsonapi_repr, + many=True, sparse_fields={'users': 'unknown'}, + ) == { + 'data': [ + { + 'id': '1', + 'type': 'users', + }, + { + 'id': '2', + 'type': 'users', + } + ], + 'meta': { + 'key': 'value', + } + } + + +def test_process_sparse_fields_included(): + complete_jsonapi_repr = { + 'data': { + 'id': '123', + 'type': 'users', + 'attributes': { + 'name': 'User 1', + 'country': 'US', + }, + 'relationships': { + 'organization': { + 'data': { + 'type': 'organizations', + 'id': '456', + } + } + } + }, + 'included': [ + { + 'id': '456', + 'type': 'organizations', + 'attributes': { + 'name': 'Organization 1', + } + } + ], + 'meta': { + 'key': 'value', + } + } + + assert process_sparse_fields( + complete_jsonapi_repr, + many=False, sparse_fields={'users': 'unknown'}, + ) == { + 'data': { + 'id': '123', + 'type': 'users', + }, + 'included': [ + { + 'id': '456', + 'type': 'organizations', + 'attributes': { + 'name': 'Organization 1', + } + } + ], + 'meta': { + 'key': 'value', + } + } + + +def test_process_sparse_fields_without_fields(): + complete_jsonapi_repr = { + 'data': { + 'id': '123', + 'type': 'users', + 'attributes': { + 'name': 'User 1', + 'country': 'US', + } + } + } + + assert process_sparse_fields(complete_jsonapi_repr, many=False) == complete_jsonapi_repr + assert process_sparse_fields(complete_jsonapi_repr, many=False, sparse_fields={}) == complete_jsonapi_repr diff --git a/tox.ini b/tox.ini index 4fa9b36..7be2899 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = flake8,mypy,py36,py37,py38 +envlist = flake8,mypy,py36,py37,py38,py39 skip_missing_interpreters = true [testenv]