from abc import ABC
from abc import abstractmethod
from contextlib import AbstractAsyncContextManager
from contextlib import AbstractContextManager
from contextlib import nullcontext
from typing import Any
from scim2_models import Resource
from scim2_models import ResourceType
from scim2_models import SearchRequest
[docs]
class ScimStorage(ABC):
"""Where a SCIM server reads and writes its resources.
Subclass it to connect the server to a data source, such as a SQL database
or a directory. The server handles the SCIM protocol, and calls these
methods to read, search, create, update and delete resources. Every method
receives the :class:`~scim2_models.ResourceType` it applies to, so a single
storage can serve several resource types.
A storage follows these rules, which
:class:`~scim2_server.testing.ScimStorageContract` checks:
- Every resource it returns is a copy. Changing it does not change the
stored resource, and the resources it receives are not changed either.
- It fills ``id``, ``meta.resourceType``, ``meta.created``,
``meta.lastModified`` and ``meta.version``. It leaves ``meta.location``
to the server, which knows the URLs.
- The version changes whenever the resource changes. An update that
changes nothing may keep it.
- A resource that does not exist raises
:class:`~scim2_models.NotFoundException`.
- A value already taken by an attribute whose uniqueness is ``server`` or
``global`` raises :class:`~scim2_models.UniquenessException`.
- It supports what the :class:`~scim2_models.ServiceProviderConfig` of the
server announces, such as sorting. The server refuses the rest before
calling the storage.
"""
[docs]
@abstractmethod
def get(self, resource_type: ResourceType, resource_id: str) -> Resource[Any]:
"""Return a resource.
:raises ~scim2_models.NotFoundException: When no resource of this type
has this identifier.
"""
[docs]
@abstractmethod
def search(
self, resource_types: list[ResourceType], search_request: SearchRequest[Any]
) -> tuple[int, list[Resource[Any]]]:
"""Return the number of matching resources, and one page of them.
The search request is already validated, and its ``count`` is already
bounded by the ``maxResults`` of the server. The storage filters, sorts
and pages the resources of every given resource type as a single
collection. Several resource types mean a search at the server root.
An attribute that a resource type does not declare matches none of its
resources (:rfc:`RFC 7644 §3.4.2.1 <7644#section-3.4.2.1>`).
:raises ~scim2_models.InvalidFilterException: When the storage cannot
evaluate the filter. Filtering the page afterwards would make
``totalResults`` and the paging wrong.
:raises ~scim2_models.NotImplementedException: When the storage does
not support searching several resource types at once.
"""
[docs]
@abstractmethod
def create(
self, resource_type: ResourceType, resource: Resource[Any]
) -> Resource[Any]:
"""Store a new resource, and return the stored resource.
:raises ~scim2_models.UniquenessException: When a unique value is taken.
"""
[docs]
@abstractmethod
def update(
self,
resource_type: ResourceType,
resource: Resource[Any],
*,
expected_version: str | None = None,
) -> Resource[Any]:
"""Replace the stored resource that has the identifier of ``resource``, and return the stored resource.
The server calls it for PUT and PATCH requests. It applies the
request to the stored resource first, so ``resource`` is the whole new
state of the resource.
:param expected_version: The version the stored resource must still
have, when given.
:raises ~scim2_models.NotFoundException: When the resource does not exist.
:raises ~scim2_models.PreconditionFailedException: When the stored
version is not ``expected_version``.
:raises ~scim2_models.UniquenessException: When a unique value is taken.
"""
[docs]
@abstractmethod
def delete(
self,
resource_type: ResourceType,
resource_id: str,
*,
expected_version: str | None = None,
) -> None:
"""Delete a resource.
:param expected_version: The version the stored resource must still
have, when given.
:raises ~scim2_models.NotFoundException: When the resource does not exist.
:raises ~scim2_models.PreconditionFailedException: When the stored
version is not ``expected_version``.
"""
[docs]
def operation(self) -> AbstractContextManager[None]:
"""Enclose one SCIM operation: a single request, or one operation of a bulk request.
It does nothing by default. A SQL storage can open a savepoint here,
so that a failed operation does not prevent the next ones of a bulk
request (:rfc:`RFC 7644 §3.7 <7644#section-3.7>`). Committing the
request is left to the application.
"""
return nullcontext()
[docs]
class AsyncScimStorage(ABC):
"""The asynchronous variant of :class:`ScimStorage`.
Its methods are coroutines, and follow the rules of :class:`ScimStorage`,
which :class:`~scim2_server.testing.AsyncScimStorageContract` checks.
"""
[docs]
@abstractmethod
async def get(self, resource_type: ResourceType, resource_id: str) -> Resource[Any]:
"""Return a resource. See :meth:`ScimStorage.get`."""
[docs]
@abstractmethod
async def search(
self, resource_types: list[ResourceType], search_request: SearchRequest[Any]
) -> tuple[int, list[Resource[Any]]]:
"""Return the number of matching resources, and one page of them. See :meth:`ScimStorage.search`."""
[docs]
@abstractmethod
async def create(
self, resource_type: ResourceType, resource: Resource[Any]
) -> Resource[Any]:
"""Store a new resource. See :meth:`ScimStorage.create`."""
[docs]
@abstractmethod
async def update(
self,
resource_type: ResourceType,
resource: Resource[Any],
*,
expected_version: str | None = None,
) -> Resource[Any]:
"""Replace a stored resource. See :meth:`ScimStorage.update`."""
[docs]
@abstractmethod
async def delete(
self,
resource_type: ResourceType,
resource_id: str,
*,
expected_version: str | None = None,
) -> None:
"""Delete a resource. See :meth:`ScimStorage.delete`."""
[docs]
def operation(self) -> AbstractAsyncContextManager[None]:
"""Enclose one SCIM operation. See :meth:`ScimStorage.operation`."""
return nullcontext()