Skip to content

Commit

Permalink
refactor: Resources must define a 'scim_schema' attribute
Browse files Browse the repository at this point in the history
  • Loading branch information
azmeuk committed Dec 1, 2024
1 parent 9236228 commit 7514c8b
Show file tree
Hide file tree
Showing 23 changed files with 153 additions and 75 deletions.
22 changes: 22 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
Changelog
=========

[0.3.0] - Unreleased
--------------------

.. warning::

This version comes with breaking changes:

- :class:`~scim2_models.Resource`, :class:`~scim2_models.Extension` and :class:`~scim2_models.Message` must define a ``scim_schema`` attribute.

.. code-block:: python
:caption: Before
class MyResource(Resource):
schemas : list[str] = ["example:schema:MyResource"]
.. code-block:: python
:caption: After
class MyResource(Resource):
scim_schema: ClassVar[str] = "example:schema:MyResource"
[0.2.7] - 2024-11-30
--------------------

Expand Down
9 changes: 6 additions & 3 deletions doc/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -272,11 +272,12 @@ Custom models

You can write your own model and use it the same way than the other scim2-models models.
Just inherit from :class:`~scim2_models.Resource` for your main resource, or :class:`~scim2_models.Extension` for extensions.
Then you need to define a ``scim_schema`` attribute, that is a class variable detailing the schema identifier of your model.
Use :class:`~scim2_models.ComplexAttribute` as base class for complex attributes:

.. code-block:: python
>>> from typing import Annotated, Optional, List
>>> from typing import Annotated, ClassVar, Optional, List
>>> from scim2_models import Resource, Returned, Mutability, ComplexAttribute
>>> from enum import Enum
Expand All @@ -288,7 +289,7 @@ Use :class:`~scim2_models.ComplexAttribute` as base class for complex attributes
... """The pet color."""
>>> class Pet(Resource):
... schemas: List[str] = ["example:schemas:Pet"]
... scim_schema : ClassVar[str] = "example:schemas:Pet"
...
... name: Annotated[Optional[str], Mutability.immutable, Returned.always]
... """The name of the pet."""
Expand All @@ -309,6 +310,8 @@ that can take type parameters to represent :rfc:`RFC7643 §7 'referenceTypes'<7

>>> from typing import Literal
>>> class PetOwner(Resource):
... scim_schema : ClassVar[str] = "examples:schema.PetOwner"
...
... pet: Reference[Literal["Pet"]]

:class:`~scim2_models.Reference` has two special type parameters :data:`~scim2_models.ExternalReference` and :data:`~scim2_models.URIReference` that matches :rfc:`RFC7643 §7 <7643#section-7>` external and URI reference types.
Expand All @@ -325,7 +328,7 @@ This is useful for server implementations, so custom models or models provided b
>>> class MyCustomResource(Resource):
... """My awesome custom schema."""
...
... schemas: List[str] = ["example:schemas:MyCustomResource"]
... scim_schema: ClassVar[str] = "example:schemas:MyCustomResource"
...
... foobar: Optional[str]
...
Expand Down
21 changes: 7 additions & 14 deletions scim2_models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,7 @@ def validate_attribute_urn(
if default_resource and default_resource not in resource_types:
resource_types.append(default_resource)

default_schema = (
default_resource.model_fields["schemas"].default[0]
if default_resource
else None
)
default_schema = default_resource.scim_schema if default_resource else None

schema: Optional[Any]
schema, attribute_base = extract_schema_and_attribute_base(attribute_name)
Expand Down Expand Up @@ -613,20 +609,17 @@ def mark_with_schema(self):
if not is_complex_attribute(attr_type):
continue

main_schema = (
getattr(self, "_schema", None)
or self.model_fields["schemas"].default[0]
)
main_schema = getattr(self, "_scim_schema", None) or self.scim_schema

separator = ":" if isinstance(self, Resource) else "."
schema = f"{main_schema}{separator}{field_name}"

if attr_value := getattr(self, field_name):
if isinstance(attr_value, list):
for item in attr_value:
item._schema = schema
item._scim_schema = schema
else:
attr_value._schema = schema
attr_value._scim_schema = schema

@field_serializer("*", mode="wrap")
def scim_serializer(
Expand Down Expand Up @@ -793,7 +786,7 @@ def get_attribute_urn(self, field_name: str) -> str:
See :rfc:`RFC7644 §3.10 <7644#section-3.10>`.
"""
main_schema = self.model_fields["schemas"].default[0]
main_schema = self.scim_schema
alias = self.model_fields[field_name].serialization_alias or field_name

# if alias contains a ':' this is an extension urn
Expand All @@ -804,15 +797,15 @@ def get_attribute_urn(self, field_name: str) -> str:
class ComplexAttribute(BaseModel):
"""A complex attribute as defined in :rfc:`RFC7643 §2.3.8 <7643#section-2.3.8>`."""

_schema: Optional[str] = None
_scim_schema: Optional[str] = None

def get_attribute_urn(self, field_name: str) -> str:
"""Build the full URN of the attribute.
See :rfc:`RFC7644 §3.10 <7644#section-3.10>`.
"""
alias = self.model_fields[field_name].serialization_alias or field_name
return f"{self._schema}.{alias}"
return f"{self._scim_schema}.{alias}"


class MultiValuedComplexAttribute(ComplexAttribute):
Expand Down
5 changes: 4 additions & 1 deletion scim2_models/rfc7643/enterprise_user.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Annotated
from typing import ClassVar
from typing import Literal
from typing import Optional

Expand Down Expand Up @@ -26,7 +27,9 @@ class Manager(ComplexAttribute):


class EnterpriseUser(Extension):
schemas: list[str] = ["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"]
scim_schema: ClassVar[str] = (
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
)

employee_number: Optional[str] = None
"""Numeric or alphanumeric identifier assigned to a person, typically based
Expand Down
3 changes: 2 additions & 1 deletion scim2_models/rfc7643/group.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Annotated
from typing import ClassVar
from typing import Literal
from typing import Optional
from typing import Union
Expand Down Expand Up @@ -31,7 +32,7 @@ class GroupMember(MultiValuedComplexAttribute):


class Group(Resource):
schemas: list[str] = ["urn:ietf:params:scim:schemas:core:2.0:Group"]
scim_schema: ClassVar[str] = "urn:ietf:params:scim:schemas:core:2.0:Group"

display_name: Optional[str] = None
"""A human-readable name for the Group."""
Expand Down
29 changes: 23 additions & 6 deletions scim2_models/rfc7643/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ class Meta(ComplexAttribute):


class Extension(BaseModel):
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if not hasattr(cls, "scim_schema"):
raise AttributeError(
f"{cls.__name__} did not define a scim_schema attribute"
)

@classmethod
def to_schema(cls):
"""Build a :class:`~scim2_models.Schema` from the current extension class."""
Expand Down Expand Up @@ -120,7 +127,7 @@ def __new__(cls, name, bases, attrs, **kwargs):
else [extensions]
)
for extension in extensions:
schema = extension.model_fields["schemas"].default[0]
schema = extension.scim_schema
attrs.setdefault("__annotations__", {})[extension.__name__] = Annotated[
Optional[extension],
WrapSerializer(extension_serializer),
Expand All @@ -136,6 +143,18 @@ def __new__(cls, name, bases, attrs, **kwargs):


class Resource(BaseModel, Generic[AnyExtension], metaclass=ResourceMetaclass):
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if not hasattr(cls, "scim_schema"):
raise AttributeError(
f"{cls.__name__} did not define a scim_schema attribute"
)

def init_schemas():
return [cls.scim_schema]

cls.model_fields["schemas"].default_factory = init_schemas

schemas: list[str]
"""The "schemas" attribute is a REQUIRED attribute and is an array of
Strings containing URIs that are used to indicate the namespaces of the
Expand Down Expand Up @@ -186,9 +205,7 @@ def get_extension_models(cls) -> dict[str, type]:
else extension_models
)

by_schema = {
ext.model_fields["schemas"].default[0]: ext for ext in extension_models
}
by_schema = {ext.scim_schema: ext for ext in extension_models}
return by_schema

@staticmethod
Expand All @@ -197,7 +214,7 @@ def get_by_schema(
) -> Optional[type]:
"""Given a resource type list and a schema, find the matching resource type."""
by_schema = {
resource_type.model_fields["schemas"].default[0].lower(): resource_type
resource_type.scim_schema.lower(): resource_type
for resource_type in (resource_types or [])
}
if with_extensions:
Expand Down Expand Up @@ -274,7 +291,7 @@ def compare_field_infos(fi1, fi2):
def model_to_schema(model: type[BaseModel]):
from scim2_models.rfc7643.schema import Schema

schema_urn = model.model_fields["schemas"].default[0]
schema_urn = model.scim_schema
field_infos = dedicated_attributes(model)
attributes = [
model_attribute_to_attribute(model, attribute_name)
Expand Down
9 changes: 4 additions & 5 deletions scim2_models/rfc7643/resource_type.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Annotated
from typing import ClassVar
from typing import Optional

from pydantic import Field
Expand Down Expand Up @@ -35,7 +36,7 @@ class SchemaExtension(ComplexAttribute):


class ResourceType(Resource):
schemas: list[str] = ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"]
scim_schema: ClassVar[str] = "urn:ietf:params:scim:schemas:core:2.0:ResourceType"

name: Annotated[Optional[str], Mutability.read_only, Required.true] = None
"""The resource type name.
Expand Down Expand Up @@ -78,7 +79,7 @@ class ResourceType(Resource):
@classmethod
def from_resource(cls, resource_model: type[Resource]) -> Self:
"""Build a naive ResourceType from a resource model."""
schema = resource_model.model_fields["schemas"].default[0]
schema = resource_model.scim_schema
name = schema.split(":")[-1]
extensions = resource_model.__pydantic_generic_metadata__["args"]
return ResourceType(
Expand All @@ -88,9 +89,7 @@ def from_resource(cls, resource_model: type[Resource]) -> Self:
endpoint=f"/{name}s",
schema_=schema,
schema_extensions=[
SchemaExtension(
schema_=extension.model_fields["schemas"].default[0], required=False
)
SchemaExtension(schema_=extension.scim_schema, required=False)
for extension in extensions
],
)
8 changes: 3 additions & 5 deletions scim2_models/rfc7643/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from enum import Enum
from typing import Annotated
from typing import Any
from typing import ClassVar
from typing import List # noqa : UP005
from typing import Literal
from typing import Optional
Expand Down Expand Up @@ -64,10 +65,7 @@ def make_python_model(
for attr in (obj.attributes or [])
if attr.name
}
pydantic_attributes["schemas"] = (
Optional[list[str]],
Field(default=[obj.id]),
)
pydantic_attributes["scim_schema"] = (ClassVar[str], obj.id)

model_name = to_pascal(to_snake(obj.name))
model = create_model(model_name, __base__=base, **pydantic_attributes)
Expand Down Expand Up @@ -240,7 +238,7 @@ def to_python(self) -> Optional[tuple[Any, Field]]:


class Schema(Resource):
schemas: list[str] = ["urn:ietf:params:scim:schemas:core:2.0:Schema"]
scim_schema: ClassVar[str] = "urn:ietf:params:scim:schemas:core:2.0:Schema"

id: Annotated[Optional[str], Mutability.read_only, Required.true] = None
"""The unique URI of the schema."""
Expand Down
5 changes: 4 additions & 1 deletion scim2_models/rfc7643/service_provider_config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from enum import Enum
from typing import Annotated
from typing import ClassVar
from typing import Optional

from pydantic import Field
Expand Down Expand Up @@ -94,7 +95,9 @@ class Type(str, Enum):


class ServiceProviderConfig(Resource):
schemas: list[str] = ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"]
scim_schema: ClassVar[str] = (
"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"
)

id: Annotated[
Optional[str], Mutability.read_only, Returned.default, Uniqueness.global_
Expand Down
3 changes: 2 additions & 1 deletion scim2_models/rfc7643/user.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from enum import Enum
from typing import Annotated
from typing import ClassVar
from typing import Literal
from typing import Optional
from typing import Union
Expand Down Expand Up @@ -214,7 +215,7 @@ class X509Certificate(MultiValuedComplexAttribute):


class User(Resource):
schemas: list[str] = ["urn:ietf:params:scim:schemas:core:2.0:User"]
scim_schema: ClassVar[str] = "urn:ietf:params:scim:schemas:core:2.0:User"

user_name: Annotated[Optional[str], Uniqueness.server, Required.true] = None
"""Unique identifier for the User, typically used by the user to directly
Expand Down
5 changes: 3 additions & 2 deletions scim2_models/rfc7644/bulk.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from enum import Enum
from typing import Annotated
from typing import Any
from typing import ClassVar
from typing import Optional

from pydantic import Field
Expand Down Expand Up @@ -53,7 +54,7 @@ class BulkRequest(Message):
The models for Bulk operations are defined, but their behavior is not implemented nor tested yet.
"""

schemas: list[str] = ["urn:ietf:params:scim:api:messages:2.0:BulkRequest"]
scim_schema: ClassVar[str] = "urn:ietf:params:scim:api:messages:2.0:BulkRequest"

fail_on_errors: Optional[int] = None
"""An integer specifying the number of errors that the service provider
Expand All @@ -74,7 +75,7 @@ class BulkResponse(Message):
The models for Bulk operations are defined, but their behavior is not implemented nor tested yet.
"""

schemas: list[str] = ["urn:ietf:params:scim:api:messages:2.0:BulkResponse"]
scim_schema: ClassVar[str] = "urn:ietf:params:scim:api:messages:2.0:BulkResponse"

operations: Optional[list[BulkOperation]] = Field(
None, serialization_alias="Operations"
Expand Down
3 changes: 2 additions & 1 deletion scim2_models/rfc7644/error.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Annotated
from typing import ClassVar
from typing import Optional

from pydantic import PlainSerializer
Expand All @@ -10,7 +11,7 @@
class Error(Message):
"""Representation of SCIM API errors."""

schemas: list[str] = ["urn:ietf:params:scim:api:messages:2.0:Error"]
scim_schema: ClassVar[str] = "urn:ietf:params:scim:api:messages:2.0:Error"

status: Annotated[Optional[int], PlainSerializer(int_to_str)] = None
"""The HTTP status code (see Section 6 of [RFC7231]) expressed as a JSON
Expand Down
Loading

0 comments on commit 7514c8b

Please sign in to comment.