Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 41 additions & 40 deletions doc/guides/_examples/fastapi_example.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
from http import HTTPStatus
from typing import Annotated

from fastapi import APIRouter
from fastapi import Depends
Expand All @@ -17,6 +18,9 @@
from scim2_models import ResponseParameters
from scim2_models import Schema
from scim2_models import SCIMException
from scim2_models import SCIMSerializer
from scim2_models import ServiceProviderConfig
from scim2_models import SCIMValidator
from scim2_models import SearchRequest
from scim2_models import User

Expand All @@ -42,11 +46,16 @@ class SCIMResponse(Response):

media_type = "application/scim+json"

def __init__(self, content: str, **kwargs):
def __init__(self, content=None, **kwargs):
if isinstance(content, (dict, list)):
content = json.dumps(content, ensure_ascii=False)
super().__init__(content=content, **kwargs)
meta = json.loads(content).get("meta", {})
if version := meta.get("version"):
self.headers["ETag"] = version
try:
meta = json.loads(content).get("meta", {})
if version := meta.get("version"):
self.headers["ETag"] = version
except (json.JSONDecodeError, AttributeError, TypeError):
pass


router = APIRouter(prefix="/scim/v2", default_response_class=SCIMResponse)
Expand Down Expand Up @@ -137,47 +146,44 @@ async def get_user(request: Request, app_record: dict = Depends(resolve_user)):

# -- patch-user-start --
@router.patch("/Users/{user_id}")
async def patch_user(request: Request, app_record: dict = Depends(resolve_user)):
async def patch_user(
request: Request,
patch: Annotated[
PatchOp[User], SCIMValidator(Context.RESOURCE_PATCH_REQUEST)
],
app_record: dict = Depends(resolve_user),
) -> Annotated[User, SCIMSerializer(Context.RESOURCE_PATCH_RESPONSE)]:
"""Apply a SCIM PatchOp to an existing user."""
check_etag(app_record, request)
scim_user = to_scim_user(app_record, resource_location(request, app_record))
patch = PatchOp[User].model_validate(
await request.json(),
scim_ctx=Context.RESOURCE_PATCH_REQUEST,
)
patch.patch(scim_user)

updated_record = from_scim_user(scim_user)
save_record(updated_record)

return SCIMResponse(
scim_user.model_dump_json(scim_ctx=Context.RESOURCE_PATCH_RESPONSE),
)
return to_scim_user(updated_record, resource_location(request, updated_record))
# -- patch-user-end --


# -- put-user-start --
@router.put("/Users/{user_id}")
async def replace_user(request: Request, app_record: dict = Depends(resolve_user)):
async def replace_user(
request: Request,
replacement: Annotated[
User, SCIMValidator(Context.RESOURCE_REPLACEMENT_REQUEST)
],
app_record: dict = Depends(resolve_user),
) -> Annotated[User, SCIMSerializer(Context.RESOURCE_REPLACEMENT_RESPONSE)]:
"""Replace an existing user with a full SCIM resource."""
check_etag(app_record, request)
existing_user = to_scim_user(app_record, resource_location(request, app_record))
replacement = User.model_validate(
await request.json(),
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
)
replacement.replace(existing_user)

replacement.id = existing_user.id
updated_record = from_scim_user(replacement)
save_record(updated_record)

response_user = to_scim_user(
updated_record, resource_location(request, updated_record)
)
return SCIMResponse(
response_user.model_dump_json(scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE),
)
return to_scim_user(updated_record, resource_location(request, updated_record))
# -- put-user-end --


Expand Down Expand Up @@ -219,21 +225,18 @@ async def list_users(request: Request):


# -- create-user-start --
@router.post("/Users")
async def create_user(request: Request):
@router.post("/Users", status_code=HTTPStatus.CREATED)
async def create_user(
request: Request,
request_user: Annotated[
User, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)
],
) -> Annotated[User, SCIMSerializer(Context.RESOURCE_CREATION_RESPONSE)]:
"""Validate a SCIM creation payload and store the new user."""
request_user = User.model_validate(
await request.json(),
scim_ctx=Context.RESOURCE_CREATION_REQUEST,
)
app_record = from_scim_user(request_user)
save_record(app_record)

response_user = to_scim_user(app_record, resource_location(request, app_record))
return SCIMResponse(
response_user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE),
status_code=HTTPStatus.CREATED,
)
return to_scim_user(app_record, resource_location(request, app_record))
# -- create-user-end --
# -- collection-end --

Expand Down Expand Up @@ -305,13 +308,11 @@ async def get_resource_type_by_id(resource_type_id: str):

# -- service-provider-config-start --
@router.get("/ServiceProviderConfig")
async def get_service_provider_config():
async def get_service_provider_config() -> Annotated[
ServiceProviderConfig, SCIMSerializer(Context.RESOURCE_QUERY_RESPONSE)
]:
"""Return the SCIM service provider configuration."""
return SCIMResponse(
service_provider_config.model_dump_json(
scim_ctx=Context.RESOURCE_QUERY_RESPONSE
),
)
return service_provider_config
# -- service-provider-config-end --
# -- discovery-end --

Expand Down
51 changes: 45 additions & 6 deletions doc/guides/fastapi.rst
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ Endpoints
The routes below serve ``/Users``, but the same structure applies to any resource type:
replace the mapping helpers, the model class, and the URL prefix to expose ``/Groups`` or
any other collection.
All route functions are ``async`` because the request body is read with ``await request.json()``.
Write endpoints use :class:`~scim2_models.SCIMValidator` to let FastAPI parse and validate
the request body with the correct SCIM :class:`~scim2_models.Context` automatically.
Read endpoints still build responses explicitly because they need to forward
``attributes`` / ``excludedAttributes`` query parameters.

GET /Users/<id>
^^^^^^^^^^^^^^^
Expand Down Expand Up @@ -113,8 +116,9 @@ No SCIM serialization is needed.
PATCH /Users/<id>
^^^^^^^^^^^^^^^^^

Validate the patch payload with :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`,
apply it to a SCIM conversion of the native record with :meth:`~scim2_models.PatchOp.patch`,
The patch payload is validated through :class:`~scim2_models.SCIMValidator` with
:attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`.
Apply it to a SCIM conversion of the native record with :meth:`~scim2_models.PatchOp.patch`,
convert back to native and persist, then serialize the result with
:attr:`~scim2_models.Context.RESOURCE_PATCH_RESPONSE`.
:class:`~scim2_models.PatchOp` is generic and works with any resource type.
Expand All @@ -127,7 +131,7 @@ convert back to native and persist, then serialize the result with
PUT /Users/<id>
^^^^^^^^^^^^^^^

Validate the full replacement payload with
The full replacement payload is validated through :class:`~scim2_models.SCIMValidator` with
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, then call
:meth:`~scim2_models.Resource.replace` to verify that immutable attributes
have not been modified.
Expand Down Expand Up @@ -156,8 +160,9 @@ Pass ``req.attributes`` and ``req.excluded_attributes`` to
POST /Users
^^^^^^^^^^^

Validate the creation payload with :attr:`~scim2_models.Context.RESOURCE_CREATION_REQUEST`,
convert to native and persist, then serialize the created resource with
The creation payload is validated through :class:`~scim2_models.SCIMValidator` with
:attr:`~scim2_models.Context.RESOURCE_CREATION_REQUEST`.
Convert to native and persist, then serialize the created resource with
:attr:`~scim2_models.Context.RESOURCE_CREATION_RESPONSE`.

.. literalinclude:: _examples/fastapi_example.py
Expand Down Expand Up @@ -245,6 +250,40 @@ features the server supports (patch, bulk, filtering, etc.).
:start-after: # -- service-provider-config-start --
:end-before: # -- service-provider-config-end --

Idiomatic type annotations
==========================

The endpoints above use ``await request.json()`` and explicit
:meth:`~scim2_models.Resource.model_validate` / :meth:`~scim2_models.Resource.model_dump_json` calls.
:mod:`scim2_models` also provides two Pydantic-compatible annotations that let you use
FastAPI's native body parsing and response serialization with the correct SCIM context:

- :class:`~scim2_models.SCIMValidator` — injects a SCIM :class:`~scim2_models.Context` during
**input validation** (request body parsing).
- :class:`~scim2_models.SCIMSerializer` — injects a SCIM :class:`~scim2_models.Context` during
**output serialization** (response rendering).

.. code-block:: python

from typing import Annotated
from scim2_models import Context, SCIMSerializer, SCIMValidator, User

@router.post("/Users", status_code=201)
async def create_user(
user: Annotated[User, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)]
) -> Annotated[User, SCIMSerializer(Context.RESOURCE_CREATION_RESPONSE)]:
app_record = from_scim_user(user)
save_record(app_record)
return to_scim_user(app_record, ...)

These annotations are **pure Pydantic** and carry no dependency on FastAPI — they work with any
framework that respects :data:`typing.Annotated` metadata.

:class:`~scim2_models.SCIMSerializer` on the return type lets FastAPI handle the response
serialization automatically.
When you need to pass ``attributes`` or ``excluded_attributes`` (for ``GET`` endpoints),
use the explicit ``model_dump_json`` approach shown in the previous sections instead.

Complete example
================

Expand Down
55 changes: 55 additions & 0 deletions doc/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,61 @@ fields with unexpected values will raise :class:`~pydantic.ValidationError`:
... except pydantic.ValidationError:
... obj = Error(...)

Context annotations
===================

:class:`~scim2_models.SCIMValidator` and :class:`~scim2_models.SCIMSerializer` are
`Pydantic Annotated markers <https://docs.pydantic.dev/latest/concepts/types/#custom-types>`_
that embed a :class:`~scim2_models.Context` directly in the type hint.
They are useful for web framework integration where the framework handles parsing and
serialization automatically.

:class:`~scim2_models.SCIMValidator` injects the context during **validation**:

.. code-block:: python

>>> from typing import Annotated
>>> from pydantic import TypeAdapter
>>> from scim2_models import User, Context, SCIMValidator

>>> adapter = TypeAdapter(
... Annotated[User, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)]
... )
>>> 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

:class:`~scim2_models.SCIMSerializer` injects the context during **serialization**:

.. code-block:: python

>>> from scim2_models import 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

These annotations are **pure Pydantic** and carry no dependency on any web framework.
In FastAPI for instance, they can be used directly in endpoint signatures:

.. code-block:: python

@router.post("/Users", status_code=201)
async def create_user(
user: Annotated[User, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)]
) -> Annotated[User, SCIMSerializer(Context.RESOURCE_CREATION_RESPONSE)]:
...

See the :doc:`guides/fastapi` guide for a complete example.

Attributes inclusions and exclusions
====================================

Expand Down
4 changes: 4 additions & 0 deletions scim2_models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from .annotated import SCIMSerializer
from .annotated import SCIMValidator
from .annotations import CaseExact
from .annotations import Mutability
from .annotations import Required
Expand Down Expand Up @@ -72,6 +74,8 @@
__all__ = [
"Address",
"AnyResource",
"SCIMSerializer",
"SCIMValidator",
"AnyExtension",
"Attribute",
"AuthenticationScheme",
Expand Down
Loading
Loading