diff --git a/doc/guides/_examples/fastapi_example.py b/doc/guides/_examples/fastapi_example.py index 4c29fa4..7e683f7 100644 --- a/doc/guides/_examples/fastapi_example.py +++ b/doc/guides/_examples/fastapi_example.py @@ -1,5 +1,6 @@ import json from http import HTTPStatus +from typing import Annotated from fastapi import APIRouter from fastapi import Depends @@ -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 @@ -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) @@ -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 -- @@ -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 -- @@ -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 -- diff --git a/doc/guides/fastapi.rst b/doc/guides/fastapi.rst index a7788ee..d280433 100644 --- a/doc/guides/fastapi.rst +++ b/doc/guides/fastapi.rst @@ -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/ ^^^^^^^^^^^^^^^ @@ -113,8 +116,9 @@ No SCIM serialization is needed. PATCH /Users/ ^^^^^^^^^^^^^^^^^ -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. @@ -127,7 +131,7 @@ convert back to native and persist, then serialize the result with PUT /Users/ ^^^^^^^^^^^^^^^ -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. @@ -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 @@ -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 ================ diff --git a/doc/tutorial.rst b/doc/tutorial.rst index 8c875be..8469443 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -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 `_ +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 ==================================== diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index bbec384..26e1a34 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -1,3 +1,5 @@ +from .annotated import SCIMSerializer +from .annotated import SCIMValidator from .annotations import CaseExact from .annotations import Mutability from .annotations import Required @@ -72,6 +74,8 @@ __all__ = [ "Address", "AnyResource", + "SCIMSerializer", + "SCIMValidator", "AnyExtension", "Attribute", "AuthenticationScheme", diff --git a/scim2_models/annotated.py b/scim2_models/annotated.py new file mode 100644 index 0000000..126c7ed --- /dev/null +++ b/scim2_models/annotated.py @@ -0,0 +1,88 @@ +"""Pydantic-compatible annotations for SCIM context validation and serialization. + +These markers can be used with :data:`typing.Annotated` to inject a SCIM +:class:`~scim2_models.Context` into Pydantic validation and serialization, +making integration with web frameworks like FastAPI idiomatic:: + + from typing import Annotated + + + @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)]: + ... + return response_user +""" + +import json +from typing import Any + +from pydantic import GetCoreSchemaHandler +from pydantic_core import CoreSchema +from pydantic_core import core_schema + +from scim2_models.context import Context + + +class SCIMValidator: + """Annotated marker that injects a SCIM context during Pydantic validation. + + When used in a :data:`typing.Annotated` type hint, the incoming data is + validated through :meth:`~scim2_models.base.BaseModel.model_validate` with + the given *ctx*, activating all SCIM-specific validators (mutability, + required fields, etc.). + + :param ctx: The SCIM context to use during validation. + """ + + def __init__(self, ctx: Context) -> None: + self.ctx = ctx + + def __get_pydantic_core_schema__( + self, source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + schema = handler(source_type) + ctx = self.ctx + + def validate_with_context(value: Any, handler: Any) -> Any: + if isinstance(value, dict): + return source_type.model_validate(value, scim_ctx=ctx) + return handler(value) + + return core_schema.no_info_wrap_validator_function( + validate_with_context, schema + ) + + +class SCIMSerializer: + """Annotated marker that injects a SCIM context during Pydantic serialization. + + When used in a :data:`typing.Annotated` type hint on a return type, the + response object is serialized through + :meth:`~scim2_models.scim_object.SCIMObject.model_dump_json` with the + given *ctx*, applying returnability and mutability rules. + + :param ctx: The SCIM context to use during serialization. + """ + + def __init__(self, ctx: Context) -> None: + self.ctx = ctx + + def __get_pydantic_core_schema__( + self, source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + schema = handler(source_type) + ctx = self.ctx + + def serialize_with_context(value: Any, _handler: Any) -> Any: + return json.loads(value.model_dump_json(scim_ctx=ctx)) + + return core_schema.no_info_wrap_validator_function( + lambda v, h: h(v), + schema, + serialization=core_schema.wrap_serializer_function_ser_schema( + serialize_with_context, + schema=schema, + ), + ) diff --git a/tests/test_annotated.py b/tests/test_annotated.py new file mode 100644 index 0000000..14d78ef --- /dev/null +++ b/tests/test_annotated.py @@ -0,0 +1,138 @@ +from typing import Annotated + +import pytest +from pydantic import TypeAdapter +from pydantic import ValidationError + +from scim2_models import Context +from scim2_models import SCIMSerializer +from scim2_models import SCIMValidator +from scim2_models import User + + +def test_validator_strips_read_only_fields_on_creation(): + """read_only fields like 'id' are stripped during creation validation.""" + 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", + } + ) + assert user.user_name == "bjensen" + assert user.id is None + + +def test_validator_rejects_missing_required_field(): + """UserName is required in creation context and must raise on absence.""" + adapter = TypeAdapter( + Annotated[User, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)] + ) + with pytest.raises(ValidationError, match="required"): + adapter.validate_python( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + } + ) + + +def test_validator_accepts_valid_creation_payload(): + """A minimal valid creation payload passes validation.""" + 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", + } + ) + assert user.user_name == "bjensen" + + +def test_validator_passes_through_already_validated_instance(): + """An already-constructed User instance passes through without re-parsing.""" + adapter = TypeAdapter( + Annotated[User, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)] + ) + original = User(user_name="bjensen") + result = adapter.validate_python(original) + assert result is original + + +def test_validator_with_replacement_context(): + """Replacement context allows read_write and immutable fields.""" + adapter = TypeAdapter( + Annotated[User, SCIMValidator(Context.RESOURCE_REPLACEMENT_REQUEST)] + ) + user = adapter.validate_python( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen", + "displayName": "Barbara Jensen", + } + ) + assert user.display_name == "Barbara Jensen" + + +def test_serializer_excludes_write_only_fields(): + """write_only fields like 'password' are excluded in query response.""" + adapter = TypeAdapter( + Annotated[User, SCIMSerializer(Context.RESOURCE_QUERY_RESPONSE)] + ) + user = User(user_name="bjensen", password="secret") + user.id = "123" + data = adapter.dump_python(user) + assert "password" not in data + assert data["userName"] == "bjensen" + + +def test_serializer_includes_id_in_response(): + """Server-assigned fields like 'id' are present in response serialization.""" + adapter = TypeAdapter( + Annotated[User, SCIMSerializer(Context.RESOURCE_CREATION_RESPONSE)] + ) + user = User(user_name="bjensen") + user.id = "123" + data = adapter.dump_python(user) + assert data["id"] == "123" + assert data["userName"] == "bjensen" + + +def test_serializer_uses_camel_case_keys(): + """Serialized keys use camelCase as required by SCIM.""" + adapter = TypeAdapter( + Annotated[User, SCIMSerializer(Context.RESOURCE_QUERY_RESPONSE)] + ) + user = User(user_name="bjensen", display_name="Barbara") + user.id = "123" + data = adapter.dump_python(user) + assert "userName" in data + assert "displayName" in data + + +def test_validator_and_serializer_combined(): + """SCIMValidator on input and SCIMSerializer on output work together.""" + input_adapter = TypeAdapter( + Annotated[User, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)] + ) + output_adapter = TypeAdapter( + Annotated[User, SCIMSerializer(Context.RESOURCE_CREATION_RESPONSE)] + ) + user = input_adapter.validate_python( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen", + "id": "should-be-stripped", + "password": "secret", + } + ) + assert user.id is None + + user.id = "server-assigned-id" + data = output_adapter.dump_python(user) + assert data["id"] == "server-assigned-id" + assert "password" not in data