Skip to main content
Join the official 2020 Python Developers SurveyStart the survey!

Tiny wrapper on starlette and marshmallow-jsonapi for fast JSON:API compliant python services.

Project description

starlette_jsonapi

A minimal "framework" intended to help write json:api compliant services in async Python, written on top of starlette and marshmallow-jsonapi.

In the maintainer's view, REST frameworks that come with a complete data layer implementation are quite limiting and rarely usable in production systems due to business logic needs or authorization constraints. The default implementation they come with is usually getting in the way, more than helping.

Because of that, starlette_jsonapi does not contain a data layer implementation, so you should be able to pick any available async ORM. This also means that you are going to get a very basic interface for writing a REST resource, with some helpers to make your experience more pleasant, but nothing fancy.

Installing

pip install starlette-jsonapi==1.2.0

Since this project is under development, please pin your dependencies to avoid problems.

Features

  • 100% tests coverage
  • basic json:api serialization
  • including related resources
  • starlette friendly route generation
  • exception handlers to serialize as json:api responses
  • relationship resources
  • sparse fields
  • support for client generated IDs
  • support top level meta objects

Todo:

Documentation

You should take a look at the examples directory for implementations.

Defining a schema

After you've decided which ORM to use, you can start writing the associated schemas using the marshmallow_jsonapi library (which itself is extending marshmallow).

from marshmallow_jsonapi import fields
from starlette_jsonapi.fields import JSONAPIRelationship
from starlette_jsonapi.schema import JSONAPISchema

class ExampleSchema(JSONAPISchema):
    class Meta:
        type_ = 'examples'

    id = fields.Str(dump_only=True)
    some_optional_field = fields.Str()
    some_required_field = fields.Str(required=True)

class ChildExampleSchema(JSONAPISchema):
    class Meta:
        type_ = 'examples'

    id = fields.Str(dump_only=True)
    name = fields.Str()

    parent = JSONAPIRelationship(
        type_='examples',
        schema='ExampleSchema',
        include_resource_linkage=True,
        required=True,
        # `id_attribute` can be specified in order to allow serializing
        # relationships even when the related object is not available.
        # See the `sample-tortoise-orm` example for more information.
    )

You can also generate the associated links object by specifying more options in the Meta class of a schema:

from marshmallow_jsonapi import fields
from starlette_jsonapi.schema import JSONAPISchema

class SomeSchema(JSONAPISchema):
    id = fields.Str(dump_only=True)
    name = fields.Str()

    class Meta:
        type_='some-resource'
        self_route = 'some-resource:get'
        self_route_kwargs = {'id': '<id>'}
        self_route_many = 'some-resource:get_all'

Defining a resource

Once the schema is defined, we can add the associated resource class and link them by setting the schema attribute on the resource class.

Resources are implemented by subclassing starlette_jsonapi.resource.BaseResource. A json:api compliant service will implement GET, POST, PATCH, DELETE HTTP methods on a resource, so the BaseResource comes with 5 methods that you can override:

  • get -> handling GET /<id>
  • patch -> handling PATCH /<id>
  • delete -> handling DELETE /<id>
  • get_all -> handling GET /
  • post -> handling POST /

All methods return 405 Method Not Allowed by default. You can also customize allowed_methods to a subset of the above HTTP methods.

Additionally, the prepare_relations method is available for enabling inclusion of related resources when the include query parameter is specified. Since the json:api specification does not enforce this, the default implementation will skip inclusion in order to avoid data leaks.

Example:

from starlette_jsonapi.resource import BaseResource
from starlette_jsonapi.responses import JSONAPIResponse
from starlette_jsonapi.exceptions import ResourceNotFound

examples_db = {}
last_id = 0

class ExampleResource(BaseResource):
    type_ = 'examples'
    schema = ExampleSchema

    async def get(self, id=None, *args, **kwargs):
        example = examples_db.get(id)
        if not example:
            raise ResourceNotFound
        return await self.to_response(await self.serialize(example))

    async def patch(self, id=None, *args, **kwargs):
        example = examples_db.get(id)
        if not example:
            raise ResourceNotFound

        body = await self.deserialize_body(partial=True)
        if body.get('some_optional_field'):
            example['some_optional_field'] = body.get('some_optional_field')
        if body.get('some_required_field'):
            example['some_required_field'] = body.get('some_required_field')

        return await self.to_response(await self.serialize(example))

    async def delete(self, id=None, *args, **kwargs):
        example = examples_db.pop(id, None)
        if not example:
            raise ResourceNotFound
        return JSONAPIResponse(status_code=204)

    async def get_all(self, *args, **kwargs):
        examples = list(examples_db.values())
        return await self.to_response(await self.serialize(examples, many=True))

    async def post(self, *args, **kwargs):
        global last_id
        body = await self.deserialize_body()
        last_id += 1
        example = {'id': last_id}
        if body.get('some_optional_field'):
            example['some_optional_field'] = body.get('some_optional_field')

        # We didn't ask for a partial deserialization, so a required field shouldn't throw a KeyError
        example['some_required_field'] = body['some_required_field']
        examples_db[example['id']] = example

        return await self.to_response(await self.serialize(example), status_code=201)

Also optional, you can support fetching related data for requests like /articles/1/author, by implementing get_related, then calling serialize_related with the related data. Check sample-plain for an implementation example.

Defining a relationship resource

You can choose to define a relationship resource by subclassing starlette_jsonapi.resource.BaseRelationshipResource.

A json:api compliant service might implement GET, POST, PATCH, DELETE HTTP methods on a relationship resource, so the BaseRelationshipResource comes with 4 methods that you can override:

  • get -> handling GET /<parent_type>/<parent_id>/relationships/<relationship_name>
  • patch -> handling PATCH /<parent_type>/<parent_id>/relationships/<relationship_name>
  • delete -> handling DELETE /<parent_type>/<parent_id>/relationships/<relationship_name>
  • post -> handling POST /<parent_type>/<parent_id>/relationships/<relationship_name>

All methods return 405 Method Not Allowed by default. You can also customize allowed_methods to a subset of the above HTTP methods.

When defining a relationship resource, the following class attributes must be set:

  • parent_resource -> must point to the BaseResource subclass that is exposing the parent resource
  • relationship_name -> must contain the relationship name, as found on parent_resource.schema

Example:

from marshmallow_jsonapi import fields
from starlette.applications import Starlette
from starlette_jsonapi.fields import JSONAPIRelationship
from starlette_jsonapi.resource import BaseRelationshipResource, BaseResource
from starlette_jsonapi.schema import JSONAPISchema

class EmployeeSchema(JSONAPISchema):
    class Meta:
        type_ = 'employees'
        self_route = 'employees:get'
        self_route_kwargs = {'id': '<id>'}
        self_route_many = 'employees:get_all'

    id = fields.Str(dump_only=True)
    name = fields.Str()

    manager = JSONAPIRelationship(
        type_='employees',
        schema='EmployeeSchema',
        include_resource_linkage=True,
        self_route='employees:relationships-manager',
        self_route_kwargs={'parent_id': '<id>'},
        related_route='employees:manager',
        related_route_kwargs={'id': '<id>'},
    )


class EmployeeResource(BaseResource):
    type_ = 'employees'
    schema = EmployeeSchema


class EmployeeManagerResource(BaseRelationshipResource):
    parent_resource = EmployeeResource
    relationship_name = 'manager'


app = Starlette()
EmployeeResource.register_routes(app=app, base_path='/')
EmployeeManagerResource.register_routes(app=app)

Registering resource paths

To register a defined resource class, we need to add the appropriate paths to the Starlette app. Considering the ExampleResource implementation above, it's as simple as:

from starlette.applications import Starlette

app = Starlette()
ExampleResource.register_routes(app=app, base_path='/api/')

This will add the following routes to the Starlette application:

  • GET /api/examples/
  • POST /api/examples/
  • GET /api/examples/{id:str}
  • PATCH /api/examples/{id:str}
  • DELETE /api/examples/{id:str}

You can also customize the registered Mount name by specifying register_as on the resource class.

from starlette.applications import Starlette
from starlette_jsonapi.resource import BaseResource

class ExampleResourceV2(BaseResource):
    type_ = 'examples'
    register_as = 'v2-examples'

app = Starlette()
ExampleResourceV2.register_routes(app=app, base_path='/api/v2')

assert app.url_path_for('v2-examples:get_all') == '/api/v2/examples/'
assert app.url_path_for('v2-examples:post') == '/api/v2/examples/'
assert app.url_path_for('v2-examples:get', id='foo') == '/api/v2/examples/foo'
assert app.url_path_for('v2-examples:patch', id='foo') == '/api/v2/examples/foo'
assert app.url_path_for('v2-examples:delete', id='foo') == '/api/v2/examples/foo'

Which will cause the routes to be registered:

  • GET /api/v2/examples/
  • POST /api/v2/examples/
  • GET /api/v2/examples/{id:str}
  • PATCH /api/v2/examples/{id:str}
  • DELETE /api/v2/examples/{id:str}

Accessing the request

While handling a request inside a resource, you can use self.request to access the Starlette Request object.

Accessing the request body

Although directly accessible from self.request, you should probably use self.deserialize_body to raise any validation errors as 400 HTTP responses, while benefiting from a cleaner payload.

Links

Links are relative by default. You can add a static prefix to the generated links url by adding an url_prefix attribute to your app instance.

Setting up the app with

from starlette.applications import Starlette

app = Starlette()
app.url_prefix = 'https://example.com'

Will produce the following links

{
    'data': {
        'id': 'foo',
        'type': 'test-resource',
        'attributes': {
            'name': 'foo-name',
        },
        'links': {
            'self': 'https://example.com/test-resource/foo',
        },
    },
    'links': {
        'self': 'https://example.com/test-resource/foo',
    },
}

Client generated IDs

The JSON:API spec mentions:

A server MAY accept a client-generated ID along with a request to create a resource.

To enable client generated IDS, specify the Schema's id field without the usual dump_only attribute that has been presented in this documentation. Doing this will make marshmallow read the id field when deserialize_body is called.

Note: Validation of the client generated ID is not provided by this framework, but the specification mentions:

An ID MUST be specified with an id key, the value of which MUST be a universally unique identifier.

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.

Top level meta objects

To include a meta object (specification) in the top level json:api response, you can pass a dictionary meta argument when calling to_response, in a primary or relationship resource:

to_response({'id': 123, ....}, meta={'copyright': 'FooBar'})

Contributing

This project is in its early days, so any help is appreciated.

Running tests:

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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Files for starlette-jsonapi, version 1.2.0
Filename, size File type Python version Upload date Hashes
Filename, size starlette_jsonapi-1.2.0-py3-none-any.whl (16.6 kB) File type Wheel Python version py3 Upload date Hashes View
Filename, size starlette_jsonapi-1.2.0.tar.gz (15.0 kB) File type Source Python version None Upload date Hashes View

Supported by

Pingdom Pingdom Monitoring Google Google Object Storage and Download Analytics Sentry Sentry Error logging AWS AWS Cloud computing DataDog DataDog Monitoring Fastly Fastly CDN DigiCert DigiCert EV certificate StatusPage StatusPage Status page