Service interfaces

When a product picks one of several features that do the same job, a concrete parent feature declares the interface they implement, and each of them registers itself as a provider. The parent owns the wiring, the configuration and the choice. The providers implement an abstract class and do nothing else, so a product swaps one for another without a refinement.

Table of contents

When to use one

A provider is a feature of an alternative or or group in the product line’s model, whose children are interchangeable ways of doing one thing. Without an interface each child wires itself on its own, the copies drift, and a product that wants a different behaviour ends up with a refinement of one of them. With one, the parent says once what a provider answers, and the model can say which providers a product may pick.

Group Parent Interface Providers How many a product installs
captcha (alternative) splent_feature_captcha CaptchaService recaptcha, cloudflare, captcha_image One
publicacion (or) splent_feature_dataset DepositionProvider zenodo, local_publication One or more, chosen per deposit
curation (optional) splent_feature_dataset PublicationReviewer curation None, or one when the publication policy is curated

The parent declares it

The interface is an abstract class in the parent’s package, with the methods every provider implements marked abstractmethod and the rest given a default. Next to it, a registration function checks a class before it accepts it.

# splent_feature_dataset/publication.py
from abc import ABC, abstractmethod


class DepositionProvider(ABC):
    name: str = ""        # what a dataset records, "zenodo"
    pid_scheme: str = ""  # what it mints, "doi"

    @abstractmethod
    def create(self, deposit) -> str: ...

    @abstractmethod
    def publish(self, record_id): ...

    def landing_url(self, pid):  # a default: the hub's /doi landing
        return None


def register_deposition_provider(app, provider):
    accept_provider(app, provider)  # TypeError, RuntimeError
    _registry(app)[provider.name] = provider.__name__
    register_service(app, provider.__name__, provider)

accept_provider raises TypeError for a class that does not subclass the interface, that leaves an abstract method unimplemented, or that names no valid name. It raises RuntimeError for a second provider where the group allows one (captcha), or for a second class under a name already taken (dataset). The same class registered again is accepted, because an app built again registers its features again.

The provider is registered in the service locator as well, under its class name for dataset and under the generic CaptchaService for captcha, so a refinement can still replace it with refine_service.


A provider registers itself

# splent_feature_zenodo/__init__.py
from splent_io.splent_feature_dataset.publication import register_deposition_provider
from splent_io.splent_feature_zenodo.provider import ZenodoProvider


def init_feature(app):
    register_deposition_provider(app, ZenodoProvider)

The provider imports its parent, so its contract requires it (requires.features = ["dataset>=0.7.0"]) and the model says so (zenodo => dataset), which also makes it load after the parent. The parent never imports a provider.

A provider never builds a Flask response and never sees the request. It raises the errors the interface declares, and the parent’s routes turn them into statuses and messages. DepositionProvider’s errors carry http_status and user_message for that.


The parent chooses

For an alternative, the provider is the one registered. For an or-group, the parent keeps a registry by name and picks with a rule the product configures. dataset takes the provider a dataset is bound to, else the one the request names, else DATASET_PUBLICATION_PROVIDER, else the only one installed.


An optional provider that a setting needs

Some interfaces have no provider most of the time and need one only for a value of a parameter. dataset publishes without anybody looking first unless DATASET_PUBLISH_POLICY is curated; then an account the reviewer does not trust sends its dataset to review instead, and a reviewer approves it. The reviewer is the provider of PublicationReviewer, which curation implements:

# splent_feature_dataset/policy.py
class PublicationReviewer(ABC):
    @abstractmethod
    def is_trusted(self, user) -> bool: ...  # publishes without review

    @abstractmethod
    def may_review(self, user) -> bool: ...  # reads what waits for review


# splent_feature_curation/__init__.py
def init_feature(app):
    register_publication_reviewer(app, CurationReviewer)

A setting that cannot work without its provider stops the start with a message that says what to install, rather than letting the product run and fail on the first request. Here every publication would wait for a review nobody makes. The provider loads after the parent, which it requires, so the parent cannot ask for it while it loads itself; it reads what the product declares instead (get_features_from_pyproject). And it still refuses at a request if no provider registered, rather than act as if there were one.

The model says the same, so product:validate catches it before any start: the values of the parameter are nodes of the model (uvl = true in the schema, P7), and the value that needs the provider requires it.

dataset_publish_policy {param 'DATASET_PUBLISH_POLICY'}
    alternative
        publish_open {value 'open'}
        publish_verified {value 'verified'}
        publish_curated {value 'curated'}

publish_curated => curation

Testing

The parent’s tests register a fake provider of their own and put the app’s registry back afterwards, so they run the same with any provider installed, or none. dataset’s is tests/providers.py, whose records live in memory and which fails where a test says, and whose FakeReviewer trusts and lets review the accounts a test names. They also pin the settings a product may change underneath them: dataset’s run on an open hub (an autouse fixture) whatever policy the product chose, and the policy’s own tests set it themselves.

A provider’s tests cannot import the parent’s: the tests package is left out of a feature’s wheel, so the neutral app of a release gate does not have it. curation brings its own fake provider and mail service.

def test_a_failure_before_the_publish_step_deletes_the_draft(draft, provider):
    provider.fail["upload"] = ProviderUnavailable("the upload broke")
    ...
    assert provider.names() == ["create", "upload", "delete_unpublished"]

A provider’s tests mock the third party one layer below (requests.request for zenodo) and check that each answer becomes the right error of the interface.


See also


Back to top

splent. Distributed by an LGPL license v3. Contact us: drorganvidez@us.es