Nothing Special   »   [go: up one dir, main page]

Skip to content

Building a service

The service layer is a high-level entry point into your application. Often, you'll have a direct connection between e.g. a button in the UI and a single service method. The service layer contains the domain and business logic of the application and is e.g. responsible for checking permissions, business-level validation and overall control flow.

Designing a service

Before you start writing your service, it's highly important you spend time on designing your service first. Clearly

  • Define responsibilities
  • Define names

As a service often defines transactional boundaries in your code, this often implies that the domain objects will have a rather tight coupling.

Directory structure

A service is normally split over multiple files. Below is an example of such a module structure:

|-- __init__.py
|-- components/
|   |-- __init__.py
|   `-- <component>.py
|-- config.py
|-- customizations.py
|-- errors.py
|-- permissions.py
|-- result_items.py
|-- schema.py
`-- service.py

Service

A service in itself is quite basic. For instance, we can imagine building a click service with a single service method.

# service.py
from invenio_records_resources.services import Service

class ClickService(Service):

    def click(self, identity):
        # do something ..

Tip

The control flow of your service methods should be easy to follow and understand for your colleagues. If it's not, you are either missing new entities in the service layer or your data layer is not well-defined enough.

Service config

Each service also always has a configuration which is used for dependency injection:

# config.py
from invenio_records_resources.services import ServiceConfig

class ClickServiceConfig(ServiceConfig):
    permission_policy_cls = ...

Instantiating a service

Before you can use a service, the service always has to be instantiated:

from flask import current_app
from invenio_access.permissions import system_identity

from .services import ClickService, ClickServiceConfig

service = ClickService(ClickServiceConfig.build(current_app))
service.click(system_identity)

This basically means that all dependencies that can be customized are injected in the service via the config.

Service results

A service is always independent of the presentation layer and thus all parameters must be passed explicitly to a service. Furthermore, a service must do all permission checking, thus a service usually never returns a data layer object directly. Instead, it normally returns a view of a data layer object specific to a given identity:

# service.py
from invenio_records_resources.services import Service

class ClickService(Service):
    def click(self, identity):
        # Retrieving a data layer object
        record = ...

        # The data layer object is wrapped in a service result
        return self.result_item(
            self,
            identity,
            record,
            # ...
        )

The class used to wrap the record in the above case is set via the config:

# config.py
from invenio_records_resources.services import ServiceConfig

from .result_items import RecordView

class ClickServiceConfig(ServiceConfig):
    result_item_cls = RecordView

The result item itself, often provides a to_dict() method that's used by the presentation layer:

# result_items.py
from invenio_records_resources.services.base import \
    ServiceItemResult

class RecordView(ServiceResultItem):
    def __init__(self, identity, record):
        self._identity = identity
        self._record = record

    def to_dict(self):
        # .. view of the record for the given identity ...

Errors

An important aspect of a service is that in case of errors it should always raise a domain error. These errors should be well-defined so that the presentation layer can respond with a correct message.

Always define a base class for errors, and the individual errors:

# errors.py
class ClickException(Exception):
    pass

class AlreadyClickedError(ClickException)
    # ...
    pass

A service method can then raise the error:

# service.py
from invenio_records_resources.services import Service

from .errors import AlreadyClickedError

class ClickService(Service):
    def click(self, identity):
        # ...
        raise AlreadyClickedError()

Info

You should never raise an HTTPException from a service method or use the Flask abort(404) method.

Permissions

Checking permissions

A service method nearly always checks permissions first thing:

# service.py
from invenio_records_resources.services import Service

class ClickService(Service):
    def click(self, identity):
        # the "click" maps to "can_click" in the permission policy
        self.require_permission("click", identity, ...)

The identity must always be given explicitly to the service methods. Thus, often in the REST API (presentation layer), you'll see the identity passed in like below:

from flask import g

def view()
    service.click(g.identity)

Defining permission policies

The require_permission() method delegates permission checks to a permission policy for the given service. The policy is defined in the config:

# config.py
from invenio_records_resources.services import ServiceConfig

from .permissions import ClickPermissionPolicy

class ClickServiceConfig(ServiceConfig):
    permission_policy_cls = ClickPermissionPolicy

The permission policy itself is defined in a declarative way:

# permissions.py
from invenio_records_permissions import RecordPermissionPolicy
from invenio_records_permissions.generators import AnyUser, SystemProcess

class ClickPermissionPolicy(RecordPermissionPolicy):
    can_click = [AnyUser(), SystemProcess()]

The AnyUser() and SystemProcess() objects are called "need generators".

Service components

Services usually define many methods that each may be dealing with multiple independent concerns. For instance the create() method may need to set metadata on a data layer object as well as register a persistent identifier, while the delete() method may need to just delete the persistent identifier.

A service component groups related functionality across service methods, so for instance a PIDComponent would implement the create() and delete() method related to registering/deleting a persistent identifier while a Metadata component would only deal with metadata in its service methods.

# service.py
from invenio_records_resources.services import Service

class ClickService(Service):
    def click(self, identity):
        # ...
        self.run_components(
            'click', # name identical to method's.
            identity, # arguments identical to the method's.
        )

        return self.result_item(...)

The components are injected in the service config:

# config.py
from invenio_records_resources.services import ServiceConfig

from .components import MetadataComponent

class ClickServiceConfig(ServiceConfig):
    components = [
        MetadataComponent,
    ]

The components themselves:

# components.py
from invenio_records_resources.services import ServiceComponent

class MetadataComponent(ServiceComponent):
    def click(self, identity, **kwargs):
        # ...

Note

Service components are not mandatory to use, but they help keep service methods clean and readable by separating independent concerns.

Unit of work

Any state-changing service methods (i.e. create, delete, ...) must support the unit of work pattern to allow grouping multiple service methods into a single atomic operation.

In a service method, you should never use db.session.commit(), but instead use the unit_of_work() decorator like below:

from invenio_records_resources.services import Service
from invenio_records_resources.services.uow import unit_of_work, RecordCommitOp

class ClickService(Service):

    @unit_of_work()
    def click(self, ..., uow=None):
        record = ...
        # Register an operation on the unit of work.
        uow.register(RecordCommitOp(record, indexer=self.indexer))
        return ...

Bootstrapping a service

Once you've written a service, you'll need to create an instance of the service to be used in the Flask application. The overall pattern you'll often see used is that the resources and services are created as below in ext.py:

# ext.py
from .services import ClickService, ClickServiceConfig

class MyExtension:
    # ...
    def init_app(self, app):
        self.init_config(app)
        self.init_services(app)
        self.init_resources(app)

    def init_config(self, app):
        # ....

    def init_services(self, app):
        # Prepare all service configs
        configs = self.service_configs(app)
        # Set the services
        self.service = ClickService(configs.click_service)

    def service_configs(self, app):

        class Configs:
            click_service = ClickServiceConfig.build(app)
            # other service configs could be defined here

        return Configs

In addition, a proxy is set up to access the current service:

# proxies.py
from flask import current_app
from werkzeug.local import LocalProxy

current_myextension = LocalProxy(
    lambda: current_app.extensions['myextension']
)

current_clickservice = LocalProxy(
    lambda: current_myextension.service
)