Tutorial¶
Attribute access¶
SCIM resources support two ways to access and modify attributes. The standard Python dot notation uses snake_case attribute names, while the bracket notation accepts SCIM paths as defined in RFC7644 §3.10.
>>> from scim2_models import User
>>> user = User(user_name="bjensen")
>>> user.display_name = "Barbara Jensen"
>>> user["nickName"] = "Babs"
>>> user["name.familyName"] = "Jensen"
Attributes can be removed with del or by assigning None to the attribute.
>>> del user["nickName"]
>>> user.nick_name is None
True
Model parsing¶
Use Pydantic’s model_validate() method to parse and validate SCIM2 payloads.
>>> from scim2_models import User
>>> import datetime
>>> payload = {
... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
... "id": "2819c223-7f76-453a-919d-413861904646",
... "userName": "[email protected]",
... "meta": {
... "resourceType": "User",
... "created": "2010-01-23T04:56:22Z",
... "lastModified": "2011-05-13T04:42:34Z",
... "version": 'W\\/"3694e05e9dff590"',
... "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646",
... },
... }
>>> user = User.model_validate(payload)
>>> user.user_name
'[email protected]'
>>> user.meta.created
datetime.datetime(2010, 1, 23, 4, 56, 22, tzinfo=...)
Model serialization¶
Pydantic model_dump() method has been tuned to produce valid SCIM2 payloads.
>>> from scim2_models import User, Meta
>>> import datetime
>>> user = User(
... id="2819c223-7f76-453a-919d-413861904646",
... user_name="[email protected]",
... meta=Meta(
... resource_type="User",
... created=datetime.datetime(2010, 1, 23, 4, 56, 22, tzinfo=datetime.timezone.utc),
... last_modified=datetime.datetime(2011, 5, 13, 4, 42, 34, tzinfo=datetime.timezone.utc),
... version='W\\/"3694e05e9dff590"',
... location="https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646",
... ),
... )
>>> dump = user.model_dump()
>>> assert dump == {
... "schemas": [
... "urn:ietf:params:scim:schemas:core:2.0:User"
... ],
... "id": "2819c223-7f76-453a-919d-413861904646",
... "meta": {
... "resourceType": "User",
... "created": "2010-01-23T04:56:22Z",
... "lastModified": "2011-05-13T04:42:34Z",
... "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646",
... "version": "W\\/\"3694e05e9dff590\""
... },
... "userName": "[email protected]"
... }
Contexts¶
The SCIM specifications detail some Mutability and Returned parameters for model attributes.
Depending on the context, they will indicate that attributes should be present, absent, or ignored.
For instance, attributes marked as read_only should not be sent by SCIM clients on resource creation requests.
By passing the right Context to the model_dump() method, only the expected fields will be dumped for this context:
>>> from scim2_models import User, Context
>>> user = User(user_name="[email protected]")
>>> payload = user.model_dump(scim_ctx=Context.RESOURCE_CREATION_REQUEST)
In the same fashion, by passing the right Context to the model_validate() method,
fields with unexpected values will raise ValidationError:
>>> from scim2_models import User, Context, Error
>>> from pydantic import ValidationError
>>> try:
... obj = User.model_validate(payload, scim_ctx=Context.RESOURCE_CREATION_REQUEST)
... except ValidationError:
... obj = Error(...)
Context annotations¶
Context type aliases¶
scim2-models provides generic type aliases that wrap
SCIMValidator and SCIMSerializer for each
SCIM context. *RequestContext aliases inject the context during validation,
*ResponseContext aliases during serialization:
CreationRequestContext/CreationResponseContext— resource creation (POST)QueryRequestContext/QueryResponseContext— resource query (GET)ReplacementRequestContext/ReplacementResponseContext— resource replacement (PUT)SearchRequestContext/SearchResponseContext— search (POST /…/.search)PatchRequestContext/PatchResponseContext— patch (PATCH)
>>> from pydantic import TypeAdapter
>>> from scim2_models import User, CreationRequestContext, CreationResponseContext
>>> adapter = TypeAdapter(CreationRequestContext[User])
>>> user = adapter.validate_python({
... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
... "userName": "bjensen",
... "id": "should-be-stripped",
... })
>>> user.id is None
True
>>> adapter = TypeAdapter(CreationResponseContext[User])
>>> user.id = "123"
>>> data = adapter.dump_python(user)
>>> "password" not in data
True
In FastAPI for instance, they can be used directly in endpoint signatures:
from scim2_models import CreationRequestContext, CreationResponseContext, User
@router.post("/Users", status_code=201)
async def create_user(
user: CreationRequestContext[User],
) -> CreationResponseContext[User]:
...
See the FastAPI guide for a complete example.
Note
*ResponseContext aliases do not support the attributes /
excludedAttributes parameters defined in
RFC 7644 §3.9. When you need to forward those
parameters, use model_dump_json explicitly instead.
Low-level markers¶
For more advanced usage, the underlying markers can be used directly with
typing.Annotated:
SCIMValidator— injects a context during validation.SCIMSerializer— injects a context during serialization.
>>> from typing import Annotated
>>> from pydantic import TypeAdapter
>>> from scim2_models import User, Context, SCIMSerializer
>>> adapter = TypeAdapter(
... Annotated[User, SCIMSerializer(Context.RESOURCE_QUERY_RESPONSE)]
... )
>>> user = User(user_name="bjensen", password="secret")
>>> user.id = "123"
>>> data = adapter.dump_python(user)
>>> "password" not in data
True
Attributes inclusions and exclusions¶
In some situations it might be needed to exclude, or only include a given set of attributes when serializing a model.
This happens for instance when servers build response payloads for clients requesting only a subset of the model attributes.
As defined in RFC7644 §3.9, attributes and excluded_attributes parameters can
be passed to model_dump().
The expected attribute notation is the one detailed on RFC7644 §3.10,
like urn:ietf:params:scim:schemas:core:2.0:User:userName, or userName for short.
>>> from scim2_models import User, Context
>>> user = User(user_name="[email protected]", display_name="bjensen")
>>> payload = user.model_dump(
... scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
... excluded_attributes=["displayName"]
... )
>>> assert payload == {
... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
... "userName": "[email protected]",
... }
Values read from attributes and excluded_attributes in SearchRequest objects can directly be used in model_dump().
Attributes inclusions and exclusions interact with attributes Returned, in the server response Contexts:
Typed ListResponse¶
ListResponse models take a type or a Union of types.
You must pass the type you expect in the response, e.g. ListResponse[User] or ListResponse[Union[User, Group]].
If a response resource type cannot be found, a pydantic.ValidationError will be raised.
>>> from typing import Union
>>> from scim2_models import User, Group, ListResponse
>>> payload = {
... "totalResults": 2,
... "itemsPerPage": 10,
... "startIndex": 1,
... "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
... "Resources": [
... {
... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
... "id": "2819c223-7f76-453a-919d-413861904646",
... "userName": "[email protected]",
... "meta": {
... "resourceType": "User",
... "created": "2010-01-23T04:56:22Z",
... "lastModified": "2011-05-13T04:42:34Z",
... "version": 'W\\/"3694e05e9dff590"',
... "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646",
... },
... },
... {
... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
... "id": "e9e30dba-f08f-4109-8486-d5c6a331660a",
... "displayName": "Tour Guides",
... "members": [
... {
... "value": "2819c223-7f76-453a-919d-413861904646",
... "$ref": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646",
... "display": "Babs Jensen",
... },
... {
... "value": "902c246b-6245-4190-8e05-00816be7344a",
... "$ref": "https://example.com/v2/Users/902c246b-6245-4190-8e05-00816be7344a",
... "display": "Mandy Pepperidge",
... },
... ],
... "meta": {
... "resourceType": "Group",
... "created": "2010-01-23T04:56:22Z",
... "lastModified": "2011-05-13T04:42:34Z",
... "version": 'W\\/"3694e05e9dff592"',
... "location": "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a",
... },
... },
... ],
... }
>>> response = ListResponse[Union[User, Group]].model_validate(payload)
>>> user, group = response.resources
>>> type(user)
<class 'scim2_models.resources.user.User'>
>>> type(group)
<class 'scim2_models.resources.group.Group'>
Schema extensions¶
RFC7643 §3.3 extensions are supported.
Any class inheriting from Extension can be passed as a Resource type parameter, e.g. user = User[EnterpriseUser] or user = User[Union[EnterpriseUser, SuperHero]].
Extensions attributes are accessed with brackets, e.g. user[EnterpriseUser].employee_number, where user[EnterpriseUser] is a shortcut for user["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"].
>>> import datetime
>>> from scim2_models import User, EnterpriseUser, Meta
>>> user = User[EnterpriseUser](
... id="2819c223-7f76-453a-919d-413861904646",
... user_name="[email protected]",
... meta=Meta(
... resource_type="User",
... created=datetime.datetime(
... 2010, 1, 23, 4, 56, 22, tzinfo=datetime.timezone.utc
... ),
... ),
... )
>>> user[EnterpriseUser] = EnterpriseUser(employee_number = "701984")
>>> user[EnterpriseUser].division="Theme Park"
>>> dump = user.model_dump()
>>> assert dump == {
... "schemas": [
... "urn:ietf:params:scim:schemas:core:2.0:User",
... "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
... ],
... "id": "2819c223-7f76-453a-919d-413861904646",
... "meta": {
... "resourceType": "User",
... "created": "2010-01-23T04:56:22Z"
... },
... "userName": "[email protected]",
... "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
... "employeeNumber": "701984",
... "division": "Theme Park",
... }
... }
Errors and Exceptions¶
scim2-models provides a hierarchy of exceptions corresponding to RFC7644 §3.12 error types.
Each exception can be converted to an Error response object or used in Pydantic validators.
Raising exceptions¶
Exceptions are named after their scimType value:
>>> from scim2_models import InvalidPathException, PathNotFoundException
>>> raise InvalidPathException(path="invalid..path")
Traceback (most recent call last):
...
scim2_models.exceptions.InvalidPathException: The path attribute was invalid or malformed
>>> raise PathNotFoundException(path="unknownAttr")
Traceback (most recent call last):
...
scim2_models.exceptions.PathNotFoundException: The specified path references a non-existent field
Converting to Error response¶
Use to_error() to convert an exception to an Error response:
>>> from scim2_models import InvalidPathException
>>> exc = InvalidPathException(path="invalid..path")
>>> error = exc.to_error()
>>> error.status
400
>>> error.scim_type
'invalidPath'
Converting from ValidationError¶
Use Error.from_validation_error to convert a single Pydantic error to an Error:
>>> from pydantic import ValidationError
>>> from scim2_models import Error, User
>>> from scim2_models.base import Context
>>> try:
... User.model_validate({"userName": None}, scim_ctx=Context.RESOURCE_CREATION_REQUEST)
... except ValidationError as exc:
... error = Error.from_validation_error(exc.errors()[0])
>>> error.scim_type
'invalidValue'
Use Error.from_validation_errors to convert all errors at once:
>>> try:
... User.model_validate({"userName": 123, "displayName": 456})
... except ValidationError as exc:
... errors = Error.from_validation_errors(exc)
>>> len(errors)
2
>>> [e.detail for e in errors]
['Input should be a valid string: username', 'Input should be a valid string: displayname']
The exhaustive list of exceptions is available in the reference.
Custom models¶
You can write your own model and use it the same way as the other scim2-models models.
Just inherit from Resource for your main resource, or Extension for extensions.
Use ComplexAttribute as base class for complex attributes:
>>> from typing import Annotated, Optional
>>> from scim2_models import Resource, Returned, Mutability, ComplexAttribute, URN
>>> from enum import Enum
>>> class PetType(ComplexAttribute):
... type: Optional[str]
... """The pet type like 'cat' or 'dog'."""
...
... color: Optional[str]
... """The pet color."""
>>> class Pet(Resource):
... __schema__ = URN("urn:example:schemas:Pet")
...
... name: Annotated[Optional[str], Mutability.immutable, Returned.always]
... """The name of the pet."""
...
... pet_type: Optional[PetType]
... """The pet type."""
You can annotate fields to indicate their Mutability and Returned.
If unset the default values will be read_write and default.
Warning
Be sure to make all the fields of your model Optional.
There will always be a Context in which this will be true.
There is a dedicated type for RFC7643 §2.3.7 Reference
that can take type parameters to represent RFC7643 §7 ‘referenceTypes’:
>>> from scim2_models import Reference
>>> class PetOwner(Resource):
... pet: Optional[Reference["Pet"]]
Reference has two special type parameters External and URI that matches RFC7643 §7 external and URI reference types.
Dynamic schemas from models¶
With Resource.to_schema and Extension.to_schema, any model can be exported as a Schema object.
This is useful for server implementations, so custom models or models provided by scim2-models can easily be exported on the /Schemas endpoint.
>>> from scim2_models import Resource, URN
>>> class MyCustomResource(Resource):
... """My awesome custom schema."""
...
... __schema__ = URN("urn:example:schemas:MyCustomResource")
...
... foobar: Optional[str]
...
>>> schema = MyCustomResource.to_schema()
>>> dump = schema.model_dump()
>>> assert dump == {
... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
... "id": "urn:example:schemas:MyCustomResource",
... "name": "MyCustomResource",
... "description": "My awesome custom schema.",
... "attributes": [
... {
... "caseExact": False,
... "multiValued": False,
... "mutability": "readWrite",
... "name": "foobar",
... "required": False,
... "returned": "default",
... "type": "string",
... "uniqueness": "none",
... },
... ],
... }
Dynamic models from schemas¶
Given a Schema object, scim2-models can dynamically generate a pythonic model to be used in your code
with the Resource.from_schema and Extension.from_schema methods.
payload = {
"id": "urn:ietf:params:scim:schemas:core:2.0:Group",
"name": "Group",
"description": "Group",
"attributes": [
{
"name": "displayName",
"type": "string",
"multiValued": false,
"description": "A human-readable name for the Group. REQUIRED.",
"required": false,
"caseExact": false,
"mutability": "readWrite",
"returned": "default",
"uniqueness": "none"
},
...
],
}
schema = Schema.model_validate(payload)
Group = Resource.from_schema(schema)
my_group = Group(display_name="This is my group")
Client applications can use this to dynamically discover server resources by browsing the /Schemas endpoint.
Tip
Sub-Attribute models are automatically created and set as members of their parent model classes.
For instance the RFC7643 Group members sub-attribute can be accessed with Group.Members.
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
"id": "urn:ietf:params:scim:schemas:core:2.0:Group",
"name": "Group",
"description": "Group",
"attributes": [
{
"name": "displayName",
"type": "string",
"multiValued": false,
"description": "A human-readable name for the Group. REQUIRED.",
"required": true,
"caseExact": false,
"mutability": "readWrite",
"returned": "default",
"uniqueness": "none"
},
{
"name": "members",
"type": "complex",
"multiValued": true,
"description": "A list of members of the Group.",
"required": false,
"subAttributes": [
{
"name": "value",
"type": "string",
"multiValued": false,
"description": "Identifier of the member of this Group.",
"required": false,
"caseExact": false,
"mutability": "immutable",
"returned": "default",
"uniqueness": "none"
},
{
"name": "$ref",
"type": "reference",
"referenceTypes": [
"User",
"Group"
],
"multiValued": false,
"description": "The URI corresponding to a SCIM resource that is a member of this Group.",
"required": false,
"caseExact": false,
"mutability": "immutable",
"returned": "default",
"uniqueness": "none"
},
{
"name": "type",
"type": "string",
"multiValued": false,
"description": "A label indicating the type of resource, e.g., 'User' or 'Group'.",
"required": false,
"caseExact": false,
"canonicalValues": [
"User",
"Group"
],
"mutability": "immutable",
"returned": "default",
"uniqueness": "none"
},
{
"name": "display",
"type": "string",
"multiValued": false,
"description": "A human-readable name for the group member, primarily used for display purposes.",
"required": false,
"caseExact": false,
"mutability": "readOnly",
"returned": "default",
"uniqueness": "none"
}
],
"mutability": "readWrite",
"returned": "default"
}
],
"meta": {
"resourceType": "Schema",
"location": "/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:Group"
}
}
Replace operations¶
When handling a PUT request, validate the incoming payload with the
RESOURCE_REPLACEMENT_REQUEST context, then call
replace() against the existing resource to
verify that immutable attributes have not been
modified.
>>> from scim2_models import User, Context
>>> existing = User(user_name="bjensen")
>>> replacement = User.model_validate(
... {"userName": "bjensen"},
... scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
... )
>>> replacement.replace(existing)
If an immutable attribute differs, a MutabilityException
is raised.
Patch operations¶
PatchOp allows you to apply patch operations to modify SCIM resources.
The patch() method applies operations in sequence and returns whether the resource was modified. The return code is a boolean indicating whether the object has been modified by the operations.
Note
PatchOp takes a type parameter that should be the class of the resource
that is expected to be patched.
>>> from scim2_models import User, PatchOp, PatchOperation
>>> user = User(user_name="john.doe", nick_name="Johnny")
>>> payload = {
... "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
... "Operations": [
... {"op": "replace", "path": "nickName", "value": "John" },
... {"op": "add", "path": "emails", "value": [{"value": "[email protected]"}]},
... ]
... }
>>> patch = PatchOp[User].model_validate(
... payload, scim_ctx=Context.RESOURCE_PATCH_REQUEST
... )
>>> modified = patch.patch(user)
>>> print(modified)
True
>>> print(user.nick_name)
John
>>> print(user.emails[0].value)
[email protected]
Warning
Patch operations are validated in the RESOURCE_PATCH_REQUEST
context. Make sure to validate patch operations with the correct context to
ensure proper validation of mutability and required constraints.
Bulk operations¶
Todo
Bulk operations are not implemented yet, but any help is welcome!